diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 676c4b4657b..39e968d941b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -29,8 +29,9 @@ libs/common/src/auth @bitwarden/team-auth-dev ## Tools team files ## apps/browser/src/tools @bitwarden/team-tools-dev apps/cli/src/tools @bitwarden/team-tools-dev -apps/desktop/src/app/tools @bitwarden/team-tools-dev +apps/desktop/desktop_native/bitwarden_chromium_import_helper @bitwarden/team-tools-dev apps/desktop/desktop_native/chromium_importer @bitwarden/team-tools-dev +apps/desktop/src/app/tools @bitwarden/team-tools-dev apps/web/src/app/tools @bitwarden/team-tools-dev libs/angular/src/tools @bitwarden/team-tools-dev libs/common/src/models/export @bitwarden/team-tools-dev diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml new file mode 100644 index 00000000000..49b91d2d1a1 --- /dev/null +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -0,0 +1,167 @@ +# This workflow runs TypeScript compatibility checks when the SDK is updated. +# Triggered automatically by the SDK repository via repository_dispatch when SDK PRs are created/updated. +name: SDK Breaking Change Check +run-name: "SDK breaking change check (${{ github.event.client_payload.sdk_version }})" +on: + repository_dispatch: + types: [sdk-breaking-change-check] + +permissions: + contents: read + actions: read + id-token: write + +jobs: + type-check: + name: TypeScript compatibility check + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + _SOURCE_REPO: ${{ github.event.client_payload.source_repo }} + _SDK_VERSION: ${{ github.event.client_payload.sdk_version }} + _ARTIFACTS_RUN_ID: ${{ github.event.client_payload.artifacts_info.run_id }} + _ARTIFACT_NAME: ${{ github.event.client_payload.artifacts_info.artifact_name }} + _CLIENT_LABEL: ${{ github.event.client_payload.client_label }} + + steps: + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: gh-org-bitwarden + secrets: "BW-GHAPP-ID,BW-GHAPP-KEY" + + - name: Generate GH App token + uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 + id: app-token + with: + app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} + private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + - name: Log out from Azure + uses: bitwarden/gh-actions/azure-logout@main + - name: Validate inputs + run: | + echo "🔍 Validating required client_payload fields..." + + if [ -z "${_SOURCE_REPO}" ] || [ -z "${_SDK_VERSION}" ] || [ -z "${_ARTIFACTS_RUN_ID}" ] || [ -z "${_ARTIFACT_NAME}" ]; then + echo "::error::Missing required client_payload fields" + echo "SOURCE_REPO: ${_SOURCE_REPO}" + echo "SDK_VERSION: ${_SDK_VERSION}" + echo "ARTIFACTS_RUN_ID: ${_ARTIFACTS_RUN_ID}" + echo "ARTIFACT_NAME: ${_ARTIFACT_NAME}" + echo "CLIENT_LABEL: ${_CLIENT_LABEL}" + exit 1 + fi + + echo "✅ All required payload fields are present" + - name: Check out clients repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Get Node Version + id: retrieve-node-version + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + cache: 'npm' + cache-dependency-path: '**/package-lock.json' + node-version: ${{ steps.retrieve-node-version.outputs.node_version }} + + - name: Install Node dependencies + run: | + echo "📦 Installing Node dependencies with retry logic..." + + RETRY_COUNT=0 + MAX_RETRIES=3 + while [ ${RETRY_COUNT} -lt ${MAX_RETRIES} ]; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo "🔄 npm ci attempt ${RETRY_COUNT} of ${MAX_RETRIES}..." + + if npm ci; then + echo "✅ npm ci successful" + break + else + echo "❌ npm ci attempt ${RETRY_COUNT} failed" + [ ${RETRY_COUNT} -lt ${MAX_RETRIES} ] && sleep 5 + fi + done + + if [ ${RETRY_COUNT} -eq ${MAX_RETRIES} ]; then + echo "::error::npm ci failed after ${MAX_RETRIES} attempts" + exit 1 + fi + + - name: Download SDK artifacts + uses: bitwarden/gh-actions/download-artifacts@main + with: + github_token: ${{ steps.app-token.outputs.token }} + workflow: build-wasm-internal.yml + workflow_conclusion: success + run_id: ${{ env._ARTIFACTS_RUN_ID }} + artifacts: ${{ env._ARTIFACT_NAME }} + repo: ${{ env._SOURCE_REPO }} + path: ./sdk-internal + if_no_artifact_found: fail + + - name: Override SDK using npm link + working-directory: ./ + run: | + echo "🔧 Setting up SDK override using npm link..." + echo "📊 SDK Version: ${_SDK_VERSION}" + echo "📦 Artifact Source: ${_SOURCE_REPO} run ${_ARTIFACTS_RUN_ID}" + + echo "📋 SDK package contents:" + ls -la ./sdk-internal/ + + echo "🔗 Creating npm link to SDK package..." + if ! npm link ./sdk-internal; then + echo "::error::Failed to link SDK package" + exit 1 + fi + + - name: Run TypeScript compatibility check + run: | + + echo "🔍 Running TypeScript type checking for ${_CLIENT_LABEL} client with SDK version: ${_SDK_VERSION}" + echo "🎯 Type checking command: npm run test:types" + + # Add GitHub Step Summary output + { + echo "## 📊 TypeScript Compatibility Check (${_CLIENT_LABEL})" + echo "- **Client**: ${_CLIENT_LABEL}" + echo "- **SDK Version**: ${_SDK_VERSION}" + echo "- **Source Repository**: ${_SOURCE_REPO}" + echo "- **Artifacts Run ID**: ${_ARTIFACTS_RUN_ID}" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + + TYPE_CHECK_START=$(date +%s) + + # Run type check with timeout - exit code determines gh run watch result + if timeout 10m npm run test:types; then + TYPE_CHECK_END=$(date +%s) + TYPE_CHECK_DURATION=$((TYPE_CHECK_END - TYPE_CHECK_START)) + echo "✅ TypeScript compilation successful for ${_CLIENT_LABEL} client (${TYPE_CHECK_DURATION}s)" + echo "✅ **Result**: TypeScript compilation successful" >> "$GITHUB_STEP_SUMMARY" + echo "No breaking changes detected in ${_CLIENT_LABEL} client for SDK version ${_SDK_VERSION}" >> "$GITHUB_STEP_SUMMARY" + else + TYPE_CHECK_END=$(date +%s) + TYPE_CHECK_DURATION=$((TYPE_CHECK_END - TYPE_CHECK_START)) + echo "❌ TypeScript compilation failed for ${_CLIENT_LABEL} client after ${TYPE_CHECK_DURATION}s - breaking changes detected" + echo "❌ **Result**: TypeScript compilation failed" >> "$GITHUB_STEP_SUMMARY" + echo "Breaking changes detected in ${_CLIENT_LABEL} client for SDK version ${_SDK_VERSION}" >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi \ No newline at end of file diff --git a/apps/browser/package.json b/apps/browser/package.json index 744b53688b2..82d2ad7ab7a 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2025.10.1", + "version": "2025.11.0", "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 35d21b59be9..ad36ba5854a 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "استخدام تسجيل الدخول الأحادي" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "مرحبًا بعودتك" }, @@ -588,6 +591,9 @@ "view": { "message": "عرض" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "تم تعديل العنصر" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "هل تريد حقاً أن ترسل إلى سلة المهملات؟" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "إظهار اقتراحات التعبئة التلقائية في حقول النموذج" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "عرض الهويات كاقتراحات" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "خطأ فك التشفير" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "تعذر على بتواردن فك تشفير العنصر (العناصر) المدرجة أدناه." }, @@ -4011,6 +4053,15 @@ "message": "ملء تلقائي عند تعيين تحميل الصفحة لاستخدام الإعداد الافتراضي.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 2c9a496a95c..9a0239d2a34 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Vahid daxil olma üsulunu istifadə et" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Təşkilatınız, vahid daxil olma tələb edir." + }, "welcomeBack": { "message": "Yenidən xoş gəlmisiniz" }, @@ -588,6 +591,9 @@ "view": { "message": "Bax" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "Girişə bax" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Element saxlanıldı" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Həqiqətən tullantı qutusuna göndərmək istəyirsiniz?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Avto-doldurmanı söndür" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Avto-doldurma təkliflərini form xanalarında göstər" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Kimlikləri təklif kimi göstər" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Şifrə açma xətası" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden, aşağıda sadalanan seyf element(lər)inin şifrəsini aça bilmədi." }, @@ -4011,6 +4053,15 @@ "message": "\"Səhifə yüklənəndə avto-doldurma\" özəlliyi ilkin ayarı istifadə etmək üzrə ayarlandı.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Yan naviqasiyanı aç/bağla" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Bu ayar, təşkilatınızın siyasəti tərəfindən sıradan çıxarılıb.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Poçt kodu" + }, + "cardNumberLabel": { + "message": "Kart nömrəsi" } } diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index f9fd41cf6e7..35aaddc13b2 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Выкарыстаць аднаразовы ўваход" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "З вяртаннем" }, @@ -588,6 +591,9 @@ "view": { "message": "Прагляд" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Элемент адрэдагаваны" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Вы сапраўды хочаце адправіць гэты элемент у сметніцу?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index d8c288d9fca..68b962837eb 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Използване на еднократна идентификация" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Вашата организация изисква еднократно удостоверяване." + }, "welcomeBack": { "message": "Добре дошли отново" }, @@ -588,6 +591,9 @@ "view": { "message": "Преглед" }, + "viewAll": { + "message": "Показване на всички" + }, "viewLogin": { "message": "Преглед на елемента за вписване" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Елементът е редактиран" }, + "savedWebsite": { + "message": "Запазен уеб сайт" + }, + "savedWebsites": { + "message": "Запазени уеб сайтове ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Наистина ли искате да изтриете елемента?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Изключване на автоматичното попълване" }, + "confirmAutofill": { + "message": "Потвърждаване на автоматичното попълване" + }, + "confirmAutofillDesc": { + "message": "Този уеб сайт не съвпада със запазените данни за вписване. Преди да попълните данните си, уверете се, че имате вяра на сайта." + }, "showInlineMenuLabel": { "message": "Показване на предложения за авт. попълване на полетата във формуляри" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Как Битуорден защитава данните Ви от измами?" + }, + "currentWebsite": { + "message": "Текущ уеб сайт" + }, + "autofillAndAddWebsite": { + "message": "Автоматично попълване и добавяне на този уеб сайт" + }, + "autofillWithoutAdding": { + "message": "Автоматично попълване без добавяне" + }, + "doNotAutofill": { + "message": "Да не се попълва автоматично" + }, "showInlineMenuIdentitiesLabel": { "message": "Показване на идентичности като предложения" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Грешка при дешифриране" }, + "errorGettingAutoFillData": { + "message": "Грешка при получаването на данните за автоматично попълване" + }, "couldNotDecryptVaultItemsBelow": { "message": "Битоурден не може да дешифрира елементите от трезора посочени по-долу." }, @@ -4011,6 +4053,15 @@ "message": "Автоматичното попълване при зареждане на страницата използва настройката си по подразбиране.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Автоматичното попълване не може да бъде извършено" + }, + "cannotAutofillExactMatch": { + "message": "По подразбиране е зададена настройката „Точно съвпадение“. Текущият уеб сайт не съвпада точно със запазените данни за вход в този запис." + }, + "okay": { + "message": "Добре" + }, "toggleSideNavigation": { "message": "Превключване на страничната навигация" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Тази настройка е изключена съгласно политиката на организацията Ви.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Пощенски код" + }, + "cardNumberLabel": { + "message": "Номер на картата" } } diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 1b8c289f717..25e37c06745 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -3,7 +3,7 @@ "message": "Bitwarden" }, "appLogoLabel": { - "message": "Bitwarden logo" + "message": "বিটওয়ার্ডেন লোগো" }, "extName": { "message": "Bitwarden Password Manager", @@ -31,8 +31,11 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { - "message": "Welcome back" + "message": "আবারও স্বাগতম" }, "setAStrongPassword": { "message": "Set a strong password" @@ -276,10 +279,10 @@ "message": "Send a verification code to your email" }, "sendCode": { - "message": "Send code" + "message": "কোড পাঠান" }, "codeSent": { - "message": "Code sent" + "message": "কোড পাঠানো হয়েছে" }, "verificationCode": { "message": "যাচাইকরণ কোড" @@ -300,7 +303,7 @@ "message": "Continue to Help Center?" }, "continueToHelpCenterDesc": { - "message": "Learn more about how to use Bitwarden on the Help Center." + "message": "সহায়তা কেন্দ্রে বিটওয়ার্ডেন কীভাবে ব্যবহার করতে হয় সে সম্পর্কে আরও জানুন।" }, "continueToBrowserExtensionStore": { "message": "Continue to browser extension store?" @@ -588,6 +591,9 @@ "view": { "message": "দেখুন" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "সম্পাদিত বস্তু" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "আপনি কি সত্যিই আবর্জনাতে পাঠাতে চান?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index 8cc0d947199..566f0e7077e 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 4483967ab33..a5e00afae0c 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Inici de sessió únic" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Benvingut/da de nou" }, @@ -588,6 +591,9 @@ "view": { "message": "Visualitza" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Element guardat" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Esteu segur que voleu suprimir aquest element?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Mostra suggeriments d'emplenament automàtic als camps del formulari" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Mostra identitats com a suggeriments" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Error de desxifrat" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden no ha pogut desxifrar els elements de la caixa forta que s'indiquen a continuació." }, @@ -4011,6 +4053,15 @@ "message": "S'ha configurat l'emplenament automàtic en carregar la pàgina perquè utilitze la configuració predeterminada.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Canvia a la navegació lateral" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index b9383416eb4..5dd4a6a6efc 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Použít jednotné přihlášení" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Vaše organizace vyžaduje jednotné přihlášení." + }, "welcomeBack": { "message": "Vítejte zpět" }, @@ -588,6 +591,9 @@ "view": { "message": "Zobrazit" }, + "viewAll": { + "message": "Zobrazit vše" + }, "viewLogin": { "message": "Zobrazit přihlašovací údaje" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Položka byla uložena" }, + "savedWebsite": { + "message": "Uložená webová stránka" + }, + "savedWebsites": { + "message": "Uložené webové stránky ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Opravdu chcete položku přesunout do koše?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Vypnout automatické vyplňování" }, + "confirmAutofill": { + "message": "Potvrdit automatické vyplňování" + }, + "confirmAutofillDesc": { + "message": "Tato stránka neodpovídá Vašim uloženým přihlašovacím údajům. Před vyplněním přihlašovacích údajů se ujistěte, že se jedná o důvěryhodný web." + }, "showInlineMenuLabel": { "message": "Zobrazit návrhy automatického vyplňování v polích formuláře" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Jak Bitwarden chrání Vaše data před phishingem?" + }, + "currentWebsite": { + "message": "Aktuální webová stránka" + }, + "autofillAndAddWebsite": { + "message": "Automatické vyplňování a přidání této stránky" + }, + "autofillWithoutAdding": { + "message": "Automatické vyplňování bez přidání" + }, + "doNotAutofill": { + "message": "Nevyplňovat automaticky" + }, "showInlineMenuIdentitiesLabel": { "message": "Zobrazit identity jako návrhy" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Chyba dešifrování" }, + "errorGettingAutoFillData": { + "message": "Chyba při načítání dat automatického vyplňování" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden nemohl dešifrovat níže uvedené položky v trezoru." }, @@ -4011,6 +4053,15 @@ "message": "Automatické vyplnění při načítání stránky bylo nastaveno na výchozí nastavení.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Nelze automaticky vyplňovat" + }, + "cannotAutofillExactMatch": { + "message": "Výchozí shoda je nastavena na \"Přesná shoda\". Aktuální web neodpovídá přesně uloženým přihlašovacím údajům pro tuto položku." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Přepnout boční navigaci" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Toto nastavení je zakázáno zásadami Vaší organizace.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / PSČ" + }, + "cardNumberLabel": { + "message": "Číslo karty" } } diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index c18633c281c..1f46f034f5e 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Croeso nôl" }, @@ -588,6 +591,9 @@ "view": { "message": "Gweld" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Eitem wedi'i chadw" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Ydych chi wir eisiau anfon i'r sbwriel?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 0f92552c9c1..7e1f66478cf 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Brug Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Velkommen tilbage" }, @@ -588,6 +591,9 @@ "view": { "message": "Vis" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Element gemt" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Er du sikker på, at du sende til papirkurven?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Vis autoudfyld-menu i formularfelter" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Vis identiteter som forslag" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Dekrypteringsfejl" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden kunne ikke dekryptere boks-emne(r) anført nedenfor." }, @@ -4011,6 +4053,15 @@ "message": "Autoudfyldning ved sideindlæsning sat til standardindstillingen.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Slå sidenavigering til/fra" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 411b73be447..9527c15e6a3 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Single Sign-on verwenden" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Willkommen zurück" }, @@ -588,6 +591,9 @@ "view": { "message": "Anzeigen" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "Zugangsdaten anzeigen" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Eintrag gespeichert" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Soll dieser Eintrag wirklich in den Papierkorb verschoben werden?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Auto-Ausfüllen deaktivieren" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Vorschläge zum Auto-Ausfüllen in Formularfeldern anzeigen" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Identitäten als Vorschläge anzeigen" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Entschlüsselungsfehler" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden konnte folgende(n) Tresor-Eintrag/Einträge nicht entschlüsseln." }, @@ -4011,6 +4053,15 @@ "message": "Auto-Ausfüllen beim Laden einer Seite wurde auf die Standardeinstellung gesetzt.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Seitennavigation umschalten" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Diese Einstellung ist durch die Richtlinien deiner Organisation deaktiviert.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "PLZ / Postleitzahl" + }, + "cardNumberLabel": { + "message": "Kartennummer" } } diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 025a66c5cde..230f5d60423 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Χρήση ενιαίας σύνδεσης" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Καλώς ήρθατε" }, @@ -551,18 +554,18 @@ "message": "Επαναφορά αναζήτησης" }, "archiveNoun": { - "message": "Archive", + "message": "Αρχειοθήκη", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Αρχειοθέτηση", "description": "Verb" }, "unArchive": { "message": "Unarchive" }, "itemsInArchive": { - "message": "Items in archive" + "message": "Στοιχεία στην αρχειοθήκη" }, "noItemsInArchive": { "message": "No items in archive" @@ -588,8 +591,11 @@ "view": { "message": "Προβολή" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { - "message": "View login" + "message": "Προβολή σύνδεσης" }, "noItemsInList": { "message": "Δεν υπάρχουν στοιχεία στη λίστα." @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Το αντικείμενο αποθηκεύτηκε" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το στοιχείο;" }, @@ -1213,7 +1231,7 @@ "description": "Message prompting user to undertake completion of another security task." }, "saveFailure": { - "message": "Error saving", + "message": "Σφάλμα αποθήκευσης", "description": "Error message shown when the system fails to save login details." }, "saveFailureDetails": { @@ -1549,7 +1567,7 @@ "message": "Read security key" }, "readingPasskeyLoading": { - "message": "Reading passkey..." + "message": "Ανάγνωση κλειδιού πρόσβασης..." }, "passkeyAuthenticationFailed": { "message": "Passkey authentication failed" @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Απενεργοποίηση αυτόματης συμπλήρωσης" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Εμφάνιση μενού αυτόματης συμπλήρωσης στα πεδία της φόρμας" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Εμφάνιση ταυτοτήτων ως προτάσεις" }, @@ -1779,7 +1818,7 @@ "message": "Σύρετε για ταξινόμηση" }, "dragToReorder": { - "message": "Drag to reorder" + "message": "Σύρετε για αναδιάταξη" }, "cfTypeText": { "message": "Κείμενο" @@ -1865,7 +1904,7 @@ "message": "Κωδικός ασφαλείας" }, "cardNumber": { - "message": "card number" + "message": "αριθμός κάρτας" }, "ex": { "message": "πχ." @@ -1967,82 +2006,82 @@ "message": "Κλειδί SSH" }, "typeNote": { - "message": "Note" + "message": "Σημείωση" }, "newItemHeaderLogin": { - "message": "New Login", + "message": "Νέα σύνδεση", "description": "Header for new login item type" }, "newItemHeaderCard": { - "message": "New Card", + "message": "Νέα κάρτα", "description": "Header for new card item type" }, "newItemHeaderIdentity": { - "message": "New Identity", + "message": "Νέα ταυτότητα", "description": "Header for new identity item type" }, "newItemHeaderNote": { - "message": "New Note", + "message": "Νέα σημείωση", "description": "Header for new note item type" }, "newItemHeaderSshKey": { - "message": "New SSH key", + "message": "Νέο κλειδί SSH", "description": "Header for new SSH key item type" }, "newItemHeaderTextSend": { - "message": "New Text Send", + "message": "Νέο Send κειμένου", "description": "Header for new text send" }, "newItemHeaderFileSend": { - "message": "New File Send", + "message": "Νέο Send αρχείου", "description": "Header for new file send" }, "editItemHeaderLogin": { - "message": "Edit Login", + "message": "Επεξεργασία σύνδεσης", "description": "Header for edit login item type" }, "editItemHeaderCard": { - "message": "Edit Card", + "message": "Επεξεργασία κάρτας", "description": "Header for edit card item type" }, "editItemHeaderIdentity": { - "message": "Edit Identity", + "message": "Επεξεργασία ταυτότητας", "description": "Header for edit identity item type" }, "editItemHeaderNote": { - "message": "Edit Note", + "message": "Επεξεργασία σημείωσης", "description": "Header for edit note item type" }, "editItemHeaderSshKey": { - "message": "Edit SSH key", + "message": "Επεξεργασία κλειδιού SSH", "description": "Header for edit SSH key item type" }, "editItemHeaderTextSend": { - "message": "Edit Text Send", + "message": "Επεξεργασία Send κειμένου", "description": "Header for edit text send" }, "editItemHeaderFileSend": { - "message": "Edit File Send", + "message": "Επεξεργασία Send αρχείου", "description": "Header for edit file send" }, "viewItemHeaderLogin": { - "message": "View Login", + "message": "Προβολή σύνδεσης", "description": "Header for view login item type" }, "viewItemHeaderCard": { - "message": "View Card", + "message": "Προβολή κάρτας", "description": "Header for view card item type" }, "viewItemHeaderIdentity": { - "message": "View Identity", + "message": "Προβολή ταυτότητας", "description": "Header for view identity item type" }, "viewItemHeaderNote": { - "message": "View Note", + "message": "Προβολή σημείωσης", "description": "Header for view note item type" }, "viewItemHeaderSshKey": { - "message": "View SSH key", + "message": "Προβολή κλειδιού SSH", "description": "Header for view SSH key item type" }, "passwordHistory": { @@ -2594,7 +2633,7 @@ "message": "Αποκλεισμένοι τομείς" }, "learnMoreAboutBlockedDomains": { - "message": "Learn more about blocked domains" + "message": "Μάθετε περισσότερα για τους αποκλεισμένους τομείς" }, "excludedDomains": { "message": "Εξαιρούμενοι Τομείς" @@ -2618,7 +2657,7 @@ "message": "Αλλαγή" }, "changePassword": { - "message": "Change password", + "message": "Αλλαγή κωδικού πρόσβασής", "description": "Change password button for browser at risk notification on login." }, "changeButtonTitle": { @@ -2631,7 +2670,7 @@ } }, "atRiskPassword": { - "message": "At-risk password" + "message": "Κωδικός πρόσβασης σε κίνδυνο" }, "atRiskPasswords": { "message": "Κωδικοί πρόσβασης σε κίνδυνο" @@ -2726,7 +2765,7 @@ "message": "Illustration of the Bitwarden autofill menu displaying a generated password." }, "updateInBitwarden": { - "message": "Update in Bitwarden" + "message": "Ενημέρωση στο Bitwarden" }, "updateInBitwardenSlideDesc": { "message": "Bitwarden will then prompt you to update the password in the password manager.", @@ -3157,7 +3196,7 @@ "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." }, "organizationName": { - "message": "Organization name" + "message": "Όνομα οργανισμού" }, "keyConnectorDomain": { "message": "Key Connector domain" @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Σφάλμα αποκρυπτογράφησης" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Το Bitwarden δεν μπόρεσε να αποκρυπτογραφήσει τα αντικείμενα θησαυ/κίου που αναφέρονται παρακάτω." }, @@ -3582,10 +3624,10 @@ "message": "You denied a login attempt from another device. If this was you, try to log in with the device again." }, "device": { - "message": "Device" + "message": "Συσκευή" }, "loginStatus": { - "message": "Login status" + "message": "Κατάσταση σύνδεσης" }, "masterPasswordChanged": { "message": "Master password saved" @@ -3681,17 +3723,17 @@ "message": "Απομνημόνευση αυτής της συσκευής για την αυτόματες συνδέσεις στο μέλλον" }, "manageDevices": { - "message": "Manage devices" + "message": "Διαχείριση συσκευών" }, "currentSession": { - "message": "Current session" + "message": "Τρέχουσα συνεδρία" }, "mobile": { "message": "Mobile", "description": "Mobile app" }, "extension": { - "message": "Extension", + "message": "Επέκταση", "description": "Browser extension/addon" }, "desktop": { @@ -3715,7 +3757,7 @@ "message": "Request pending" }, "firstLogin": { - "message": "First login" + "message": "Πρώτη σύνδεση" }, "trusted": { "message": "Trusted" @@ -3724,10 +3766,10 @@ "message": "Needs approval" }, "devices": { - "message": "Devices" + "message": "Συσκευές" }, "accessAttemptBy": { - "message": "Access attempt by $EMAIL$", + "message": "Απόπειρα πρόσβασης από το $EMAIL$", "placeholders": { "email": { "content": "$1", @@ -3736,31 +3778,31 @@ } }, "confirmAccess": { - "message": "Confirm access" + "message": "Επιβεβαίωση πρόσβασης" }, "denyAccess": { - "message": "Deny access" + "message": "Άρνηση πρόσβασης" }, "time": { - "message": "Time" + "message": "Ώρα" }, "deviceType": { - "message": "Device Type" + "message": "Τύπος συσκευής" }, "loginRequest": { - "message": "Login request" + "message": "Αίτημα σύνδεσης" }, "thisRequestIsNoLongerValid": { - "message": "This request is no longer valid." + "message": "Αυτό το αίτημα δεν είναι πλέον έγκυρο." }, "loginRequestHasAlreadyExpired": { - "message": "Login request has already expired." + "message": "Το αίτημα σύνδεσης έχει ήδη λήξει." }, "justNow": { - "message": "Just now" + "message": "Μόλις τώρα" }, "requestedXMinutesAgo": { - "message": "Requested $MINUTES$ minutes ago", + "message": "Ζητήθηκε πριν από $MINUTES$ λεπτά", "placeholders": { "minutes": { "content": "$1", @@ -4011,6 +4053,15 @@ "message": "Η αυτόματη συμπλήρωση κατά τη φόρτωση της σελίδας ορίστηκε να χρησιμοποιεί τις προεπιλεγμένες ρυθμίσεις.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Εναλλαγή πλευρικής πλοήγησης" }, @@ -4476,7 +4527,7 @@ "description": "Link to match detection docs on warning dialog for advance match strategy" }, "uriAdvancedOption": { - "message": "Advanced options", + "message": "Σύνθετες επιλογές", "description": "Advanced option placeholder for uri option component" }, "confirmContinueToBrowserSettingsTitle": { @@ -4831,10 +4882,10 @@ "message": "Download from bitwarden.com now" }, "getItOnGooglePlay": { - "message": "Get it on Google Play" + "message": "Αποκτήστε το στο Google Play" }, "downloadOnTheAppStore": { - "message": "Download on the App Store" + "message": "Λήψη στο AppStore" }, "permanentlyDeleteAttachmentConfirmation": { "message": "Είστε σίγουροι ότι θέλετε να διαγράψετε οριστικά αυτό το συνημμένο;" @@ -5538,7 +5589,7 @@ "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." }, "introCarouselLabel": { - "message": "Welcome to Bitwarden" + "message": "Καλώς ορίσατε στο Bitwarden" }, "securityPrioritized": { "message": "Security, prioritized" @@ -5547,13 +5598,13 @@ "message": "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you." }, "quickLogin": { - "message": "Quick and easy login" + "message": "Εύκολη και γρήγορη σύνδεση" }, "quickLoginBody": { "message": "Set up biometric unlock and autofill to log into your accounts without typing a single letter." }, "secureUser": { - "message": "Level up your logins" + "message": "Αναβαθμίστε τις συνδέσεις σας" }, "secureUserBody": { "message": "Use the generator to create and save strong, unique passwords for all your accounts." @@ -5600,7 +5651,7 @@ "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, "phishingPageLearnMore": { - "message": "Learn more about phishing detection" + "message": "Μάθετε περισσότερα για την ανίχνευση ηλεκτρονικού «ψαρέματος»" }, "protectedBy": { "message": "Protected by $PRODUCT$", @@ -5621,7 +5672,7 @@ "message": "Search your vault for something else" }, "newLoginNudgeTitle": { - "message": "Save time with autofill" + "message": "Εξοικονομήστε χρόνο με την αυτόματη συμπλήρωση" }, "newLoginNudgeBodyOne": { "message": "Include a", @@ -5700,13 +5751,13 @@ "description": "'WebAssembly' is a technical term and should not be translated." }, "showMore": { - "message": "Show more" + "message": "Εμφάνιση περισσότερων" }, "showLess": { - "message": "Show less" + "message": "Εμφάνιση λιγότερων" }, "next": { - "message": "Next" + "message": "Επόμενο" }, "moreBreadcrumbs": { "message": "More breadcrumbs", @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 29601bfa70c..a7fe29e85d4 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1505,12 +1523,6 @@ "enableAutoBiometricsPrompt": { "message": "Ask for biometrics on launch" }, - "premiumRequired": { - "message": "Premium required" - }, - "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." - }, "authenticationTimeout": { "message": "Authentication timeout" }, @@ -1623,6 +1635,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -1676,9 +1691,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3276,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4050,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5718,6 +5766,30 @@ "atRiskLoginsSecured": { "message": "Great job securing your at-risk logins!" }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" + }, "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 7fd3091ef75..2058d68c55b 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organisation requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the bin?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Auto-fill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organisation's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 88b95533ff1..6c1b1e01139 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organisation requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Edited item" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Are you sure you want to delete this item?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Auto-fill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organisation's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "PIN" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 2adf87d63f3..1284563a6e3 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Usar inicio de sesión único" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Bienvenido de nuevo" }, @@ -588,6 +591,9 @@ "view": { "message": "Ver" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Elemento editado" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "¿Seguro que quieres enviarlo a la papelera?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Desactivar autocompletado" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Mostrar sugerencias de autocompletado en campos de formulario" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Mostrar identidades como sugerencias" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Error de descifrado" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden no pudo descifrar el/los elemento(s) de la bóveda listados a continuación." }, @@ -4011,6 +4053,15 @@ "message": "El autorrellenado de la página está usando la configuración predeterminada.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Activar/desactivar navegación lateral" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 1500e20e3aa..3f163506214 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Tere tulemast tagasi" }, @@ -588,6 +591,9 @@ "view": { "message": "Vaata" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Kirje on muudetud" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Soovid tõesti selle kirje kustutada?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 81106464f69..f74233193ef 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Ongi etorri berriro ere" }, @@ -588,6 +591,9 @@ "view": { "message": "Erakutsi" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Elementua editatuta" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Ziur zaude elementu hau zakarrontzira bidali nahi duzula?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 6617ad085cc..6b52f1d4364 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "استفاده از ورود تک مرحله‌ای" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "خوش آمدید" }, @@ -588,6 +591,9 @@ "view": { "message": "مشاهده" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "مورد ذخیره شد" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "واقعاً می‌خواهید این مورد را به سطل زباله ارسال کنید؟" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "پر کردن خودکار را خاموش کنید" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "نمایش پیشنهادهای پر کردن خودکار روی فیلدهای فرم" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "نمایش هویت‌ها به‌عنوان پیشنهاد" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "خطای رمزگشایی" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden نتوانست مورد(های) گاوصندوق فهرست شده زیر را رمزگشایی کند." }, @@ -4011,6 +4053,15 @@ "message": "پر کردن خودکار در بارگیری صفحه برای استفاده از تنظیمات پیش‌فرض تنظیم شده است.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "تغییر وضعیت ناوبری کناری" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 57a6ecfedd0..a0e6fce06bd 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Käytä kertakirjautumista" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Tervetuloa takaisin" }, @@ -588,6 +591,9 @@ "view": { "message": "Näytä" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Kohde tallennettiin" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Haluatko varmasti siirtää roskakoriin?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Poista automaattitäyttö käytöstä" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Näytä automaattitäytön ehdotukset lomakekentissä" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Näytä identiteetit ehdotuksina" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Salauksen purkuvirhe" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden ei pystynyt purkamaan alla lueteltuja holvin kohteita." }, @@ -4011,6 +4053,15 @@ "message": "Automaattitäyttö sivun avautuessa käyttää oletusasetusta.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Näytä/piilota sivuvalikko" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 88b94d9b9c1..3c249b0a350 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "Tanaw" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Ang item ay nai-save" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Gusto mo bang talagang ipadala sa basura?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 15d1cdecacf..e3a153fe34f 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Utiliser l'authentification unique" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Content de vous revoir" }, @@ -559,7 +562,7 @@ "description": "Verb" }, "unArchive": { - "message": "Unarchive" + "message": "Désarchiver" }, "itemsInArchive": { "message": "Éléments dans l'archive" @@ -571,10 +574,10 @@ "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." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "L'élément a été envoyé à l'archive" }, "itemUnarchived": { - "message": "Item was unarchived" + "message": "L'élément a été désarchivé" }, "archiveItem": { "message": "Archiver l'élément" @@ -588,6 +591,9 @@ "view": { "message": "Afficher" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "Afficher l'Identifiant" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Élément enregistré" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Êtes-vous sûr de vouloir supprimer cet identifiant ?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Désactiver la saisie automatique" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Afficher les suggestions de saisie automatique dans les champs d'un formulaire" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Afficher les identités sous forme de suggestions" }, @@ -2241,7 +2280,7 @@ "message": "Déverrouiller avec un code PIN" }, "setYourPinTitle": { - "message": "Définir PIN" + "message": "Définir NIP" }, "setYourPinButton": { "message": "Définir PIN" @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Erreur de déchiffrement" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden n’a pas pu déchiffrer le(s) élément(s) du coffre listé(s) ci-dessous." }, @@ -4011,6 +4053,15 @@ "message": "La saisie automatique au chargement de la page est configuré selon les paramètres par défaut.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Basculer la navigation latérale" }, @@ -4142,7 +4193,7 @@ "message": "Vérification requise pour cette action. Définissez un code PIN pour continuer." }, "setPin": { - "message": "Définir le code PIN" + "message": "Définir le code NIP" }, "verifyWithBiometrics": { "message": "Vérifier par biométrie" @@ -4344,7 +4395,7 @@ "message": "Code incorrect" }, "incorrectPin": { - "message": "Code PIN incorrect" + "message": "Code NIP incorrect" }, "multifactorAuthenticationFailed": { "message": "Authentification multifacteur échouée" @@ -5304,7 +5355,7 @@ "message": "Vous pouvez personnaliser vos paramètres de déverrouillage et de délai d'attente pour accéder plus rapidement à votre coffre-fort." }, "unlockPinSet": { - "message": "Déverrouiller l'ensemble de codes PIN" + "message": "Déverrouiller l'ensemble de codes NIP" }, "unlockWithBiometricSet": { "message": "Déverrouiller avec l'ensemble biométrique" @@ -5580,30 +5631,30 @@ "message": "Bienvenue dans votre coffre !" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "Tentative d'hameçonnage détectée" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "Le site que vous essayez de visiter est un site malveillant connu et un risque de sécurité." }, "phishingPageCloseTabV2": { - "message": "Close this tab" + "message": "Fermer cet onglet" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Continuer vers ce site (non recommandé)" }, "phishingPageExplanation1": { - "message": "This site was found in ", + "message": "Ce site a été trouvé dans ", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." }, "phishingPageExplanation2": { - "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "message": ", une liste open-source de sites d'hameçonnage connus et utilisés pour voler des informations personnelles et sensibles.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, "phishingPageLearnMore": { - "message": "Learn more about phishing detection" + "message": "En savoir plus sur la détection d'hameçonnage" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "Protégé par $PRODUCT$", "placeholders": { "product": { "content": "$1", @@ -5716,10 +5767,16 @@ "message": "Confirmez le domaine de Key Connector" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "message": "Excellent travail pour sécuriser vos identifiants à risque !" }, "settingDisabledByPolicy": { - "message": "This setting is disabled by your organization's policy.", + "message": "Ce paramètre est désactivé par la politique de sécurité de votre organisation.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Code postal" + }, + "cardNumberLabel": { + "message": "Numéro de carte" } } diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 137576cfb1f..6a13ce033b1 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Usar inicio de sesión único" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Benvido de novo" }, @@ -588,6 +591,9 @@ "view": { "message": "Ver" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Entrada gardada" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Seguro que queres envialo ó lixo?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Amosar suxestións de autoenchido en formularios" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Amosar identidades como suxestións" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Erro de descifrado" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden non puido descifrar os seguintes elementos." }, @@ -4011,6 +4053,15 @@ "message": "Axuste de autoenchido ó cargar a páxina por defecto.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Activar/desactivar navegación lateral" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 2164d197b0e..3834745f8e9 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "השתמש בכניסה יחידה" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "ברוך שובך" }, @@ -588,6 +591,9 @@ "view": { "message": "הצג" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "הצג כניסה" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "הפריט נשמר" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "האם אתה בטוח שברצונך למחוק פריט זה?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "השבת מילוי אוטומטי" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "הצג הצעות למילוי אוטומטי על שדות טופס" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "הצג זהויות כהצעות" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "שגיאת פענוח" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden לא יכל לפענח את פריט(י) הכספת המפורט(ים) להלן." }, @@ -4011,6 +4053,15 @@ "message": "מילוי אוטומטי בעת טעינת הוגדר להשתמש בהגדרת ברירת מחדל.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "החלף מצב ניווט צדדי" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index bc36073156b..3172e767974 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "सिंगल साइन-ऑन प्रयोग करें" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "आपका पुन: स्वागत है!" }, @@ -588,6 +591,9 @@ "view": { "message": "देखें" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "संपादित आइटम " }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "क्या आप वास्तव में थ्रैश में भेजना चाहते हैं?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index e678f506387..9e4c8d34004 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Jedinstvena prijava (SSO)" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Dobro došli natrag" }, @@ -588,6 +591,9 @@ "view": { "message": "Prikaz" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "Prikaži prijavu" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Stavka izmijenjena" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Želiš li zaista poslati u smeće?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Isključi auto-ispunu" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Prikaži prijedloge auto-ispune na poljima obrazaca" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Prikaži identitete kao prijedloge" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Pogreška pri dešifriranju" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden nije mogao dešifrirati sljedeće stavke trezora." }, @@ -4011,6 +4053,15 @@ "message": "Auto-ispuna kod učitavanja stranice koristi zadane postavke.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "U/Isključi bočnu navigaciju" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Ova je postavka onemogućena pravilima tvoje organizacije.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index e2674595f4b..a84487e5a1d 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Egyszeri bejelentkezés használata" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Üdvözlet újra" }, @@ -588,6 +591,9 @@ "view": { "message": "Nézet" }, + "viewAll": { + "message": "Összes megtekintése" + }, "viewLogin": { "message": "Bejelentkezés megtekintése" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Az elem szerkesztésre került." }, + "savedWebsite": { + "message": "Mentett webhely" + }, + "savedWebsites": { + "message": "Mentett webhelyek ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Biztosan törlésre kerüljön ezt az elem?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Automat kitöltés bekapcsolása" }, + "confirmAutofill": { + "message": "Automatikus kitöltés megerősítése" + }, + "confirmAutofillDesc": { + "message": "Ez a webhely nem egyezik a mentett bejelentkezési adatokkal. Mielőtt kitöltenénk a bejelentkezés hitelesítő adatokat, győződjünk meg arról, hogy megbízható webhelyről van-e szó." + }, "showInlineMenuLabel": { "message": "Automatikus kitöltési javaslatok megjelenítése űrlapmezőknél" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Hogyan védi meg a Bitwarden az adathalászattól az adatokat?" + }, + "currentWebsite": { + "message": "Jelenlegi webhely" + }, + "autofillAndAddWebsite": { + "message": "Automatikus kitöltés és ezen webhely hozzáadása" + }, + "autofillWithoutAdding": { + "message": "Automatikus kitöltés hozzáadás nélkül" + }, + "doNotAutofill": { + "message": "Ne legyen automatikus kitöltés" + }, "showInlineMenuIdentitiesLabel": { "message": "Az identitások megjelenítése javaslatként" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Visszafejtési hiba" }, + "errorGettingAutoFillData": { + "message": "Hiba történt az automatikus kitöltési adatok beolvasásakor." + }, "couldNotDecryptVaultItemsBelow": { "message": "A Bitwarden nem tudta visszafejteni az alább felsorolt ​​széf elemeket." }, @@ -4011,6 +4053,15 @@ "message": "Az automatikus kitöltés az oldal betöltésekor az alapértelmezett beállítás használatára lett beállítva.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Nem lehetséges az automatikus kitöltés." + }, + "cannotAutofillExactMatch": { + "message": "Az alapértelmezett egyezés beállítása 'Pontos egyezés'. Az aktuális webhely nem egyezik pontosan az ezzel az elemmel mentett bejelentkezési adatokkal." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Oldalnavigáció váltás" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Ezt a beállítást a szervezet házirendje letiltotta.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Irányítószám" + }, + "cardNumberLabel": { + "message": "Kártya szám" } } diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index a5757e38caf..a94709a1be1 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Gunakan masuk tunggal" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Selamat datang kembali" }, @@ -588,6 +591,9 @@ "view": { "message": "Tampilan" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item yang Diedit" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Apakah Anda yakin ingin menghapus item ini?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Matikan isi otomatis" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Tampilkan saran isi otomatis pada kolom formulir" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Tampilkan identitas sebagai saran" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Kesalahan dekripsi" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden tidak bisa mendekripsi butir brankas yang tercantum di bawah." }, @@ -4011,6 +4053,15 @@ "message": "Isi otomatis ketika halaman dimuat telah diatur untuk menggunakan pengaturan bawaan.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Saklar bilah isi navigasi" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 233ae413e5f..05cd6937246 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Usa il Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Bentornato/a" }, @@ -588,6 +591,9 @@ "view": { "message": "Visualizza" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "Visualizza login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Elemento salvato" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Sei sicuro di voler eliminare questo elemento?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Disattiva il riempimento automatico" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Mostra suggerimenti di riempimento automatico nei campi del modulo" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Mostra identità come consigli" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Errore di decifrazione" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden non può decifrare gli elementi elencati di seguito." }, @@ -4011,6 +4053,15 @@ "message": "Riempimento automatico al caricamento della pagina impostato con l'impostazione predefinita.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Attiva/Disattiva navigazione laterale" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Questa impostazione è disabilitata dalle restrizioni della tua organizzazione.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 4ab3cdc9c1b..54405f69157 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "シングルサインオンを使用する" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "ようこそ" }, @@ -588,6 +591,9 @@ "view": { "message": "表示" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "編集されたアイテム" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "このアイテムを削除しますか?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "フォームフィールドに自動入力の候補を表示する" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "ID を候補として表示する" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "復号エラー" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden は以下の保管庫のアイテムを復号できませんでした。" }, @@ -4011,6 +4053,15 @@ "message": "ページ読み込み時の自動入力はデフォルトの設定を使うよう設定しました。", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "サイドナビゲーションの切り替え" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 4ea5ab3390a..82f18caf79f 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "ხედი" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index e3e6953b0df..f160e9a8cfa 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 271db811810..f1c9e0ee8ab 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "ವೀಕ್ಷಣೆ" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "ಐಟಂ ಸಂಪಾದಿಸಲಾಗಿದೆ" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "ನೀವು ನಿಜವಾಗಿಯೂ ಅನುಪಯುಕ್ತಕ್ಕೆ ಕಳುಹಿಸಲು ಬಯಸುವಿರಾ?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index c45532076da..c5a414fc81f 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "통합인증(SSO) 사용하기" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "돌아온 것을 환영합니다." }, @@ -588,6 +591,9 @@ "view": { "message": "보기" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "로그인 보기" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "항목 편집함" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "정말로 휴지통으로 이동시킬까요?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "양식 필드에 자동 완성 제안 표시" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "신원을 제안으로 표시" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "페이지 로드 시 자동 완성이 기본 설정을 사용하도록 설정되었습니다.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "사이드 내비게이션 전환" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index e97a1cafcf9..df55af589bf 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Naudoti vieningo prisijungimo sistemą" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Sveiki sugrįžę" }, @@ -588,6 +591,9 @@ "view": { "message": "Peržiūrėti" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Redaguotas elementas" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Ar tikrai norite perkelti į šiukšlinę?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Automatinis pildymas įkeliant puslapį nustatytas naudoti numatytąjį nustatymą.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Perjungti šoninę naršymą" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index e1189450671..6ca99492c50 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Izmantot vienoto pieteikšanos" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Tava apvienība pieprasa vienoto pieteikšanos." + }, "welcomeBack": { "message": "Laipni lūdzam atpakaļ" }, @@ -588,6 +591,9 @@ "view": { "message": "Skatīt" }, + "viewAll": { + "message": "Apskatīt visu" + }, "viewLogin": { "message": "Apskatīt pieteikšanās vienumu" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Vienums labots" }, + "savedWebsite": { + "message": "Saglabāta tīmekļvietne" + }, + "savedWebsites": { + "message": "Saglabātas tīmekļvietnes ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Vai tiešām pārvietot uz atkritni?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Izslēgt automātisko aizpildi" }, + "confirmAutofill": { + "message": "Apstiprināt automātisko aizpildi" + }, + "confirmAutofillDesc": { + "message": "Šī vietne neatbilst saglabātā pieteikšanās vienumam. Pirms pieteikšanās datu aizpildīšanas jāpārliecinās, ka tā ir uzticama." + }, "showInlineMenuLabel": { "message": "Rādīt automātiskās aizpildes ieteikumuis veidlapu laukos" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Kā Bitwarden aizsargā datus no pikšķerēšanas?" + }, + "currentWebsite": { + "message": "Pašreizējā tīmekļvietne" + }, + "autofillAndAddWebsite": { + "message": "Automātiski aizpildīt un pievienot šo tīmekļvietni" + }, + "autofillWithoutAdding": { + "message": "Automātiski aizpildīt bez pievienošanas" + }, + "doNotAutofill": { + "message": "Neaizpildīt automātiski" + }, "showInlineMenuIdentitiesLabel": { "message": "Attēlot identitātes kā ieteikumus" }, @@ -3217,7 +3256,7 @@ } }, "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "message": "Tiks izgūta tikai apvienības glabātava, kas ir saistīta ar $ORGANIZATION$.", "placeholders": { "organization": { "content": "$1", @@ -3226,7 +3265,7 @@ } }, "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "message": "Tiks izgūta tikai apvienības glabātava, kas ir saistīta ar $ORGANIZATION$. Mani vienumu krājumi netiks iekļauti.", "placeholders": { "organization": { "content": "$1", @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Atšifrēšanas kļūda" }, + "errorGettingAutoFillData": { + "message": "Kļūda automātiskās aizpildes datu iegūšanā" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden nevarēja atšifrēt zemāk uzskaitītos glabātavas vienumus." }, @@ -4011,6 +4053,15 @@ "message": "Automātiskā aizpilde lapas ielādes brīdī iestatīta izmantot noklusējuma iestatījumu.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Nevar automātiski aizpildīt" + }, + "cannotAutofillExactMatch": { + "message": "Noklusējuma atbilstības noteikšana ir iestatīta uz “Pilnīga atbilstība”. Pašreizējā tīmekļvietne pilnībā neabilst saglabātajai pieteikšanās informācijai šajā vienumā." + }, + "okay": { + "message": "Labi" + }, "toggleSideNavigation": { "message": "Pārslēgt sānu pārvietošanās joslu" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Šis iestatījums ir atspējots apvienības pamatnostādnēs.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Pasta indekss" + }, + "cardNumberLabel": { + "message": "Kartes numurs" } } diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index fcf73a37e45..75eeb54c176 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "കാണുക" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "തിരുത്തപ്പെട്ട ഇനം" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "ഈ ഇനം ഇല്ലാതാക്കാൻ നിങ്ങൾ ആഗ്രഹിക്കുന്നുണ്ടോ?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 93f78303a5c..333dda2a2f8 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index e3e6953b0df..f160e9a8cfa 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 66d1ce615e1..3d632a60d3c 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Bruk singulær pålogging" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Velkommen tilbake" }, @@ -588,6 +591,9 @@ "view": { "message": "Vis" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Redigerte elementet" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Er du sikker på at du vil slette dette elementet?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Skru av autoutfylling" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Vis autoutfyll-forslag i tekstbokser" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Vis identiteter som forslag" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Dekrypteringsfeil" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Skru av/på sidenavigering" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index e3e6953b0df..f160e9a8cfa 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 73b8afa2966..d413149bd18 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Single sign-on gebruiken" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welkom terug" }, @@ -588,6 +591,9 @@ "view": { "message": "Weergeven" }, + "viewAll": { + "message": "Alles weergeven" + }, "viewLogin": { "message": "Login bekijken" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item is bewerkt" }, + "savedWebsite": { + "message": "Opgeslagen website" + }, + "savedWebsites": { + "message": "Opgeslagen websites ( $COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Weet je zeker dat je dit naar de prullenbak wilt verplaatsen?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Automatisch invullen uitschakelen" }, + "confirmAutofill": { + "message": "Automatisch aanvullen bevestigen" + }, + "confirmAutofillDesc": { + "message": "Deze website komt past niet bij je opgeslagen inloggegevens. Verzeker jezelf ervan dat het een vertrouwde website is, voordat je je inloggegevens invult." + }, "showInlineMenuLabel": { "message": "Suggesties voor automatisch invullen op formuliervelden weergeven" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Hoe beschermt Bitwarden je gegevens tegen phishing?" + }, + "currentWebsite": { + "message": "Huidige website" + }, + "autofillAndAddWebsite": { + "message": "Automatisch invullen en deze website toevoegen" + }, + "autofillWithoutAdding": { + "message": "Automatisch invullen zonder toevoegen" + }, + "doNotAutofill": { + "message": "Niet automatisch invullen" + }, "showInlineMenuIdentitiesLabel": { "message": "Identiteiten als suggesties weergeven" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Ontsleutelingsfout" }, + "errorGettingAutoFillData": { + "message": "Fout bij ophalen van gegevens voor automatisch vullen" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden kon de onderstaande kluisitem(s) niet ontsleutelen." }, @@ -4011,6 +4053,15 @@ "message": "Automatisch invullen bij het laden van een pagina ingesteld op de standaardinstelling.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Kan niet automatisch invullen" + }, + "cannotAutofillExactMatch": { + "message": "Standaard overeenkomst is ingesteld op 'Exacte Match'. De huidige website komt niet precies overeen met de opgeslagen inloggegevens voor dit item." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Zijnavigatie schakelen" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Deze instelling is uitgeschakeld door het beleid van uw organisatie.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Postcode" + }, + "cardNumberLabel": { + "message": "Kaartnummer" } } diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index e3e6953b0df..f160e9a8cfa 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index e3e6953b0df..f160e9a8cfa 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 78fb5e832a6..6c9bea95451 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Użyj logowania jednokrotnego" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Witaj ponownie" }, @@ -588,6 +591,9 @@ "view": { "message": "Pokaż" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "Pokaż dane logowania" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Element został zapisany" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Czy na pewno chcesz usunąć?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Wyłącz autouzupełnianie" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Pokaż sugestie autouzupełniania na polach formularza" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Pokaż tożsamości w sugestiach" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Błąd odszyfrowywania" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden nie mógł odszyfrować poniższych elementów sejfu." }, @@ -4011,6 +4053,15 @@ "message": "Autouzupełnianie po załadowaniu strony zostało ustawione do domyślnych ustawień.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Przełącz nawigację boczną" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Kod pocztowy" + }, + "cardNumberLabel": { + "message": "Numer karty" } } diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index e3a82f42ca7..1496455e85b 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Usar autenticação única" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Boas-vindas de volta" }, @@ -588,6 +591,9 @@ "view": { "message": "Ver" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "Ver credencial" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item salvo" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Você tem certeza que deseja enviar este item para a lixeira?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Desativar o preenchimento automático" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Mostrar sugestões de preenchimento automático em campos de formulário" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Exibir identidades como sugestões" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Erro ao descriptografar" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "O Bitwarden não conseguiu descriptografar o(s) item(ns) do cofre listado abaixo." }, @@ -4011,6 +4053,15 @@ "message": "O preenchimento automático ao carregar a página está usando a configuração padrão.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Habilitar navegação lateral" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Essa configuração está desativada pela política da sua organização.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "CEP / Código postal" + }, + "cardNumberLabel": { + "message": "Número do cartão" } } diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index db2eb776d7f..d3acb309860 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Utilizar início de sessão único" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "A sua organização exige o início de sessão único." + }, "welcomeBack": { "message": "Bem-vindo de volta" }, @@ -588,6 +591,9 @@ "view": { "message": "Ver" }, + "viewAll": { + "message": "Ver tudo" + }, "viewLogin": { "message": "Ver credencial" }, @@ -788,7 +794,7 @@ "message": "4 horas" }, "onLocked": { - "message": "No bloqueio do sistema" + "message": "Ao bloquear o sistema" }, "onRestart": { "message": "Ao reiniciar o navegador" @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item guardado" }, + "savedWebsite": { + "message": "Site guardado" + }, + "savedWebsites": { + "message": "Sites guardados ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Tem a certeza de que pretende eliminar este item?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Desativar o preenchimento automático" }, + "confirmAutofill": { + "message": "Confirmar preenchimento automático" + }, + "confirmAutofillDesc": { + "message": "Este site não corresponde às suas credenciais guardadas. Antes de preencher as suas credenciais, certifique-se de que se trata de um site fiável." + }, "showInlineMenuLabel": { "message": "Mostrar sugestões de preenchimento automático nos campos do formulário" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Como o Bitwarden protege os seus dados contra phishing?" + }, + "currentWebsite": { + "message": "Site atual" + }, + "autofillAndAddWebsite": { + "message": "Preencher automaticamente e adicionar este site" + }, + "autofillWithoutAdding": { + "message": "Preencher automaticamente sem adicionar" + }, + "doNotAutofill": { + "message": "Não preencher automaticamente" + }, "showInlineMenuIdentitiesLabel": { "message": "Apresentar as identidades como sugestões" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Erro de desencriptação" }, + "errorGettingAutoFillData": { + "message": "Erro ao obter dados de preenchimento automático" + }, "couldNotDecryptVaultItemsBelow": { "message": "O Bitwarden não conseguiu desencriptar o(s) item(ns) do cofre listado(s) abaixo." }, @@ -4011,6 +4053,15 @@ "message": "Preencher automaticamente ao carregar a página definido para utilizar a predefinição.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Não é possível preencher automaticamente" + }, + "cannotAutofillExactMatch": { + "message": "A correspondência padrão está definida como \"Correspondência exata\". O site atual não corresponde exatamente às credenciais guardadas para este item." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Ativar/desativar navegação lateral" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Esta configuração está desativada pela política da sua organização.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Código postal" + }, + "cardNumberLabel": { + "message": "Número do cartão" } } diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 4b2913ce55b..0206f473448 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Autentificare unică" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Bine ați revenit" }, @@ -588,6 +591,9 @@ "view": { "message": "Afișare" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Articol salvat" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Sigur doriți să trimiteți în coșul de reciclare?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Completarea automată la încărcarea paginii este setată la valoarea implicită.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 8661d78552e..b69494d472e 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Использовать единый вход" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Ваша организация требует единого входа." + }, "welcomeBack": { "message": "С возвращением" }, @@ -588,6 +591,9 @@ "view": { "message": "Просмотр" }, + "viewAll": { + "message": "Посмотреть все" + }, "viewLogin": { "message": "Просмотр логина" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Элемент сохранен" }, + "savedWebsite": { + "message": "Сохраненный сайт" + }, + "savedWebsites": { + "message": "Сохраненные сайты ( $COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Вы действительно хотите отправить в корзину?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Отключить автозаполнение" }, + "confirmAutofill": { + "message": "Подтвердите автозаполнение" + }, + "confirmAutofillDesc": { + "message": "Этот сайт не соответствует вашим сохраненным логинам. Прежде чем заполнять логин, убедитесь, что это надежный сайт." + }, "showInlineMenuLabel": { "message": "Показывать предположения автозаполнения в полях формы" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Как Bitwarden защищает ваши данные от фишинга?" + }, + "currentWebsite": { + "message": "Текущий сайт" + }, + "autofillAndAddWebsite": { + "message": "Заполнить и добавить этот сайт" + }, + "autofillWithoutAdding": { + "message": "Заполнить без добавления" + }, + "doNotAutofill": { + "message": "Не заполнять" + }, "showInlineMenuIdentitiesLabel": { "message": "Показывать Личную информацию как предложения" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Ошибка расшифровки" }, + "errorGettingAutoFillData": { + "message": "Ошибка получения данных автозаполнения" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden не удалось расшифровать элемент(ы) хранилища, перечисленные ниже." }, @@ -4011,6 +4053,15 @@ "message": "Автозаполнение при загрузке страницы использует настройку по умолчанию.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Не удалось заполнить" + }, + "cannotAutofillExactMatch": { + "message": "По умолчанию установлено значение 'Точное соответствие'. Текущий сайт не полностью соответствует сохраненным для этого элемента логинам." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Переключить боковую навигацию" }, @@ -5526,10 +5577,10 @@ "message": "Изменить пароль, подверженный риску" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "This login is at-risk and missing a website. Add a website and change the password for stronger security." + "message": "Этот логин подвержен риску и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." }, "missingWebsite": { - "message": "Missing website" + "message": "Отсутствует сайт" }, "settingsVaultOptions": { "message": "Настройки хранилища" @@ -5709,17 +5760,23 @@ "message": "Далее" }, "moreBreadcrumbs": { - "message": "More breadcrumbs", + "message": "Дополнительная навигация", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "confirmKeyConnectorDomain": { "message": "Подтвердите домен соединителя ключей" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "message": "Отличная работа по защите ваших логинов, подверженных риску!" }, "settingDisabledByPolicy": { "message": "Этот параметр отключен политикой вашей организации.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "Почтовый индекс" + }, + "cardNumberLabel": { + "message": "Номер карты" } } diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 649556ca64b..60ce2436254 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "දකින්න" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "සංස්කරණය කරන ලද අයිතමය" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "ඔබට ඇත්තටම කුණු කූඩයට යැවීමට අවශ්යද?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index fe86ad298c9..46ff6837c70 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -6,7 +6,7 @@ "message": "Logo Bitwarden" }, "extName": { - "message": "Bitwarden – Bezplatný správca hesiel", + "message": "Bitwarden – správca hesiel", "description": "Extension name, MUST be less than 40 characters (Safari restriction)" }, "extDesc": { @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Použiť jednotné prihlásenie" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Vaša organizácia vyžaduje jednotné prihlasovanie." + }, "welcomeBack": { "message": "Vitajte späť" }, @@ -53,7 +56,7 @@ "message": "Potvrdiť" }, "emailAddress": { - "message": "Emailová adresa" + "message": "E-mailová adresa" }, "masterPass": { "message": "Hlavné heslo" @@ -588,6 +591,9 @@ "view": { "message": "Zobraziť" }, + "viewAll": { + "message": "Zobraziť všetky" + }, "viewLogin": { "message": "Zobraziť prihlásenie" }, @@ -676,7 +682,7 @@ "message": "Nastavte metódu odomknutia, aby ste zmenili akciu pri vypršaní času trezoru." }, "unlockMethodNeeded": { - "message": "Nastavte metódu odomknutia v Nastaveniach" + "message": "Nastavte metódu odomknutia v nastaveniach" }, "sessionTimeoutHeader": { "message": "Časový limit relácie" @@ -877,7 +883,7 @@ } }, "autofillError": { - "message": "Na tejto stránke sa nedajú automaticky vyplniť prihlasovacie údaje. Namiesto toho skopírujte/vložte prihlasovacie údaje manuálne." + "message": "Nie je možné automaticky vyplniť vybranú položku na tejto stránke. Namiesto toho skopírujte a vložte prihlasovacie údaje." }, "totpCaptureError": { "message": "Nie je možné naskenovať QR kód z aktuálnej webovej stránky" @@ -967,10 +973,10 @@ "message": "Meno je povinné." }, "addedFolder": { - "message": "Pridaný priečinok" + "message": "Priečinok bol pridaný" }, "twoStepLoginConfirmation": { - "message": "Dvojstupňové prihlasovanie robí váš účet bezpečnejším vďaka vyžadovaniu bezpečnostného kódu z overovacej aplikácie vždy, keď sa prihlásite. Dvojstupňové prihlasovanie môžete povoliť vo webovom trezore bitwarden.com. Chcete navštíviť túto stránku teraz?" + "message": "Dvojstupňové prihlásenie zvyšuje bezpečnosť vášho účtu tým, že vyžaduje overenie prihlásenia pomocou iného zariadenia, napríklad bezpečnostného kľúča, overovacej aplikácie, SMS, telefonického hovoru alebo e-mailu. Dvojstupňové prihlásenie môžete nastaviť na bitwarden.com. Chcete stránku navštíviť teraz?" }, "twoStepLoginConfirmationContent": { "message": "Zabezpečte svoj účet nastavením dvojstupňového prihlasovania vo webovej aplikácii Bitwarden." @@ -979,22 +985,22 @@ "message": "Pokračovať vo webovej aplikácii?" }, "editedFolder": { - "message": "Priečinok upravený" + "message": "Priečinok bol upravený" }, "deleteFolderConfirmation": { "message": "Naozaj chcete odstrániť tento priečinok?" }, "deletedFolder": { - "message": "Odstránený priečinok" + "message": "Priečinok bol odstránený" }, "gettingStartedTutorial": { - "message": "Začiatočnícka príručka" + "message": "Úvodná príručka" }, "gettingStartedTutorialVideo": { "message": "Pozrite našu príručku pre začiatočníkov, v ktorej sa dozviete, ako získať maximum z nášho rozšírenia pre prehliadač." }, "syncingComplete": { - "message": "Synchronizácia kompletná" + "message": "Synchronizácia bola dokončená" }, "syncingFailed": { "message": "Synchronizácia zlyhala" @@ -1028,11 +1034,23 @@ "editedItem": { "message": "Položka upravená" }, + "savedWebsite": { + "message": "Uložená webstránka" + }, + "savedWebsites": { + "message": "Uložené webstránky ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Naozaj chcete odstrániť túto položku?" }, "deletedItem": { - "message": "Položka odstránená" + "message": "Položka bola presunutá do koša" }, "overwritePassword": { "message": "Prepísať heslo" @@ -1260,7 +1278,7 @@ "message": "Zobraziť možnosti kontextovej ponuky" }, "contextMenuItemDesc": { - "message": "Sekundárnym kliknutím získate prístup k vygenerovaniu hesiel a zodpovedajúcim prihláseniam pre webovú stránku. " + "message": "Kliknutím pravého tlačidla myši získate prístup k vygenerovaniu hesiel a zodpovedajúcim prihláseniam pre webovú stránku." }, "contextMenuItemDescAlt": { "message": "Sekundárnym kliknutím získate prístup k vygenerovaniu hesiel a zodpovedajúcim prihláseniam pre webovú stránku. Platí pre všetky prihlásené účty." @@ -1296,7 +1314,7 @@ "message": "Export trezoru" }, "fileFormat": { - "message": "Formát Súboru" + "message": "Formát súboru" }, "fileEncryptedExportWarningDesc": { "message": "Tento exportovaný súbor bude chránený heslom a na dešifrovanie bude potrebné heslo súboru." @@ -1374,7 +1392,7 @@ "message": "Zistiť viac" }, "authenticatorKeyTotp": { - "message": "Kľúč overovateľa (TOTP)" + "message": "Overovací kľúč (TOTP)" }, "verificationCodeTotp": { "message": "Overovací kód (TOTP)" @@ -1392,7 +1410,7 @@ "message": "Naozaj chcete odstrániť túto prílohu?" }, "deletedAttachment": { - "message": "Odstránená príloha" + "message": "Príloha bola odstránená" }, "newAttachment": { "message": "Pridať novú prílohu" @@ -1401,7 +1419,7 @@ "message": "Žiadne prílohy." }, "attachmentSaved": { - "message": "Príloha bola uložená." + "message": "Príloha bola uložená" }, "file": { "message": "Súbor" @@ -1437,13 +1455,13 @@ "message": "Momentálne nie ste prémiovým členom." }, "premiumSignUpAndGet": { - "message": "Zaregistrujte sa pre prémiové členstvo a získajte:" + "message": "Zaregistrujte sa na prémiové členstvo a získajte:" }, "ppremiumSignUpStorage": { "message": "1 GB šifrovaného úložiska na prílohy." }, "premiumSignUpEmergency": { - "message": "Núdzový prístup" + "message": "Núdzový prístup." }, "premiumSignUpTwoStepOptions": { "message": "Proprietárne možnosti dvojstupňového prihlásenia ako napríklad YubiKey a Duo." @@ -1473,7 +1491,7 @@ "message": "Ďakujeme, že podporujete Bitwarden." }, "premiumFeatures": { - "message": "Povýšte na premium a získajte:" + "message": "Inovujte na prémium a získajte:" }, "premiumPrice": { "message": "Všetko len za %price% /rok!", @@ -1494,22 +1512,22 @@ } }, "refreshComplete": { - "message": "Obnova kompletná" + "message": "Obnova bola dokončená" }, "enableAutoTotpCopy": { "message": "Automaticky kopírovať TOTP" }, "disableAutoTotpCopyDesc": { - "message": "Ak je kľúč overovateľa spojený s vašim prihlásením, TOTP verifikačný kód bude automaticky skopírovaný do schránky vždy, keď použijete automatické vypĺňanie." + "message": "Ak je overovací kľúč spojený s vašim prihlásením, overovací kód TOTP bude automaticky skopírovaný do schránky vždy, keď použijete automatické vypĺňanie." }, "enableAutoBiometricsPrompt": { "message": "Pri spustení požiadať o biometriu" }, "premiumRequired": { - "message": "Vyžaduje prémiový účet" + "message": "Vyžaduje sa prémiový účet" }, "premiumRequiredDesc": { - "message": "Pre použitie tejto funkcie je potrebné prémiové členstvo." + "message": "Na použitie tejto funkcie je potrebné prémiové členstvo." }, "authenticationTimeout": { "message": "Časový limit overenia" @@ -1576,7 +1594,7 @@ "message": "Vyberte metódu dvojstupňového prihlásenia" }, "recoveryCodeTitle": { - "message": "Záchranný kód" + "message": "Kód na obnovenie" }, "authenticatorAppTitle": { "message": "Overovacia aplikácia" @@ -1596,14 +1614,14 @@ "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "duoOrganizationDesc": { - "message": "Overiť s Duo Security vašej organizácie použitím Duo Mobile aplikácie, SMS, telefonátu alebo U2F bezpečnostným kľúčom.", + "message": "Overenie pomocou Duo Security pre vašu organizáciu pomocou aplikácie Duo Mobile, SMS, telefonického hovoru alebo bezpečnostného kľúča U2F.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "webAuthnTitle": { "message": "FIDO2 WebAuthn" }, "webAuthnDesc": { - "message": "Použiť akýkoľvek WebAuthn bezpečnostný kľúč pre prístup k vášmu účtu." + "message": "Použiť akýkoľvek kompatibilný bezpečnostný kľúč WebAuthn na prístup k svojmu účtu." }, "emailTitle": { "message": "Email" @@ -1643,13 +1661,13 @@ "message": "URL servera identít" }, "notificationsUrl": { - "message": "URL adresa servera pre oznámenia" + "message": "URL servera pre upozornenia" }, "iconsUrl": { - "message": "URL servera ikôn" + "message": "URL servera ikon" }, "environmentSaved": { - "message": "URL prostredia boli uložené." + "message": "URL adresy prostredia boli uložené" }, "showAutoFillMenuOnFormFields": { "message": "Zobraziť ponuku automatického vypĺňania na poliach formulára", @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Vypnúť automatické vypĺňanie" }, + "confirmAutofill": { + "message": "Potvrdenie automatického vypĺňania" + }, + "confirmAutofillDesc": { + "message": "Táto stránka nezodpovedá vašim uloženým prihlasovacím údajom. Pred vyplnením prihlasovacích údajov sa uistite, že ide o dôveryhodnú stránku." + }, "showInlineMenuLabel": { "message": "Zobraziť návrhy automatického vypĺňania v poliach formulára" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Ako Bitwarden chráni vaše údaje pred phishingom?" + }, + "currentWebsite": { + "message": "Aktuálna webstránka" + }, + "autofillAndAddWebsite": { + "message": "Automaticky vyplniť a pridať túto webstránku" + }, + "autofillWithoutAdding": { + "message": "Automaticky vyplniť bez pridania" + }, + "doNotAutofill": { + "message": "Nevyplniť automaticky" + }, "showInlineMenuIdentitiesLabel": { "message": "Zobrazovať identity ako návrhy" }, @@ -1706,32 +1745,32 @@ "description": "Overlay appearance select option for showing the field on focus of the input element" }, "autofillOverlayVisibilityOnButtonClick": { - "message": "Keď je vybratá ikona automatického vypĺňania", + "message": "Keď je vybraná ikona automatického vypĺňania", "description": "Overlay appearance select option for showing the field on click of the overlay icon" }, "enableAutoFillOnPageLoadSectionTitle": { "message": "Povoliť automatické vypĺňanie pri načítaní stránky" }, "enableAutoFillOnPageLoad": { - "message": "Povoliť automatické vypĺňanie pri načítaní stránky" + "message": "Automaticky vyplniť pri načítaní stránky" }, "enableAutoFillOnPageLoadDesc": { - "message": "Ak je detekovaný prihlasovací formulár, automaticky vykonať vypĺňanie pri načítaní stránky." + "message": "Ak sa zistí prihlasovací formulár, pri načítaní webovej stránky sa automaticky vyplní." }, "experimentalFeature": { - "message": "Skompromitované alebo nedôveryhodné stránky môžu pri svojom načítaní zneužiť automatické dopĺňanie." + "message": "Kompromitované alebo nedôveryhodné webové lokality môžu zneužiť automatické vypĺňanie pri načítaní stránky." }, "learnMoreAboutAutofillOnPageLoadLinkText": { "message": "Viac informácií o rizikách" }, "learnMoreAboutAutofill": { - "message": "Dozvedieť sa viac o automatickom dopĺňaní" + "message": "Viac informácií o automatickom vypĺňaní" }, "defaultAutoFillOnPageLoad": { "message": "Predvolené nastavenie automatického vypĺňania pre prihlasovacie položky" }, "defaultAutoFillOnPageLoadDesc": { - "message": "Pri úprave položky prihlásenia môžete individuálne zapnúť alebo vypnúť automatické vypĺňanie pri načítaní stránky pre danú položku." + "message": "Automatické vypĺňanie pri načítaní stránky môžete vypnúť, pre jednotlivé položky prihlásenia, pri úprave položky." }, "autoFillOnPageLoadUseDefault": { "message": "Pôvodné nastavenia" @@ -1808,7 +1847,7 @@ "message": "Zobraziť ikony webových stránok a načítať adresy URL na zmenu hesla" }, "cardholderName": { - "message": "Meno vlastníka karty" + "message": "Meno držiteľa karty" }, "number": { "message": "Číslo" @@ -1817,10 +1856,10 @@ "message": "Značka" }, "expirationMonth": { - "message": "Mesiac expirácie" + "message": "Mesiac exspirácie" }, "expirationYear": { - "message": "Rok expirácie" + "message": "Rok exspirácie" }, "expiration": { "message": "Expirácia" @@ -1892,7 +1931,7 @@ "message": "Krstné meno" }, "middleName": { - "message": "Druhé meno" + "message": "Stredné meno" }, "lastName": { "message": "Priezvisko" @@ -1907,13 +1946,13 @@ "message": "Spoločnosť" }, "ssn": { - "message": "Číslo poistenca sociálnej poisťovne" + "message": "Číslo sociálneho poistenia" }, "passportNumber": { "message": "Číslo pasu" }, "licenseNumber": { - "message": "Číslo vodičského preukazu" + "message": "Licenčné číslo" }, "email": { "message": "Email" @@ -2153,7 +2192,7 @@ "message": "Voľby prepínača" }, "toggleCurrentUris": { - "message": "Prepnúť zobrazovanie aktuálnej URI", + "message": "Prepnúť zobrazenie aktuálnej URI", "description": "Toggle the display of the URIs of the currently open tabs in the browser." }, "currentUri": { @@ -2344,13 +2383,13 @@ "message": "Naozaj chcete narvalo odstrániť túto položku?" }, "permanentlyDeletedItem": { - "message": "Natrvalo odstrániť položku" + "message": "Položka bola natrvalo odstránená" }, "restoreItem": { "message": "Obnoviť položku" }, "restoredItem": { - "message": "Obnovená položka" + "message": "Položka bola obnovená" }, "alreadyHaveAccount": { "message": "Už máte účet?" @@ -2362,13 +2401,13 @@ "message": "Potvrdenie akcie pri vypršaní časového limitu" }, "autoFillAndSave": { - "message": "Auto-vyplniť a Uložiť" + "message": "Automaticky vyplniť a uložiť" }, "fillAndSave": { "message": "Vyplniť a uložiť" }, "autoFillSuccessAndSavedUri": { - "message": "Automatické vypĺnenie a uloženie úspešné" + "message": "Položka bola automaticky vyplnená a URI uložený" }, "autoFillSuccess": { "message": "Automaticky vyplnené" @@ -2380,7 +2419,7 @@ "message": "Prajete si napriek tomu vyplniť prihlasovacie údaje?" }, "autofillIframeWarning": { - "message": "Formulár je hosťovaný inou doménou ako má URI uložených prihlasovacích údajov. Zvoľte OK ak chcete aj tak automaticky vyplniť údaje, alebo Zrušiť pre zastavenie." + "message": "Formulár je umiestnený na inej doméne ako URI vašich prihlasovacích údajov. Vyberte OK, ak chcete aj tak použiť automatické vyplnenie, alebo Zrušiť, ak chcete automatické vyplnenie zastaviť." }, "autofillIframeWarningTip": { "message": "Ak chcete tomuto upozorneniu v budúcnosti zabrániť, uložte URI, $HOSTNAME$, do položky prihlásenia Bitwardenu pre túto stránku.", @@ -2503,10 +2542,10 @@ "message": "Spustiť desktopovú aplikáciu Bitwarden Desktop" }, "startDesktopDesc": { - "message": "Aplikácia Bitwarden Desktop musí byť pred použitím odomknutia pomocou biometrických údajov spustená." + "message": "Aplikácia Bitwarden Desktop musí byť spustená pred použitím odomknutia pomocou biometrických údajov." }, "errorEnableBiometricTitle": { - "message": "Nie je môžné povoliť biometrické údaje" + "message": "Nie je možné povoliť biometrické údaje" }, "errorEnableBiometricDesc": { "message": "Akcia bola zrušená desktopovou aplikáciou" @@ -2557,7 +2596,7 @@ "message": "Biometria zlyhala" }, "biometricsFailedDesc": { - "message": "Biometria nebola vykonaná. Zvážte použitie hlavného hesla, alebo sa odhláste. Ak tento problém pretrváva, kontaktujte podporu Bitwarden." + "message": "Biometria nebola dokončená. Zvážte použitie hlavného hesla, alebo sa odhláste. Ak tento problém pretrváva, kontaktujte podporu Bitwarden." }, "nativeMessaginPermissionErrorTitle": { "message": "Povolenie nebolo udelené" @@ -2833,10 +2872,10 @@ "message": "Odstrániť" }, "removedPassword": { - "message": "Heslo odstránené" + "message": "Heslo bolo odstránené" }, "deletedSend": { - "message": "Odstrániť Send", + "message": "Send bol odstránený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendLink": { @@ -2895,7 +2934,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createSend": { - "message": "Vytvoriť nový Send", + "message": "Nový Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newPassword": { @@ -2910,7 +2949,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSend": { - "message": "Send vytvorený", + "message": "Send bol vytvorený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSendSuccessfully": { @@ -2950,7 +2989,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { - "message": "Send upravený", + "message": "Send bol upravený", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendFilePopoutDialogText": { @@ -3016,7 +3055,7 @@ "message": "Hlavné heslo bolo úspešne nastavené" }, "updatedMasterPassword": { - "message": "Hlavné heslo aktualizované" + "message": "Hlavné heslo bolo aktualizované" }, "updateMasterPassword": { "message": "Aktualizovať hlavné heslo" @@ -3074,7 +3113,7 @@ "message": "Na možnosti pri vypršaní časového limitu boli uplatnené požiadavky pravidiel spoločnosti" }, "vaultTimeoutPolicyInEffect": { - "message": "Zásady vašej organizácie ovplyvňujú časový limit trezoru. Maximálny povolený časový limit trezoru je $HOURS$ h a $MINUTES$ m", + "message": "Zásady vašej organizácie ovplyvňujú časový limit trezoru. Maximálny povolený časový limit trezoru je $HOURS$ h a $MINUTES$ m.", "placeholders": { "hours": { "content": "$1", @@ -3142,10 +3181,10 @@ "message": "Časový limit vášho trezora prekračuje obmedzenia nastavené vašou organizáciou." }, "vaultExportDisabled": { - "message": "Export trezoru je zakázaný" + "message": "Export trezoru nie je dostupný" }, "personalVaultExportPolicyInEffect": { - "message": "Jedna alebo viacero zásad organizácie vám bráni exportovať váš osobný trezor." + "message": "Jedno alebo viacero pravidiel organizácie vám bráni exportovať váš osobný trezor." }, "copyCustomFieldNameInvalidElement": { "message": "Nie je možné identifikovať platný prvok formulára. Skúste namiesto toho preskúmať HTML." @@ -3169,7 +3208,7 @@ "message": "Odstrániť hlavné heslo" }, "removedMasterPassword": { - "message": "Hlavné heslo bolo odstránené." + "message": "Hlavné heslo bolo odstránené" }, "leaveOrganizationConfirmation": { "message": "Naozaj chcete opustiť túto organizáciu?" @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Chyba dešifrovania" }, + "errorGettingAutoFillData": { + "message": "Chyba pri získavaní údajov automatického vypĺňania" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden nedokázal dešifrovať nižšie uvedené položky trezoru." }, @@ -3331,7 +3373,7 @@ "description": "Guidance provided for email forwarding services that support multiple email domains." }, "forwarderError": { - "message": "$SERVICENAME$ chyba: $ERRORMESSAGE$", + "message": "Chyba $SERVICENAME$: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", "placeholders": { "servicename": { @@ -3473,10 +3515,10 @@ "message": "Vyžaduje sa Premiové predplatné" }, "organizationIsDisabled": { - "message": "Organizácia je vypnutá." + "message": "Organizácia je pozastavená." }, "disabledOrganizationFilterError": { - "message": "K položkám vo vypnutej organizácii nie je možné pristupovať. Požiadajte o pomoc vlastníka organizácie." + "message": "K položkám v pozastavenej organizácii nie je možné pristupovať. Požiadajte o pomoc vlastníka organizácie." }, "loggingInTo": { "message": "Prihlásenie do $DOMAIN$", @@ -3506,7 +3548,7 @@ } }, "lastSeenOn": { - "message": "naposledy videné $DATE$", + "message": "naposledy videný: $DATE$", "placeholders": { "date": { "content": "$1", @@ -3524,10 +3566,10 @@ "message": "Zapamätať si e-mail" }, "loginWithDevice": { - "message": "Prihlásiť pomocou zariadenia" + "message": "Prihlásiť sa pomocou zariadenia" }, "fingerprintPhraseHeader": { - "message": "Fráza odtlačku prsta" + "message": "Jedinečný identifikátor" }, "fingerprintMatchInfo": { "message": "Uistite sa, že je váš trezor odomknutý a fráza odtlačku prsta sa zhoduje s frázou na druhom zariadení." @@ -3591,16 +3633,16 @@ "message": "Hlavné heslo uložené" }, "exposedMasterPassword": { - "message": "Odhalené hlavné heslo" + "message": "Uniknuté hlavné heslo" }, "exposedMasterPasswordDesc": { - "message": "Nájdené heslo v uniknuných údajoch. Na ochranu svojho účtu používajte jedinečné heslo. Naozaj chcete používať odhalené heslo?" + "message": "Heslo bolo nájdené v uniknutých údajoch. Na ochranu svojho účtu používajte jedinečné heslo. Naozaj chcete používať odhalené heslo?" }, "weakAndExposedMasterPassword": { - "message": "Slabé a odhalené hlavné heslo" + "message": "Slabé a uniknuté hlavné heslo" }, "weakAndBreachedMasterPasswordDesc": { - "message": "Nájdené slabé heslo v uniknuných údajoch. Na ochranu svojho účtu používajte silné a jedinečné heslo. Naozaj chcete používať toto heslo?" + "message": "Nájdené slabé heslo v uniknutých údajoch. Na ochranu svojho účtu používajte silné a jedinečné heslo. Naozaj chcete používať toto heslo?" }, "checkForBreaches": { "message": "Skontrolovať známe úniky údajov pre toto heslo" @@ -3612,7 +3654,7 @@ "message": "Vaše hlavné heslo sa nebude dať obnoviť, ak ho zabudnete!" }, "characterMinimum": { - "message": "Minimálny počet znakov $LENGTH$", + "message": "Minimálny počet znakov: $LENGTH$", "placeholders": { "length": { "content": "$1", @@ -3633,7 +3675,7 @@ } }, "autofillSelectInfoWithoutCommand": { - "message": "Vyberte položku z ponuky alebo preskúmajte ďalšie možnosti nastavenia." + "message": "Vyberte položku z ponuky, alebo preskúmajte ďalšie možnosti nastavenia." }, "gotIt": { "message": "Chápem" @@ -4008,9 +4050,18 @@ "message": "Alias doména" }, "autofillOnPageLoadSetToDefault": { - "message": "Automatické vypĺňanie pri načítaní stránky nastavené na pôvodnú predvoľbu.", + "message": "Automatické vypĺňanie pri načítaní stránky nastavené na pôvodné nastavenie.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Automatické vypĺňanie nie je možné" + }, + "cannotAutofillExactMatch": { + "message": "Predvolená zhoda je nastavená na \"Presná zhoda\". Aktuálna webová stránka sa presne nezhoduje s uloženými prihlasovacími údajmi pre túto položku." + }, + "okay": { + "message": "OK" + }, "toggleSideNavigation": { "message": "Prepnúť bočnú navigáciu" }, @@ -4070,7 +4121,7 @@ "description": "Button text to display in overlay when there are no matching items" }, "addNewVaultItem": { - "message": "Pridať novú položku trezoru", + "message": "Pridať novú položku trezora", "description": "Screen reader text (aria-label) for new item button in overlay" }, "newLogin": { @@ -4275,7 +4326,7 @@ "message": "Prihlásený!" }, "passkeyNotCopied": { - "message": "Prístupový kód sa neskopíruje" + "message": "Prístupový kľúč sa neskopíruje" }, "passkeyNotCopiedAlert": { "message": "Prístupový kľúč sa do klonovanej položky neskopíruje. Chcete pokračovať v klonovaní tejto položky?" @@ -4305,7 +4356,7 @@ "message": "Uložiť prístupový kľúč" }, "savePasskeyNewLogin": { - "message": "Uložiť prístupový kľúč ako nové prihlasovacie údaje" + "message": "Uložiť prístupový kľúč ako nové prihlásenie" }, "chooseCipherForPasskeySave": { "message": "Vyberte prihlasovacie údaje, do ktorých chcete uložiť prístupový kľúč" @@ -4435,10 +4486,10 @@ "message": "server" }, "hostedAt": { - "message": "hotované na" + "message": "hostované na" }, "useDeviceOrHardwareKey": { - "message": "Použiť vaše zariadenie alebo hardvérový kľúč" + "message": "Použiť svoje zariadenie alebo hardvérový kľúč" }, "justOnce": { "message": "Iba raz" @@ -4512,7 +4563,7 @@ "description": "Dialog message facilitating the ability to override a chrome browser's default autofill behavior" }, "overrideDefaultBrowserAutoFillSettings": { - "message": "Nastaviť Bitwarden ako predvolený správca hesiel", + "message": "Nastaviť Bitwarden ako predvoleného správcu hesiel", "description": "Label for the setting that allows overriding the default browser autofill settings" }, "privacyPermissionAdditionNotGrantedTitle": { @@ -4816,7 +4867,7 @@ "message": "Stiahnuť Bitwarden na všetky zariadenia" }, "getTheMobileApp": { - "message": "Získajte mobilnú aplikáciu" + "message": "Získať mobilnú aplikáciu" }, "getTheMobileAppDesc": { "message": "Majte prístup k heslám na cestách pomocou mobilnej aplikácie Bitwarden." @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Politika organizácie vypla toto nastavenie.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "PSČ" + }, + "cardNumberLabel": { + "message": "Číslo karty" } } diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index cd7eda9a4fa..53f7a9d8f03 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "Pogled" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Element shranjen" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Ali ste prepričani, da želite to izbrisati?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 3421dc1fae1..169713c5047 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Употребити једнократну пријаву" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Добродошли назад" }, @@ -588,6 +591,9 @@ "view": { "message": "Приказ" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "Преглед пријаве" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Ставка уређена" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Сигурно послати ову ставку у отпад?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Угасити ауто-пуњење" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Прикажи предлоге за ауто-попуњавање у пољима обрасца" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Приказати идентитете као предлоге" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Грешка при декрипцији" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden није могао да декриптује ставке из трезора наведене испод." }, @@ -4011,6 +4053,15 @@ "message": "Ауто-попуњавање при учитавању странице је подешено да користи подразумевано подешавање.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Укључите бочну навигацију" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 07ed7a491f1..3b84369db47 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Använd Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Välkommen tillbaka" }, @@ -588,6 +591,9 @@ "view": { "message": "Visa" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "Visa inloggning" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Objekt sparat" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Är du säker på att du vill radera detta objekt?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Stäng av autofyll" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Visa förslag för autofyll i formulärfält" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Visa identiteter som förslag" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Dekrypteringsfel" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden kunde inte dekryptera valvföremålet/valvföremålen som listas nedan." }, @@ -4011,6 +4053,15 @@ "message": "Aktivera automatisk ifyllnad vid sidhämtning sattes till att använda standardinställningen.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Växla sidonavigering" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "Denna inställning är inaktiverad enligt din organisations policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index c4f0fffd143..a72a4910ef8 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "ஒற்றை உள்நுழைவைப் பயன்படுத்தவும்" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "மீண்டும் வருக" }, @@ -588,6 +591,9 @@ "view": { "message": "காண்" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "உள்நுழைவைக் காண்க" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "உருப்படி சேமிக்கப்பட்டது" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "நீங்கள் உண்மையிலேயே குப்பைக்கு அனுப்ப விரும்புகிறீர்களா?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "ஆட்டோஃபில்லை முடக்கு" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "படிவப் புலங்களில் ஆட்டோஃபில் பரிந்துரைகளைக் காட்டு" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "பரிந்துரைகளாக அடையாளங்களைக் காட்டு" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "குறியாக்கம் நீக்கப் பிழை" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden கீழே பட்டியலிடப்பட்ட பெட்டக பொருளை குறியாக்கம் நீக்க முடியவில்லை." }, @@ -4011,6 +4053,15 @@ "message": "பக்க ஏற்றத்தில் தானியங்கு நிரப்புதல் இயல்புநிலை அமைப்பைப் பயன்படுத்த அமைக்கப்பட்டுள்ளது.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "பக்க வழிசெலுத்தலை மாற்று" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index e3e6953b0df..f160e9a8cfa 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Welcome back" }, @@ -588,6 +591,9 @@ "view": { "message": "View" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Item saved" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Do you really want to send to the trash?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 7487dea84bd..dd27da81316 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "ใช้การลงชื่อเพียงครั้งเดียว" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "ยินดีต้อนรับกลับมา" }, @@ -588,6 +591,9 @@ "view": { "message": "แสดง" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "View login" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "แก้ไขรายการแล้ว" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "คุณต้องการส่งไปยังถังขยะใช่หรือไม่?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Turn off autofill" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Show autofill suggestions on form fields" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Display identities as suggestions" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Decryption error" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden could not decrypt the vault item(s) listed below." }, @@ -4011,6 +4053,15 @@ "message": "Autofill on page load set to use default setting.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Toggle side navigation" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index e33addd805c..d982d0f3a1a 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Çoklu oturum açma kullan" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Tekrar hoş geldiniz" }, @@ -588,6 +591,9 @@ "view": { "message": "Görüntüle" }, + "viewAll": { + "message": "Tümünü göster" + }, "viewLogin": { "message": "Hesabı göster" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Hesap kaydedildi" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Çöp kutusuna göndermek istediğinizden emin misiniz?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Otomatik doldurmayı kapat" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Form alanlarında otomatik doldurma önerilerini göster" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Kimlikleri öneri olarak göster" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Şifre çözme sorunu" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden aşağıdaki kasa öğelerini deşifre edemedi." }, @@ -4011,6 +4053,15 @@ "message": "Sayfa yüklenince otomatik doldurma, varsayılan ayarı kullanacak şekilde ayarlandı.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Kenar menüsünü aç/kapat" }, @@ -5580,16 +5631,16 @@ "message": "Kasanıza hoş geldiniz!" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "Dolandırıcılık girişimi tespit edildi" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "Girmeye çalıştığınız site kötü amaçlı ve güvenlik riski taşıyan bir sitedir." }, "phishingPageCloseTabV2": { "message": "Bu sekmeyi kapat" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Siteye devam et (önerilmez)" }, "phishingPageExplanation1": { "message": "This site was found in ", @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / posta kodu" + }, + "cardNumberLabel": { + "message": "Kart numarası" } } diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index dba38faaec6..aa118c0b93e 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Використати єдиний вхід" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "З поверненням" }, @@ -551,15 +554,15 @@ "message": "Скинути пошук" }, "archiveNoun": { - "message": "Archive", + "message": "Архів", "description": "Noun" }, "archiveVerb": { - "message": "Archive", + "message": "Архівувати", "description": "Verb" }, "unArchive": { - "message": "Unarchive" + "message": "Видобути" }, "itemsInArchive": { "message": "Записи в архіві" @@ -571,10 +574,10 @@ "message": "Архівовані записи з'являтимуться тут і будуть виключені з результатів звичайного пошуку та пропозицій автозаповнення." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "Запис архівовано" }, "itemUnarchived": { - "message": "Item was unarchived" + "message": "Запис розархівовано" }, "archiveItem": { "message": "Архівувати запис" @@ -588,6 +591,9 @@ "view": { "message": "Переглянути" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "Переглянути запис" }, @@ -734,7 +740,7 @@ "message": "Неправильний головний пароль" }, "invalidMasterPasswordConfirmEmailAndHost": { - "message": "Invalid master password. Confirm your email is correct and your account was created on $HOST$.", + "message": "Неправильний головний пароль. Перевірте правильність адреси електронної пошти та розміщення облікового запису на $HOST$.", "placeholders": { "host": { "content": "$1", @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Запис збережено" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Ви дійсно хочете перенести до смітника?" }, @@ -1549,13 +1567,13 @@ "message": "Зчитати ключ безпеки" }, "readingPasskeyLoading": { - "message": "Reading passkey..." + "message": "Читання ключа доступу..." }, "passkeyAuthenticationFailed": { - "message": "Passkey authentication failed" + "message": "Збій автентифікації ключа доступу" }, "useADifferentLogInMethod": { - "message": "Use a different log in method" + "message": "Використати інший спосіб входу" }, "awaitingSecurityKeyInteraction": { "message": "Очікується взаємодія з ключем безпеки..." @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Вимкніть автозаповнення" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Пропозиції автозаповнення на полях форм" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Показувати посвідчення як пропозиції" }, @@ -3217,7 +3256,7 @@ } }, "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$.", "placeholders": { "organization": { "content": "$1", @@ -3226,7 +3265,7 @@ } }, "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "message": "Буде експортовано лише сховище організації, пов'язане з $ORGANIZATION$. Записи моїх збірок не будуть включені.", "placeholders": { "organization": { "content": "$1", @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Помилка розшифрування" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden не зміг розшифрувати вказані нижче елементи сховища." }, @@ -4011,6 +4053,15 @@ "message": "Автозаповнення на сторінці налаштовано з типовими параметрами.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Перемкнути бічну навігацію" }, @@ -5580,30 +5631,30 @@ "message": "Вітаємо у вашому сховищі!" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "Виявлено спробу шахрайства" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "Ви намагаєтеся відвідати відомий зловмисний вебсайт, який має ризики безпеки." }, "phishingPageCloseTabV2": { - "message": "Close this tab" + "message": "Закрити цю вкладку" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Перейти на цей сайт (не рекомендується)" }, "phishingPageExplanation1": { - "message": "This site was found in ", + "message": "Цей сайт знайдено в ", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." }, "phishingPageExplanation2": { - "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "message": "– відкритий список відомих шахрайських сайтів, що використовуються для викрадення особистої та конфіденційної інформації.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, "phishingPageLearnMore": { - "message": "Learn more about phishing detection" + "message": "Докладніше про виявлення шахрайства" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "Захищено $PRODUCT$", "placeholders": { "product": { "content": "$1", @@ -5716,10 +5767,16 @@ "message": "Підтвердити домен Key Connector" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "message": "Ви чудово впоралися із захистом своїх ризикованих записів!" }, "settingDisabledByPolicy": { - "message": "This setting is disabled by your organization's policy.", + "message": "Цей параметр вимкнено політикою вашої організації.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 055e5155955..fff32a542cc 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "Dùng đăng nhập một lần" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "Chào mừng bạn trở lại" }, @@ -588,6 +591,9 @@ "view": { "message": "Xem" }, + "viewAll": { + "message": "View all" + }, "viewLogin": { "message": "Xem đăng nhập" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "Đã lưu mục" }, + "savedWebsite": { + "message": "Saved website" + }, + "savedWebsites": { + "message": "Saved websites ( $COUNT$ )", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "Bạn có chắc muốn cho nó vào thùng rác?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "Tắt tự động điền" }, + "confirmAutofill": { + "message": "Confirm autofill" + }, + "confirmAutofillDesc": { + "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + }, "showInlineMenuLabel": { "message": "Hiển thị các gợi ý tự động điền trên các trường biểu mẫu" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "How does Bitwarden protect your data from phishing?" + }, + "currentWebsite": { + "message": "Current website" + }, + "autofillAndAddWebsite": { + "message": "Autofill and add this website" + }, + "autofillWithoutAdding": { + "message": "Autofill without adding" + }, + "doNotAutofill": { + "message": "Do not autofill" + }, "showInlineMenuIdentitiesLabel": { "message": "Hiển thị danh tính dưới dạng gợi ý" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "Lỗi giải mã" }, + "errorGettingAutoFillData": { + "message": "Error getting autofill data" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden không thể giải mã các mục trong kho lưu trữ được liệt kê bên dưới." }, @@ -4011,6 +4053,15 @@ "message": "Tự động điền khi tải trang được đặt thành mặc định trong cài đặt.", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "Cannot autofill" + }, + "cannotAutofillExactMatch": { + "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + }, + "okay": { + "message": "Okay" + }, "toggleSideNavigation": { "message": "Ẩn/hiện thanh điều hướng bên" }, @@ -5580,30 +5631,30 @@ "message": "Chào mừng đến với kho lưu trữ của bạn!" }, "phishingPageTitleV2": { - "message": "Phishing attempt detected" + "message": "Đã phát hiện nỗ lực lừa đảo" }, "phishingPageSummary": { - "message": "The site you are attempting to visit is a known malicious site and a security risk." + "message": "Trang web bạn đang cố gắng truy cập là một trang độc hại đã được báo cáo và có nguy cơ bảo mật." }, "phishingPageCloseTabV2": { - "message": "Close this tab" + "message": "Đóng thẻ này" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Tiếp tục truy cập trang web này (không khuyến khích)" }, "phishingPageExplanation1": { - "message": "This site was found in ", + "message": "Trang web này được tìm thấy trong ", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." }, "phishingPageExplanation2": { - "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "message": ", một danh sách nguồn mở các trang lừa đảo đã biết được sử dụng để đánh cắp thông tin cá nhân và nhạy cảm.", "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." }, "phishingPageLearnMore": { - "message": "Learn more about phishing detection" + "message": "Tìm hiểu thêm về phát hiện lừa đảo" }, "protectedBy": { - "message": "Protected by $PRODUCT$", + "message": "Được bảo vệ bởi $PRODUCT$", "placeholders": { "product": { "content": "$1", @@ -5716,10 +5767,16 @@ "message": "Xác nhận tên miền Key Connector" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "message": "Thật tuyệt khi bảo vệ các đăng nhập có nguy cơ của bạn!" }, "settingDisabledByPolicy": { - "message": "This setting is disabled by your organization's policy.", + "message": "Cài đặt này bị vô hiệu hóa bởi chính sách tổ chức của bạn.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 1d1a6674e18..16f41e4e987 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "使用单点登录" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "欢迎回来" }, @@ -407,7 +410,7 @@ "message": "创建文件夹以整理您的密码库项目" }, "deleteFolderPermanently": { - "message": "您确定要永久删除这个文件夹吗?" + "message": "确定要永久删除此文件夹吗?" }, "deleteFolder": { "message": "删除文件夹" @@ -588,6 +591,9 @@ "view": { "message": "查看" }, + "viewAll": { + "message": "查看全部" + }, "viewLogin": { "message": "查看登录" }, @@ -682,7 +688,7 @@ "message": "会话超时" }, "vaultTimeoutHeader": { - "message": "密码库超时时间" + "message": "密码库超时" }, "otherOptions": { "message": "其他选项" @@ -743,10 +749,10 @@ } }, "vaultTimeout": { - "message": "密码库超时时间" + "message": "密码库超时" }, "vaultTimeout1": { - "message": "超时" + "message": "超时时间" }, "lockNow": { "message": "立即锁定" @@ -1028,6 +1034,18 @@ "editedItem": { "message": "项目已保存" }, + "savedWebsite": { + "message": "保存的网站" + }, + "savedWebsites": { + "message": "保存的网站 ($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "您确定要将其发送到回收站吗?" }, @@ -1221,7 +1239,7 @@ "description": "Detailed error message shown when saving login details fails." }, "changePasswordWarning": { - "message": "更改密码后,您需要使用新密码登录。 在其他设备上的活动会话将在一小时内注销。" + "message": "更改密码后,您需要使用新密码登录。在其他设备上的活动会话将在一小时内注销。" }, "accountRecoveryUpdateMasterPasswordSubtitle": { "message": "更改您的主密码以完成账户恢复。" @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "停用自动填充" }, + "confirmAutofill": { + "message": "确认自动填充" + }, + "confirmAutofillDesc": { + "message": "此网站与您保存的登录信息不匹配。在填写您的登录凭据之前,请确保它是一个可信的网站。" + }, "showInlineMenuLabel": { "message": "在表单字段中显示自动填充建议" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Bitwarden 如何保护您的数据免遭网络钓鱼?" + }, + "currentWebsite": { + "message": "当前网站" + }, + "autofillAndAddWebsite": { + "message": "自动填充并添加此网站" + }, + "autofillWithoutAdding": { + "message": "自动填充但不添加" + }, + "doNotAutofill": { + "message": "不自动填充" + }, "showInlineMenuIdentitiesLabel": { "message": "将身份显示为建议" }, @@ -2356,7 +2395,7 @@ "message": "已经有账户了吗?" }, "vaultTimeoutLogOutConfirmation": { - "message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定使用此设置吗?" + "message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定要使用此设置吗?" }, "vaultTimeoutLogOutConfirmationTitle": { "message": "超时动作确认" @@ -2858,7 +2897,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deleteSendPermanentConfirmation": { - "message": "您确定要永久删除这个 Send 吗?", + "message": "确定要永久删除这个 Send 吗?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editSend": { @@ -3074,7 +3113,7 @@ "message": "企业策略要求已应用到您的超时选项中" }, "vaultTimeoutPolicyInEffect": { - "message": "您的组织策略已将您最大允许的密码库超时时间设置为 $HOURS$ 小时 $MINUTES$ 分钟。", + "message": "您的组织策略已将您最大允许的密码库超时设置为 $HOURS$ 小时 $MINUTES$ 分钟。", "placeholders": { "hours": { "content": "$1", @@ -3100,7 +3139,7 @@ } }, "vaultTimeoutPolicyMaximumError": { - "message": "超时时间超出了您组织设置的限制:最多 $HOURS$ 小时 $MINUTES$ 分钟", + "message": "超时超出了您组织设置的限制:最多 $HOURS$ 小时 $MINUTES$ 分钟", "placeholders": { "hours": { "content": "$1", @@ -3113,7 +3152,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时时间。最大允许的密码库超时时间是 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", "placeholders": { "hours": { "content": "$1", @@ -3130,7 +3169,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "您的组织策略已将您的密码库超时动作设置为 $ACTION$。", + "message": "您的组织策略已将您的密码库超时动作设置为「$ACTION$」。", "placeholders": { "action": { "content": "$1", @@ -3139,7 +3178,7 @@ } }, "vaultTimeoutTooLarge": { - "message": "您的密码库超时时间超出了组织设置的限制。" + "message": "您的密码库超时超出了您组织设置的限制。" }, "vaultExportDisabled": { "message": "密码库导出已禁用" @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "解密错误" }, + "errorGettingAutoFillData": { + "message": "获取自动填充数据时出错" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden 无法解密下列密码库项目。" }, @@ -4011,6 +4053,15 @@ "message": "页面加载时自动填充设置为使用默认设置。", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "无法自动填充" + }, + "cannotAutofillExactMatch": { + "message": "默认匹配被设置为「精确匹配」。当前网站与此项目保存的登录信息不完全匹配。" + }, + "okay": { + "message": "确定" + }, "toggleSideNavigation": { "message": "切换侧边导航" }, @@ -4837,7 +4888,7 @@ "message": "从 App Store 下载" }, "permanentlyDeleteAttachmentConfirmation": { - "message": "您确定要永久删除此附件吗?" + "message": "确定要永久删除此附件吗?" }, "premium": { "message": "高级会员" @@ -5238,7 +5289,7 @@ "message": "重试" }, "vaultCustomTimeoutMinimum": { - "message": "自定义超时时间最小为 1 分钟。" + "message": "自定义超时最少为 1 分钟。" }, "fileSavedToDevice": { "message": "文件已保存到设备。可以在设备下载中进行管理。" @@ -5716,10 +5767,16 @@ "message": "确认 Key Connector 域名" }, "atRiskLoginsSecured": { - "message": "Great job securing your at-risk logins!" + "message": "很好地保护了存在风险的登录!" }, "settingDisabledByPolicy": { "message": "此设置被您组织的策略禁用了。", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "ZIP / 邮政编码" + }, + "cardNumberLabel": { + "message": "卡号" } } diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index e2d9ff2068f..d3c0319e488 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -31,6 +31,9 @@ "useSingleSignOn": { "message": "使用單一登入" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "welcomeBack": { "message": "歡迎回來" }, @@ -588,6 +591,9 @@ "view": { "message": "檢視" }, + "viewAll": { + "message": "檢視全部" + }, "viewLogin": { "message": "檢視登入" }, @@ -1028,6 +1034,18 @@ "editedItem": { "message": "項目已儲存" }, + "savedWebsite": { + "message": "已儲存的網站" + }, + "savedWebsites": { + "message": "已儲存的網站($COUNT$)", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, "deleteItemConfirmation": { "message": "確定要刪除此項目嗎?" }, @@ -1676,9 +1694,30 @@ "turnOffAutofill": { "message": "停用自動填入" }, + "confirmAutofill": { + "message": "確認自動填入" + }, + "confirmAutofillDesc": { + "message": "此網站與您儲存的登入資料不相符。在填入登入憑證前,請確認這是受信任的網站。" + }, "showInlineMenuLabel": { "message": "在表單欄位上顯示自動填入選單" }, + "howDoesBitwardenProtectFromPhishing": { + "message": "Bitwarden 如何保護您的資料免於網路釣魚攻擊?" + }, + "currentWebsite": { + "message": "目網站" + }, + "autofillAndAddWebsite": { + "message": "自動填充並新增此網站" + }, + "autofillWithoutAdding": { + "message": "自動填入但不新增" + }, + "doNotAutofill": { + "message": "不要自動填入" + }, "showInlineMenuIdentitiesLabel": { "message": "顯示身分建議" }, @@ -3240,6 +3279,9 @@ "decryptionError": { "message": "解密發生錯誤" }, + "errorGettingAutoFillData": { + "message": "取得自動填入資料時發生錯誤" + }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden 無法解密您密碼庫中下面的項目。" }, @@ -4011,6 +4053,15 @@ "message": "將頁面載入時使用自動填入功能設定為預設。", "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, + "cannotAutofill": { + "message": "無法自動填入" + }, + "cannotAutofillExactMatch": { + "message": "預設比對方式為「完全相符」。目前的網站與此項目的已儲存登入資料不完全相符。" + }, + "okay": { + "message": "確定" + }, "toggleSideNavigation": { "message": "切換側邊欄" }, @@ -5721,5 +5772,11 @@ "settingDisabledByPolicy": { "message": "此設定已被你的組織原則停用。", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." + }, + "zipPostalCodeLabel": { + "message": "郵編 / 郵政代碼" + }, + "cardNumberLabel": { + "message": "支付卡號碼" } } diff --git a/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts b/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts new file mode 100644 index 00000000000..5ea6fac7ebb --- /dev/null +++ b/apps/browser/src/auth/popup/constants/auth-extension-route.constant.ts @@ -0,0 +1,8 @@ +// Full routes that auth owns in the extension +export const AuthExtensionRoute = Object.freeze({ + AccountSecurity: "account-security", + DeviceManagement: "device-management", + AccountSwitcher: "account-switcher", +} as const); + +export type AuthExtensionRoute = (typeof AuthExtensionRoute)[keyof typeof AuthExtensionRoute]; diff --git a/apps/browser/src/auth/popup/constants/index.ts b/apps/browser/src/auth/popup/constants/index.ts new file mode 100644 index 00000000000..59855040fd3 --- /dev/null +++ b/apps/browser/src/auth/popup/constants/index.ts @@ -0,0 +1 @@ +export * from "./auth-extension-route.constant"; diff --git a/apps/browser/src/autofill/notification/bar.html b/apps/browser/src/autofill/notification/bar.html index 90df670d29c..c0b57de612e 100644 --- a/apps/browser/src/autofill/notification/bar.html +++ b/apps/browser/src/autofill/notification/bar.html @@ -5,55 +5,5 @@ Bitwarden - - -
-
- - - -
-
-
- -
-
- - - - - - - + diff --git a/apps/browser/src/autofill/notification/bar.scss b/apps/browser/src/autofill/notification/bar.scss deleted file mode 100644 index c91c5f3ebac..00000000000 --- a/apps/browser/src/autofill/notification/bar.scss +++ /dev/null @@ -1,304 +0,0 @@ -@import "../shared/styles/variables"; - -body { - margin: 0; - padding: 0; - height: 100%; - font-size: 14px; - line-height: 16px; - font-family: $font-family-sans-serif; - - @include themify($themes) { - color: themed("textColor"); - background-color: themed("backgroundColor"); - } -} - -img { - margin: 0; - padding: 0; - border: 0; -} - -button, -select { - font-size: $font-size-base; - font-family: $font-family-sans-serif; -} - -.outer-wrapper { - display: block; - position: relative; - padding: 8px; - min-height: 42px; - border: 1px solid transparent; - border-bottom: 2px solid transparent; - border-radius: 4px; - box-sizing: border-box; - - @include themify($themes) { - border-color: themed("borderColor"); - border-bottom-color: themed("primaryColor"); - } - - &.success-event { - @include themify($themes) { - border-bottom-color: themed("successColor"); - } - } - - &.error-event { - @include themify($themes) { - border-bottom-color: themed("errorColor"); - } - } -} - -.inner-wrapper { - display: grid; - grid-template-columns: auto max-content; -} - -.outer-wrapper > *, -.inner-wrapper > * { - align-self: center; -} - -#logo { - width: 24px; - height: 24px; - display: block; -} - -.logo-wrapper { - position: absolute; - top: 8px; - left: 10px; - overflow: hidden; -} - -#close-button { - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - margin-right: 10px; - padding: 0; - - &:hover { - @include themify($themes) { - border-color: rgba(themed("textColor"), 0.2); - background-color: rgba(themed("textColor"), 0.2); - } - } -} - -#close { - display: block; - width: 16px; - height: 16px; - - > path { - @include themify($themes) { - fill: themed("textColor"); - } - } -} - -.notification-close { - position: absolute; - top: 6px; - right: 6px; -} - -#content .inner-wrapper { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - - .notification-body { - width: 100%; - padding: 4px 38px 24px 42px; - font-weight: 400; - } - - .notification-actions { - display: flex; - width: 100%; - align-items: stretch; - justify-content: flex-end; - - #never-save { - margin-right: auto; - padding: 0; - font-size: 16px; - font-weight: 500; - letter-spacing: 0.5px; - } - - #select-folder { - width: 125px; - margin-right: 6px; - font-size: 12px; - appearance: none; - background-repeat: no-repeat; - background-position: center right 4px; - background-size: 16px; - - @include themify($themes) { - color: themed("mutedTextColor"); - border-color: themed("mutedTextColor"); - } - - &:not([disabled]) { - display: block; - } - } - - .primary, - .secondary { - font-size: 12px; - } - - .secondary { - margin-right: 6px; - border-width: 1px; - } - - .primary { - margin-right: 2px; - } - - &.success-message, - &.error-message { - padding: 4px 36px 6px 42px; - } - } -} - -button { - padding: 4px 8px; - border-radius: $border-radius; - border: 1px solid transparent; - cursor: pointer; -} - -button.primary:not(.neutral) { - @include themify($themes) { - background-color: themed("primaryColor"); - color: themed("textContrast"); - border-color: themed("primaryColor"); - } - - &:hover { - @include themify($themes) { - background-color: darken(themed("primaryColor"), 1.5%); - color: darken(themed("textContrast"), 6%); - } - } -} - -button.secondary:not(.neutral) { - @include themify($themes) { - background-color: themed("backgroundColor"); - color: themed("mutedTextColor"); - border-color: themed("mutedTextColor"); - } - - &:hover { - @include themify($themes) { - background-color: themed("backgroundOffsetColor"); - color: darken(themed("mutedTextColor"), 6%); - } - } -} - -button.link, -button.neutral { - @include themify($themes) { - background-color: transparent; - color: themed("primaryColor"); - } - - &:hover { - text-decoration: underline; - - @include themify($themes) { - color: darken(themed("primaryColor"), 6%); - } - } -} - -select { - padding: 4px 6px; - border: 1px solid #000000; - border-radius: $border-radius; - - @include themify($themes) { - color: themed("textColor"); - background-color: themed("inputBackgroundColor"); - border-color: themed("inputBorderColor"); - } -} - -.success-message { - display: flex; - align-items: center; - justify-content: center; - - @include themify($themes) { - color: themed("successColor"); - } - - svg { - margin-right: 8px; - - path { - @include themify($themes) { - fill: themed("successColor"); - } - } - } -} - -.error-message { - @include themify($themes) { - color: themed("errorColor"); - } -} - -.success-event, -.error-event { - .notification-body { - display: none; - } -} - -@media screen and (max-width: 768px) { - #select-folder { - display: none; - } -} - -@media print { - body { - display: none; - } -} - -.theme_light { - #content .inner-wrapper { - #select-folder { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nMTYnIGhlaWdodD0nMTYnIGZpbGw9J25vbmUnPjxwYXRoIHN0cm9rZT0nIzIxMjUyOScgZD0nbTUgNiAzIDMgMy0zJy8+PC9zdmc+"); - } - } -} - -.theme_dark { - #content .inner-wrapper { - #select-folder { - background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxNicgaGVpZ2h0PScxNicgZmlsbD0nbm9uZSc+PHBhdGggc3Ryb2tlPScjZmZmZmZmJyBkPSdtNSA2IDMgMyAzLTMnLz48L3N2Zz4="); - } - } -} diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index fcf91ca2e91..3673a9f7321 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -187,8 +187,6 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { const notificationTestId = getNotificationTestId(notificationType); appendHeaderMessageToTitle(headerMessage); - document.body.innerHTML = ""; - if (isVaultLocked) { const notificationConfig = { ...notificationBarIframeInitData, diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index add53a0cd33..1153ad58719 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -232,7 +232,7 @@ {{ "enableAutoTotpCopy" | i18n }} - + {{ "clearClipboard" | i18n }} - + {{ "defaultUriMatchDetection" | i18n }} - - {{ "settingDisabledByPolicy" | i18n }} - {{ hints[0] | i18n }} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 8170c2a65a0..00e5526f4e2 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -293,6 +293,7 @@ import { AutofillBadgeUpdaterService } from "../autofill/services/autofill-badge import AutofillService from "../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service"; import { SafariApp } from "../browser/safariApp"; +import { PhishingDataService } from "../dirt/phishing-detection/services/phishing-data.service"; import { PhishingDetectionService } from "../dirt/phishing-detection/services/phishing-detection.service"; import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service"; import VaultTimeoutService from "../key-management/vault-timeout/vault-timeout.service"; @@ -491,6 +492,9 @@ export default class MainBackground { private popupViewCacheBackgroundService: PopupViewCacheBackgroundService; private popupRouterCacheBackgroundService: PopupRouterCacheBackgroundService; + // DIRT + private phishingDataService: PhishingDataService; + constructor() { // Services const lockedCallback = async (userId: UserId) => { @@ -1215,7 +1219,7 @@ export default class MainBackground { logoutCallback, this.messagingService, this.accountService, - new SignalRConnectionService(this.apiService, this.logService), + new SignalRConnectionService(this.apiService, this.logService, this.platformUtilsService), this.authService, this.webPushConnectionService, this.authRequestAnsweringService, @@ -1451,15 +1455,20 @@ export default class MainBackground { this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + this.phishingDataService = new PhishingDataService( + this.apiService, + this.taskSchedulerService, + this.globalStateProvider, + this.logService, + this.platformUtilsService, + ); + PhishingDetectionService.initialize( this.accountService, - this.auditService, this.billingAccountProfileStateService, this.configService, - this.eventCollectionService, this.logService, - this.storageService, - this.taskSchedulerService, + this.phishingDataService, ); this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts new file mode 100644 index 00000000000..94f3e99f8be --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.spec.ts @@ -0,0 +1,158 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + DefaultTaskSchedulerService, + TaskSchedulerService, +} from "@bitwarden/common/platform/scheduling"; +import { FakeGlobalStateProvider } from "@bitwarden/common/spec"; +import { LogService } from "@bitwarden/logging"; + +import { PhishingDataService, PhishingData, PHISHING_DOMAINS_KEY } from "./phishing-data.service"; + +describe("PhishingDataService", () => { + let service: PhishingDataService; + let apiService: MockProxy; + let taskSchedulerService: TaskSchedulerService; + let logService: MockProxy; + let platformUtilsService: MockProxy; + const stateProvider: FakeGlobalStateProvider = new FakeGlobalStateProvider(); + + const setMockState = (state: PhishingData) => { + stateProvider.getFake(PHISHING_DOMAINS_KEY).stateSubject.next(state); + return state; + }; + + let fetchChecksumSpy: jest.SpyInstance; + let fetchDomainsSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + apiService = mock(); + logService = mock(); + + platformUtilsService = mock(); + platformUtilsService.getApplicationVersion.mockResolvedValue("1.0.0"); + + taskSchedulerService = new DefaultTaskSchedulerService(logService); + + service = new PhishingDataService( + apiService, + taskSchedulerService, + stateProvider, + logService, + platformUtilsService, + ); + + fetchChecksumSpy = jest.spyOn(service as any, "fetchPhishingDomainsChecksum"); + fetchDomainsSpy = jest.spyOn(service as any, "fetchPhishingDomains"); + }); + + describe("isPhishingDomains", () => { + it("should detect a phishing domain", async () => { + setMockState({ + domains: ["phish.com", "badguy.net"], + timestamp: Date.now(), + checksum: "abc123", + applicationVersion: "1.0.0", + }); + const url = new URL("http://phish.com"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(true); + }); + + it("should not detect a safe domain", async () => { + setMockState({ + domains: ["phish.com", "badguy.net"], + timestamp: Date.now(), + checksum: "abc123", + applicationVersion: "1.0.0", + }); + const url = new URL("http://safe.com"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(false); + }); + + it("should match against root domain", async () => { + setMockState({ + domains: ["phish.com", "badguy.net"], + timestamp: Date.now(), + checksum: "abc123", + applicationVersion: "1.0.0", + }); + const url = new URL("http://phish.com/about"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(true); + }); + + it("should not error on empty state", async () => { + setMockState(undefined as any); + const url = new URL("http://phish.com/about"); + const result = await service.isPhishingDomain(url); + expect(result).toBe(false); + }); + }); + + describe("getNextDomains", () => { + it("refetches all domains if applicationVersion has changed", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 60000, + checksum: "old", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("new"); + fetchDomainsSpy.mockResolvedValue(["d.com", "e.com"]); + platformUtilsService.getApplicationVersion.mockResolvedValue("2.0.0"); + + const result = await service.getNextDomains(prev); + + expect(result!.domains).toEqual(["d.com", "e.com"]); + expect(result!.checksum).toBe("new"); + expect(result!.applicationVersion).toBe("2.0.0"); + }); + + it("only updates timestamp if checksum matches", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 60000, + checksum: "abc", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("abc"); + const result = await service.getNextDomains(prev); + expect(result!.domains).toEqual(prev.domains); + expect(result!.checksum).toBe("abc"); + expect(result!.timestamp).not.toBe(prev.timestamp); + }); + + it("patches daily domains if cache is fresh", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 60000, + checksum: "old", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("new"); + fetchDomainsSpy.mockResolvedValue(["b.com", "c.com"]); + const result = await service.getNextDomains(prev); + expect(result!.domains).toEqual(["a.com", "b.com", "c.com"]); + expect(result!.checksum).toBe("new"); + }); + + it("fetches all domains if cache is old", async () => { + const prev: PhishingData = { + domains: ["a.com"], + timestamp: Date.now() - 2 * 24 * 60 * 60 * 1000, + checksum: "old", + applicationVersion: "1.0.0", + }; + fetchChecksumSpy.mockResolvedValue("new"); + fetchDomainsSpy.mockResolvedValue(["d.com", "e.com"]); + const result = await service.getNextDomains(prev); + expect(result!.domains).toEqual(["d.com", "e.com"]); + expect(result!.checksum).toBe("new"); + }); + }); +}); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts new file mode 100644 index 00000000000..0c5ba500efc --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -0,0 +1,221 @@ +import { + catchError, + EMPTY, + first, + firstValueFrom, + map, + retry, + startWith, + Subject, + switchMap, + tap, + timer, +} from "rxjs"; + +import { devFlagEnabled, devFlagValue } from "@bitwarden/browser/platform/flags"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ScheduledTaskNames, TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; +import { LogService } from "@bitwarden/logging"; +import { GlobalStateProvider, KeyDefinition, PHISHING_DETECTION_DISK } from "@bitwarden/state"; + +export type PhishingData = { + domains: string[]; + timestamp: number; + checksum: string; + + /** + * We store the application version to refetch the entire dataset on a new client release. + * This counteracts daily appends updates not removing inactive or false positive domains. + */ + applicationVersion: string; +}; + +export const PHISHING_DOMAINS_KEY = new KeyDefinition( + PHISHING_DETECTION_DISK, + "phishingDomains", + { + deserializer: (value: PhishingData) => + value ?? { domains: [], timestamp: 0, checksum: "", applicationVersion: "" }, + }, +); + +/** Coordinates fetching, caching, and patching of known phishing domains */ +export class PhishingDataService { + private static readonly RemotePhishingDatabaseUrl = + "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/master/phishing-domains-ACTIVE.txt"; + private static readonly RemotePhishingDatabaseChecksumUrl = + "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.md5"; + private static readonly RemotePhishingDatabaseTodayUrl = + "https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-domains-NEW-today.txt"; + + private _testDomains = this.getTestDomains(); + private _cachedState = this.globalStateProvider.get(PHISHING_DOMAINS_KEY); + private _domains$ = this._cachedState.state$.pipe( + map( + (state) => + new Set( + (state?.domains?.filter((line) => line.trim().length > 0) ?? []).concat( + this._testDomains, + ), + ), + ), + ); + + // How often are new domains added to the remote? + readonly UPDATE_INTERVAL_DURATION = 24 * 60 * 60 * 1000; // 24 hours + + private _triggerUpdate$ = new Subject(); + update$ = this._triggerUpdate$.pipe( + startWith(), // Always emit once + tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)), + switchMap(() => + this._cachedState.state$.pipe( + first(), // Only take the first value to avoid an infinite loop when updating the cache below + switchMap(async (cachedState) => { + const next = await this.getNextDomains(cachedState); + if (next) { + await this._cachedState.update(() => next); + this.logService.info(`[PhishingDataService] cache updated`); + } + }), + retry({ + count: 3, + delay: (err, count) => { + this.logService.error( + `[PhishingDataService] Unable to update domains. Attempt ${count}.`, + err, + ); + return timer(5 * 60 * 1000); // 5 minutes + }, + resetOnSuccess: true, + }), + catchError( + ( + err: unknown /** Eslint actually crashed if you remove this type: https://github.com/cartant/eslint-plugin-rxjs/issues/122 */, + ) => { + this.logService.error( + "[PhishingDataService] Retries unsuccessful. Unable to update domains.", + err, + ); + return EMPTY; + }, + ), + ), + ), + ); + + constructor( + private apiService: ApiService, + private taskSchedulerService: TaskSchedulerService, + private globalStateProvider: GlobalStateProvider, + private logService: LogService, + private platformUtilsService: PlatformUtilsService, + ) { + this.taskSchedulerService.registerTaskHandler(ScheduledTaskNames.phishingDomainUpdate, () => { + this._triggerUpdate$.next(); + }); + this.taskSchedulerService.setInterval( + ScheduledTaskNames.phishingDomainUpdate, + this.UPDATE_INTERVAL_DURATION, + ); + } + + /** + * Checks if the given URL is a known phishing domain + * + * @param url The URL to check + * @returns True if the URL is a known phishing domain, false otherwise + */ + async isPhishingDomain(url: URL): Promise { + const domains = await firstValueFrom(this._domains$); + const result = domains.has(url.hostname); + if (result) { + this.logService.debug("[PhishingDataService] Caught phishing domain:", url.hostname); + return true; + } + return false; + } + + async getNextDomains(prev: PhishingData | null): Promise { + prev = prev ?? { domains: [], timestamp: 0, checksum: "", applicationVersion: "" }; + const timestamp = Date.now(); + const prevAge = timestamp - prev.timestamp; + this.logService.info(`[PhishingDataService] Cache age: ${prevAge}`); + + const applicationVersion = await this.platformUtilsService.getApplicationVersion(); + + // If checksum matches, return existing data with new timestamp & version + const remoteChecksum = await this.fetchPhishingDomainsChecksum(); + if (remoteChecksum && prev.checksum === remoteChecksum) { + this.logService.info( + `[PhishingDataService] Remote checksum matches local checksum, updating timestamp only.`, + ); + return { ...prev, timestamp, applicationVersion }; + } + // Checksum is different, data needs to be updated. + + // Approach 1: Fetch only new domains and append + const isOneDayOldMax = prevAge <= this.UPDATE_INTERVAL_DURATION; + if (isOneDayOldMax && applicationVersion === prev.applicationVersion) { + const dailyDomains: string[] = await this.fetchPhishingDomains( + PhishingDataService.RemotePhishingDatabaseTodayUrl, + ); + this.logService.info( + `[PhishingDataService] ${dailyDomains.length} new phishing domains added`, + ); + return { + domains: prev.domains.concat(dailyDomains), + checksum: remoteChecksum, + timestamp, + applicationVersion, + }; + } + + // Approach 2: Fetch all domains + const domains = await this.fetchPhishingDomains(PhishingDataService.RemotePhishingDatabaseUrl); + return { + domains, + timestamp, + checksum: remoteChecksum, + applicationVersion, + }; + } + + private async fetchPhishingDomainsChecksum() { + const response = await this.apiService.nativeFetch( + new Request(PhishingDataService.RemotePhishingDatabaseChecksumUrl), + ); + if (!response.ok) { + throw new Error(`[PhishingDataService] Failed to fetch checksum: ${response.status}`); + } + return response.text(); + } + + private async fetchPhishingDomains(url: string) { + const response = await this.apiService.nativeFetch(new Request(url)); + + if (!response.ok) { + throw new Error(`[PhishingDataService] Failed to fetch domains: ${response.status}`); + } + + return response.text().then((text) => text.split("\n")); + } + + private getTestDomains() { + const flag = devFlagEnabled("testPhishingUrls"); + if (!flag) { + return []; + } + + const domains = devFlagValue("testPhishingUrls") as unknown[]; + if (domains && domains instanceof Array) { + this.logService.debug( + "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing domains:", + domains, + ); + return domains as string[]; + } + return []; + } +} diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts index d6aca6abea0..5d2c4847671 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.spec.ts @@ -1,48 +1,36 @@ import { of } from "rxjs"; -import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; -import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling/task-scheduler.service"; +import { PhishingDataService } from "./phishing-data.service"; import { PhishingDetectionService } from "./phishing-detection.service"; describe("PhishingDetectionService", () => { let accountService: AccountService; - let auditService: AuditService; let billingAccountProfileStateService: BillingAccountProfileStateService; let configService: ConfigService; - let eventCollectionService: EventCollectionService; let logService: LogService; - let storageService: AbstractStorageService; - let taskSchedulerService: TaskSchedulerService; + let phishingDataService: PhishingDataService; beforeEach(() => { accountService = { getAccount$: jest.fn(() => of(null)) } as any; - auditService = { getKnownPhishingDomains: jest.fn() } as any; billingAccountProfileStateService = {} as any; configService = { getFeatureFlag$: jest.fn(() => of(false)) } as any; - eventCollectionService = {} as any; logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any; - storageService = { get: jest.fn(), save: jest.fn() } as any; - taskSchedulerService = { registerTaskHandler: jest.fn(), setInterval: jest.fn() } as any; + phishingDataService = {} as any; }); it("should initialize without errors", () => { expect(() => { PhishingDetectionService.initialize( accountService, - auditService, billingAccountProfileStateService, configService, - eventCollectionService, logService, - storageService, - taskSchedulerService, + phishingDataService, ); }).not.toThrow(); }); @@ -66,13 +54,10 @@ describe("PhishingDetectionService", () => { // Run the initialization PhishingDetectionService.initialize( accountService, - auditService, billingAccountProfileStateService, configService, - eventCollectionService, logService, - storageService, - taskSchedulerService, + phishingDataService, ); }); @@ -105,23 +90,10 @@ describe("PhishingDetectionService", () => { // Run the initialization PhishingDetectionService.initialize( accountService, - auditService, billingAccountProfileStateService, configService, - eventCollectionService, logService, - storageService, - taskSchedulerService, + phishingDataService, ); }); - - it("should detect phishing domains", () => { - PhishingDetectionService["_knownPhishingDomains"].add("phishing.com"); - const url = new URL("https://phishing.com"); - expect(PhishingDetectionService.isPhishingDomain(url)).toBe(true); - const safeUrl = new URL("https://safe.com"); - expect(PhishingDetectionService.isPhishingDomain(safeUrl)).toBe(false); - }); - - // Add more tests for other methods as needed }); diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index 179431b155c..8232b053526 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -1,28 +1,14 @@ -import { - combineLatest, - concatMap, - delay, - EMPTY, - map, - Subject, - Subscription, - switchMap, -} from "rxjs"; +import { combineLatest, concatMap, delay, EMPTY, map, Subject, switchMap, takeUntil } from "rxjs"; -import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; -import { devFlagEnabled, devFlagValue } from "@bitwarden/common/platform/misc/flags"; -import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling"; -import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling/task-scheduler.service"; import { BrowserApi } from "../../../platform/browser/browser-api"; +import { PhishingDataService } from "./phishing-data.service"; import { CaughtPhishingDomain, isPhishingDetectionMessage, @@ -32,39 +18,23 @@ import { } from "./phishing-detection.types"; export class PhishingDetectionService { - private static readonly _UPDATE_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - private static readonly _RETRY_INTERVAL = 5 * 60 * 1000; // 5 minutes - private static readonly _MAX_RETRIES = 3; - private static readonly _STORAGE_KEY = "phishing_domains_cache"; - private static _auditService: AuditService; + private static _destroy$ = new Subject(); + private static _logService: LogService; - private static _storageService: AbstractStorageService; - private static _taskSchedulerService: TaskSchedulerService; - private static _updateCacheSubscription: Subscription | null = null; - private static _retrySubscription: Subscription | null = null; + private static _phishingDataService: PhishingDataService; + private static _navigationEventsSubject = new Subject(); - private static _navigationEvents: Subscription | null = null; - private static _knownPhishingDomains = new Set(); private static _caughtTabs: Map = new Map(); - private static _isInitialized = false; - private static _isUpdating = false; - private static _retryCount = 0; - private static _lastUpdateTime: number = 0; static initialize( accountService: AccountService, - auditService: AuditService, billingAccountProfileStateService: BillingAccountProfileStateService, configService: ConfigService, - eventCollectionService: EventCollectionService, logService: LogService, - storageService: AbstractStorageService, - taskSchedulerService: TaskSchedulerService, + phishingDataService: PhishingDataService, ): void { - this._auditService = auditService; this._logService = logService; - this._storageService = storageService; - this._taskSchedulerService = taskSchedulerService; + this._phishingDataService = phishingDataService; logService.info("[PhishingDetectionService] Initialize called. Checking prerequisites..."); @@ -98,21 +68,6 @@ export class PhishingDetectionService { .subscribe(); } - /** - * Checks if the given URL is a known phishing domain - * - * @param url The URL to check - * @returns True if the URL is a known phishing domain, false otherwise - */ - static isPhishingDomain(url: URL): boolean { - const result = this._knownPhishingDomains.has(url.hostname); - if (result) { - this._logService.debug("[PhishingDetectionService] Caught phishing domain:", url.hostname); - return true; - } - return false; - } - /** * Sends a message to the phishing detection service to close the warning page */ @@ -146,45 +101,12 @@ export class PhishingDetectionService { } } - /** - * Initializes the phishing detection service, setting up listeners and registering tasks - */ - private static async _setup(): Promise { - if (this._isInitialized) { - this._logService.info("[PhishingDetectionService] Already initialized, skipping setup."); - return; - } - - this._isInitialized = true; - this._setupListeners(); - - // Register the update task - this._taskSchedulerService.registerTaskHandler( - ScheduledTaskNames.phishingDomainUpdate, - async () => { - try { - await this._fetchKnownPhishingDomains(); - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to update phishing domains in task handler:", - error, - ); - } - }, - ); - - // Initial load of cached domains - await this._loadCachedDomains(); - - // Set up periodic updates every 24 hours - this._setupPeriodicUpdates(); - this._logService.debug("[PhishingDetectionService] Phishing detection feature is initialized."); - } - /** * Sets up listeners for messages from the web page and web navigation events */ - private static _setupListeners(): void { + private static _setup(): void { + this._phishingDataService.update$.pipe(takeUntil(this._destroy$)).subscribe(); + // Setup listeners from web page/content script BrowserApi.addListener(chrome.runtime.onMessage, this._handleExtensionMessage.bind(this)); BrowserApi.addListener(chrome.tabs.onReplaced, this._handleReplacementEvent.bind(this)); @@ -192,9 +114,10 @@ export class PhishingDetectionService { // When a navigation event occurs, check if a replace event for the same tabId exists, // and call the replace handler before handling navigation. - this._navigationEvents = this._navigationEventsSubject + this._navigationEventsSubject .pipe( delay(100), // Delay slightly to allow replace events to be caught + takeUntil(this._destroy$), ) .subscribe(({ tabId, changeInfo, tab }) => { void this._processNavigation(tabId, changeInfo, tab); @@ -271,7 +194,7 @@ export class PhishingDetectionService { } // Check if tab is navigating to a phishing url and handle navigation - this._checkTabForPhishing(tabId, new URL(tab.url)); + await this._checkTabForPhishing(tabId, new URL(tab.url)); await this._handleTabNavigation(tabId); } @@ -371,11 +294,11 @@ export class PhishingDetectionService { * @param tabId Tab to check for phishing domain * @param url URL of the tab to check */ - private static _checkTabForPhishing(tabId: PhishingDetectionTabId, url: URL) { + private static async _checkTabForPhishing(tabId: PhishingDetectionTabId, url: URL) { // Check if the tab already being tracked const caughtTab = this._caughtTabs.get(tabId); - const isPhishing = this.isPhishingDomain(url); + const isPhishing = await this._phishingDataService.isPhishingDomain(url); this._logService.debug( `[PhishingDetectionService] Checking for phishing url. Result: ${isPhishing} on ${url}`, ); @@ -458,237 +381,16 @@ export class PhishingDetectionService { } } - /** - * Sets up periodic updates for phishing domains - */ - private static _setupPeriodicUpdates() { - // Clean up any existing subscriptions - if (this._updateCacheSubscription) { - this._updateCacheSubscription.unsubscribe(); - } - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - } - - this._updateCacheSubscription = this._taskSchedulerService.setInterval( - ScheduledTaskNames.phishingDomainUpdate, - this._UPDATE_INTERVAL, - ); - } - - /** - * Schedules a retry for updating phishing domains if the update fails - */ - private static _scheduleRetry() { - // If we've exceeded max retries, stop retrying - if (this._retryCount >= this._MAX_RETRIES) { - this._logService.warning( - `[PhishingDetectionService] Max retries (${this._MAX_RETRIES}) reached for phishing domain update. Will try again in ${this._UPDATE_INTERVAL / (1000 * 60 * 60)} hours.`, - ); - this._retryCount = 0; - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - this._retrySubscription = null; - } - return; - } - - // Clean up existing retry subscription if any - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - } - - // Increment retry count - this._retryCount++; - - // Schedule a retry in 5 minutes - this._retrySubscription = this._taskSchedulerService.setInterval( - ScheduledTaskNames.phishingDomainUpdate, - this._RETRY_INTERVAL, - ); - - this._logService.info( - `[PhishingDetectionService] Scheduled retry ${this._retryCount}/${this._MAX_RETRIES} for phishing domain update in ${this._RETRY_INTERVAL / (1000 * 60)} minutes`, - ); - } - - /** - * Handles adding test phishing URLs from dev flags for testing purposes - */ - private static _handleTestUrls() { - if (devFlagEnabled("testPhishingUrls")) { - const testPhishingUrls = devFlagValue("testPhishingUrls"); - this._logService.debug( - "[PhishingDetectionService] Dev flag enabled for testing phishing detection. Adding test phishing domains:", - testPhishingUrls, - ); - if (testPhishingUrls && testPhishingUrls instanceof Array) { - testPhishingUrls.forEach((domain) => { - if (domain && typeof domain === "string") { - this._knownPhishingDomains.add(domain); - } - }); - } - } - } - - /** - * Loads cached phishing domains from storage - * If no cache exists or it is expired, fetches the latest domains - */ - private static async _loadCachedDomains() { - try { - const cachedData = await this._storageService.get<{ domains: string[]; timestamp: number }>( - this._STORAGE_KEY, - ); - if (cachedData) { - this._logService.info("[PhishingDetectionService] Phishing cachedData exists"); - const phishingDomains = cachedData.domains || []; - - this._setKnownPhishingDomains(phishingDomains); - this._handleTestUrls(); - } - - // If cache is empty or expired, trigger an immediate update - if ( - this._knownPhishingDomains.size === 0 || - Date.now() - this._lastUpdateTime >= this._UPDATE_INTERVAL - ) { - await this._fetchKnownPhishingDomains(); - } - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to load cached phishing domains:", - error, - ); - this._handleTestUrls(); - } - } - - /** - * Fetches the latest known phishing domains from the audit service - * Updates the cache and handles retries if necessary - */ - static async _fetchKnownPhishingDomains(): Promise { - let domains: string[] = []; - - // Prevent concurrent updates - if (this._isUpdating) { - this._logService.warning( - "[PhishingDetectionService] Update already in progress, skipping...", - ); - return; - } - - try { - this._logService.info("[PhishingDetectionService] Starting phishing domains update..."); - this._isUpdating = true; - domains = await this._auditService.getKnownPhishingDomains(); - this._setKnownPhishingDomains(domains); - - await this._saveDomains(); - - this._resetRetry(); - this._isUpdating = false; - - this._logService.info("[PhishingDetectionService] Successfully fetched domains"); - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to fetch known phishing domains.", - error, - ); - - this._scheduleRetry(); - this._isUpdating = false; - - throw error; - } - } - - /** - * Saves the known phishing domains to storage - * Caches the updated domains and updates the last update time - */ - private static async _saveDomains() { - try { - // Cache the updated domains - await this._storageService.save(this._STORAGE_KEY, { - domains: Array.from(this._knownPhishingDomains), - timestamp: this._lastUpdateTime, - }); - this._logService.info( - `[PhishingDetectionService] Updated phishing domains cache with ${this._knownPhishingDomains.size} domains`, - ); - } catch (error) { - this._logService.error( - "[PhishingDetectionService] Failed to save known phishing domains.", - error, - ); - this._scheduleRetry(); - throw error; - } - } - - /** - * Resets the retry count and clears the retry subscription - */ - private static _resetRetry(): void { - this._logService.info( - `[PhishingDetectionService] Resetting retry count and clearing retry subscription.`, - ); - // Reset retry count and clear retry subscription on success - this._retryCount = 0; - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - this._retrySubscription = null; - } - } - - /** - * Adds phishing domains to the known phishing domains set - * Clears old domains to prevent memory leaks - * - * @param domains Array of phishing domains to add - */ - private static _setKnownPhishingDomains(domains: string[]): void { - this._logService.debug( - `[PhishingDetectionService] Tracking ${domains.length} phishing domains`, - ); - - // Clear old domains to prevent memory leaks - this._knownPhishingDomains.clear(); - - domains.forEach((domain: string) => { - if (domain) { - this._knownPhishingDomains.add(domain); - } - }); - this._lastUpdateTime = Date.now(); - } - /** * Cleans up the phishing detection service * Unsubscribes from all subscriptions and clears caches */ private static _cleanup() { - if (this._updateCacheSubscription) { - this._updateCacheSubscription.unsubscribe(); - this._updateCacheSubscription = null; - } - if (this._retrySubscription) { - this._retrySubscription.unsubscribe(); - this._retrySubscription = null; - } - if (this._navigationEvents) { - this._navigationEvents.unsubscribe(); - this._navigationEvents = null; - } - this._knownPhishingDomains.clear(); + this._destroy$.next(); + this._destroy$.complete(); + this._destroy$ = new Subject(); + this._caughtTabs.clear(); - this._lastUpdateTime = 0; - this._isUpdating = false; - this._isInitialized = false; - this._retryCount = 0; // Manually type cast to satisfy the listener signature due to the mixture // of static and instance methods in this class. To be fixed when refactoring diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index e218abd2d10..d44a3d2a2e7 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.10.1", + "version": "2025.11.0", "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 6f4fc905f44..b6381201c7d 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.10.1", + "version": "2025.11.0", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 02adaff9b83..1834beb391e 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -2,6 +2,7 @@ import { Injectable, NgModule } from "@angular/core"; import { ActivatedRouteSnapshot, RouteReuseStrategy, RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; +import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/environment-selector/environment-selector.component"; import { activeAuthGuard, @@ -45,6 +46,7 @@ import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/co import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; +import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant"; import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component"; @@ -148,7 +150,7 @@ const routes: Routes = [ component: ExtensionAnonLayoutWrapperComponent, children: [ { - path: "authentication-timeout", + path: AuthRoute.AuthenticationTimeout, canActivate: [unauthGuardFn(unauthRouteOverrides)], children: [ { @@ -167,7 +169,7 @@ const routes: Routes = [ ], }, { - path: "device-verification", + path: AuthRoute.NewDeviceVerification, component: ExtensionAnonLayoutWrapperComponent, canActivate: [unauthGuardFn(), activeAuthGuard()], children: [{ path: "", component: NewDeviceVerificationComponent }], @@ -259,13 +261,13 @@ const routes: Routes = [ data: { elevation: 1 } satisfies RouteDataProperties, }, { - path: "account-security", + path: AuthExtensionRoute.AccountSecurity, component: AccountSecurityComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, { - path: "device-management", + path: AuthExtensionRoute.DeviceManagement, component: ExtensionDeviceManagementComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, @@ -341,7 +343,7 @@ const routes: Routes = [ component: ExtensionAnonLayoutWrapperComponent, children: [ { - path: "signup", + path: AuthRoute.SignUp, canActivate: [unauthGuardFn()], data: { elevation: 1, @@ -361,13 +363,13 @@ const routes: Routes = [ component: RegistrationStartSecondaryComponent, outlet: "secondary", data: { - loginRoute: "/login", + loginRoute: `/${AuthRoute.Login}`, } satisfies RegistrationStartSecondaryComponentData, }, ], }, { - path: "finish-signup", + path: AuthRoute.FinishSignUp, canActivate: [unauthGuardFn()], data: { pageIcon: LockIcon, @@ -382,7 +384,7 @@ const routes: Routes = [ ], }, { - path: "set-initial-password", + path: AuthRoute.SetInitialPassword, canActivate: [authGuard], component: SetInitialPasswordComponent, data: { @@ -390,7 +392,7 @@ const routes: Routes = [ } satisfies RouteDataProperties, }, { - path: "login", + path: AuthRoute.Login, canActivate: [unauthGuardFn(unauthRouteOverrides), IntroCarouselGuard], data: { pageIcon: VaultIcon, @@ -411,7 +413,7 @@ const routes: Routes = [ ], }, { - path: "login-with-passkey", + path: AuthRoute.LoginWithPasskey, canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { pageIcon: TwoFactorAuthSecurityKeyIcon, @@ -434,7 +436,7 @@ const routes: Routes = [ ], }, { - path: "sso", + path: AuthRoute.Sso, canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { pageIcon: VaultIcon, @@ -456,7 +458,7 @@ const routes: Routes = [ ], }, { - path: "login-with-device", + path: AuthRoute.LoginWithDevice, canActivate: [redirectToVaultIfUnlockedGuard()], data: { pageIcon: DevicesIcon, @@ -479,7 +481,7 @@ const routes: Routes = [ ], }, { - path: "hint", + path: AuthRoute.PasswordHint, canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { pageTitle: { @@ -502,7 +504,7 @@ const routes: Routes = [ ], }, { - path: "admin-approval-requested", + path: AuthRoute.AdminApprovalRequested, canActivate: [redirectToVaultIfUnlockedGuard()], data: { pageIcon: DevicesIcon, @@ -519,7 +521,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginViaAuthRequestComponent }], }, { - path: "login-initiated", + path: AuthRoute.LoginInitiated, canActivate: [tdeDecryptionRequiredGuard()], data: { pageIcon: DevicesIcon, @@ -557,7 +559,7 @@ const routes: Routes = [ ], }, { - path: "2fa", + path: AuthRoute.TwoFactor, canActivate: [unauthGuardFn(unauthRouteOverrides), TwoFactorAuthGuard], children: [ { @@ -576,7 +578,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, }, { - path: "change-password", + path: AuthRoute.ChangePassword, data: { elevation: 1, hideFooter: true, @@ -698,7 +700,7 @@ const routes: Routes = [ canActivate: [authGuard, canAccessAtRiskPasswords, hasAtRiskPasswords], }, { - path: "account-switcher", + path: AuthExtensionRoute.AccountSwitcher, component: AccountSwitcherComponent, data: { elevation: 4, doNotSaveUrl: true } satisfies RouteDataProperties, }, diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index b85da665fa0..8f00569b720 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -67,6 +67,8 @@ import { initPopupClosedListener } from "../platform/services/popup-view-cache-b import { routerTransition } from "./app-routing.animations"; import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-dialog.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-root", styles: [], diff --git a/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts index 2ca24da6c75..510348927ce 100644 --- a/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts +++ b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts @@ -15,6 +15,8 @@ export type DesktopSyncVerificationDialogParams = { fingerprint: string[]; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "desktop-sync-verification-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], diff --git a/apps/browser/src/popup/tabs-v2.component.ts b/apps/browser/src/popup/tabs-v2.component.ts index f1e42799b35..1c409fee639 100644 --- a/apps/browser/src/popup/tabs-v2.component.ts +++ b/apps/browser/src/popup/tabs-v2.component.ts @@ -17,6 +17,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { NavButton } from "../platform/popup/layout/popup-tab-navigation.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-tabs-v2", templateUrl: "./tabs-v2.component.html", diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts index c58798d9d12..96c597113a5 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts @@ -1,4 +1,4 @@ -import { Component, Input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { mock } from "jest-mock-extended"; @@ -37,43 +37,32 @@ import { AtRiskCarouselDialogResult } from "../at-risk-carousel-dialog/at-risk-c import { AtRiskPasswordPageService } from "./at-risk-password-page.service"; import { AtRiskPasswordsComponent } from "./at-risk-passwords.component"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "popup-header", template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockPopupHeaderComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() pageTitle: string | undefined; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() backAction: (() => void) | undefined; + readonly pageTitle = input(undefined); + readonly backAction = input<(() => void) | undefined>(undefined); } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "popup-page", template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockPopupPageComponent { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() loading: boolean | undefined; + readonly loading = input(undefined); } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-vault-icon", template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, }) class MockAppIcon { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() cipher: CipherView | undefined; + readonly cipher = input(undefined); } describe("AtRiskPasswordsComponent", () => { @@ -109,11 +98,15 @@ describe("AtRiskPasswordsComponent", () => { id: "cipher", organizationId: "org", name: "Item 1", + edit: true, + viewPassword: true, } as CipherView, { id: "cipher2", organizationId: "org", name: "Item 2", + edit: true, + viewPassword: true, } as CipherView, ]); mockOrgs$ = new BehaviorSubject([ @@ -235,6 +228,38 @@ describe("AtRiskPasswordsComponent", () => { organizationId: "org", name: "Item 1", isDeleted: true, + edit: true, + viewPassword: true, + } as CipherView, + ]); + + const items = await firstValueFrom(component["atRiskItems$"]); + expect(items).toHaveLength(0); + }); + + it("should not show tasks when cipher does not have edit permission", async () => { + mockCiphers$.next([ + { + id: "cipher", + organizationId: "org", + name: "Item 1", + edit: false, + viewPassword: true, + } as CipherView, + ]); + + const items = await firstValueFrom(component["atRiskItems$"]); + expect(items).toHaveLength(0); + }); + + it("should not show tasks when cipher does not have viewPassword permission", async () => { + mockCiphers$.next([ + { + id: "cipher", + organizationId: "org", + name: "Item 1", + edit: true, + viewPassword: false, } as CipherView, ]); @@ -288,11 +313,15 @@ describe("AtRiskPasswordsComponent", () => { id: "cipher", organizationId: "org", name: "Item 1", + edit: true, + viewPassword: true, } as CipherView, { id: "cipher2", organizationId: "org2", name: "Item 2", + edit: true, + viewPassword: true, } as CipherView, ]); diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts index 3eeb2d1917b..94fdb00f566 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts @@ -1,5 +1,12 @@ import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core"; +import { + Component, + DestroyRef, + inject, + OnInit, + signal, + ChangeDetectionStrategy, +} from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; import { @@ -58,8 +65,6 @@ import { import { AtRiskPasswordPageService } from "./at-risk-password-page.service"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [ PopupPageComponent, @@ -82,6 +87,7 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service"; ], selector: "vault-at-risk-passwords", templateUrl: "./at-risk-passwords.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AtRiskPasswordsComponent implements OnInit { private taskService = inject(TaskService); @@ -158,6 +164,8 @@ export class AtRiskPasswordsComponent implements OnInit { t.type === SecurityTaskType.UpdateAtRiskCredential && t.cipherId != null && ciphers[t.cipherId] != null && + ciphers[t.cipherId].edit && + ciphers[t.cipherId].viewPassword && !ciphers[t.cipherId].isDeleted, ) .map((t) => ciphers[t.cipherId!]), diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts index a2045736ce2..459b328c44e 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts @@ -155,11 +155,12 @@ describe("OpenAttachmentsComponent", () => { }); it("routes the user to the premium page when they cannot access premium features", async () => { + const premiumUpgradeService = TestBed.inject(PremiumUpgradePromptService); hasPremiumFromAnySource$.next(false); await component.openAttachments(); - expect(router.navigate).toHaveBeenCalledWith(["/premium"]); + expect(premiumUpgradeService.promptForPremium).toHaveBeenCalled(); }); it("disables attachments when the edit form is disabled", () => { diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts index e2af3c44c7e..a267e7999ab 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.ts @@ -19,6 +19,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components"; import { CipherFormContainer } from "@bitwarden/vault"; @@ -67,6 +68,7 @@ export class OpenAttachmentsComponent implements OnInit { private filePopoutUtilsService: FilePopoutUtilsService, private accountService: AccountService, private cipherFormContainer: CipherFormContainer, + private premiumUpgradeService: PremiumUpgradePromptService, ) { this.accountService.activeAccount$ .pipe( @@ -115,7 +117,7 @@ export class OpenAttachmentsComponent implements OnInit { /** Routes the user to the attachments screen, if available */ async openAttachments() { if (!this.canAccessAttachments) { - await this.router.navigate(["/premium"]); + await this.premiumUpgradeService.promptForPremium(); return; } diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html new file mode 100644 index 00000000000..625c92e38c5 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html @@ -0,0 +1,70 @@ + + {{ "confirmAutofill" | i18n }} +
+

+ {{ "confirmAutofillDesc" | i18n }} +

+ @if (savedUrls.length === 1) { +

+ {{ "savedWebsite" | i18n }} +

+ +
+ {{ savedUrls[0] }} +
+
+ } + @if (savedUrls.length > 1) { +
+

+ {{ "savedWebsites" | i18n: savedUrls.length }} +

+ +
+
+
+ +
+ {{ url }} +
+
+
+
+ } +

+ {{ "currentWebsite" | i18n }} +

+ +
+ {{ currentUrl }} +
+
+
+ @if (!viewOnly) { + + } + + +
+
+
diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts new file mode 100644 index 00000000000..e8f00cd7b8d --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.spec.ts @@ -0,0 +1,244 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideNoopAnimations } from "@angular/platform-browser/animations"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DIALOG_DATA, DialogRef, DialogService } from "@bitwarden/components"; + +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, + AutofillConfirmationDialogParams, +} from "./autofill-confirmation-dialog.component"; + +describe("AutofillConfirmationDialogComponent", () => { + let fixture: ComponentFixture; + let component: AutofillConfirmationDialogComponent; + + const dialogRef = { + close: jest.fn(), + } as unknown as DialogRef; + + const params: AutofillConfirmationDialogParams = { + currentUrl: "https://example.com/path?q=1", + savedUrls: ["https://one.example.com/a", "https://two.example.com/b", "not-a-url.example"], + }; + + async function createFreshFixture(options?: { + params?: AutofillConfirmationDialogParams; + viewOnly?: boolean; + }) { + const p = options?.params ?? params; + + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [AutofillConfirmationDialogComponent], + providers: [ + provideNoopAnimations(), + { provide: DIALOG_DATA, useValue: p }, + { provide: DialogRef, useValue: dialogRef }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: DialogService, useValue: {} }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + const freshFixture = TestBed.createComponent(AutofillConfirmationDialogComponent); + const freshInstance = freshFixture.componentInstance; + + // If needed, set viewOnly BEFORE first detectChanges so initial render reflects it. + if (typeof options?.viewOnly !== "undefined") { + freshInstance.viewOnly = options.viewOnly; + } + + freshFixture.detectChanges(); + return { fixture: freshFixture, component: freshInstance }; + } + + beforeEach(async () => { + jest.spyOn(Utils, "getHostname").mockImplementation((value: string | null | undefined) => { + if (typeof value !== "string" || !value) { + return ""; + } + try { + // handle non-URL host strings gracefully + if (!value.includes("://")) { + return value; + } + return new URL(value).hostname; + } catch { + return ""; + } + }); + + await TestBed.configureTestingModule({ + imports: [AutofillConfirmationDialogComponent], + providers: [ + provideNoopAnimations(), + { provide: DIALOG_DATA, useValue: params }, + { provide: DialogRef, useValue: dialogRef }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: DialogService, useValue: {} }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(AutofillConfirmationDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("normalizes currentUrl and savedUrls via Utils.getHostname", () => { + expect(Utils.getHostname).toHaveBeenCalledTimes(1 + (params.savedUrls?.length ?? 0)); + // current + expect(component.currentUrl).toBe("example.com"); + // saved + expect(component.savedUrls).toEqual([ + "one.example.com", + "two.example.com", + "not-a-url.example", + ]); + }); + + it("renders normalized values into the template (shallow check)", () => { + const text = fixture.nativeElement.textContent as string; + expect(text).toContain("example.com"); + expect(text).toContain("one.example.com"); + expect(text).toContain("two.example.com"); + expect(text).toContain("not-a-url.example"); + }); + + it("emits Canceled on close()", () => { + const spy = jest.spyOn(dialogRef, "close"); + component["close"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.Canceled); + }); + + it("emits AutofillAndUrlAdded on autofillAndAddUrl()", () => { + const spy = jest.spyOn(dialogRef, "close"); + component["autofillAndAddUrl"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + }); + + it("emits AutofilledOnly on autofillOnly()", () => { + const spy = jest.spyOn(dialogRef, "close"); + component["autofillOnly"](); + expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofilledOnly); + }); + + it("applies collapsed list gradient class by default, then clears it after viewAllSavedUrls()", () => { + const initial = component["savedUrlsListClass"]; + expect(initial).toContain("gradient"); + + component["viewAllSavedUrls"](); + fixture.detectChanges(); + + const expanded = component["savedUrlsListClass"]; + expect(expanded).toBe(""); + }); + + it("handles empty savedUrls gracefully", async () => { + const newParams: AutofillConfirmationDialogParams = { + currentUrl: "https://bitwarden.com/help", + savedUrls: [], + }; + + const { component: fresh } = await createFreshFixture({ params: newParams }); + expect(fresh.savedUrls).toEqual([]); + expect(fresh.currentUrl).toBe("bitwarden.com"); + }); + + it("handles undefined savedUrls by defaulting to [] and empty strings from Utils.getHostname", () => { + const localParams: AutofillConfirmationDialogParams = { + currentUrl: "https://sub.domain.tld/x", + }; + + const local = new AutofillConfirmationDialogComponent(localParams as any, dialogRef); + + expect(local.savedUrls).toEqual([]); + expect(local.currentUrl).toBe("sub.domain.tld"); + }); + + it("filters out falsy/invalid values from Utils.getHostname in savedUrls", () => { + (Utils.getHostname as jest.Mock).mockImplementationOnce(() => "example.com"); + (Utils.getHostname as jest.Mock) + .mockImplementationOnce(() => "ok.example") + .mockImplementationOnce(() => "") + .mockImplementationOnce(() => undefined as unknown as string); + + const edgeParams: AutofillConfirmationDialogParams = { + currentUrl: "https://example.com", + savedUrls: ["https://ok.example", "://bad", "%%%"], + }; + + const edge = new AutofillConfirmationDialogComponent(edgeParams as any, dialogRef); + + expect(edge.currentUrl).toBe("example.com"); + expect(edge.savedUrls).toEqual(["ok.example"]); + }); + + it("renders one current-url callout and N saved-url callouts", () => { + const callouts = Array.from( + fixture.nativeElement.querySelectorAll("bit-callout"), + ) as HTMLElement[]; + expect(callouts.length).toBe(1 + params.savedUrls!.length); + }); + + it("renders normalized hostnames into the DOM text", () => { + const text = (fixture.nativeElement.textContent as string).replace(/\s+/g, " "); + expect(text).toContain("example.com"); + expect(text).toContain("one.example.com"); + expect(text).toContain("two.example.com"); + }); + + it("shows the 'view all' button when savedUrls > 1 and hides it after click", () => { + const findViewAll = () => + fixture.nativeElement.querySelector( + "button.tw-text-sm.tw-font-bold.tw-cursor-pointer", + ) as HTMLButtonElement | null; + + let btn = findViewAll(); + expect(btn).toBeTruthy(); + + btn!.click(); + fixture.detectChanges(); + + btn = findViewAll(); + expect(btn).toBeFalsy(); + expect(component.savedUrlsExpanded).toBe(true); + }); + + it("shows autofillWithoutAdding text on autofill button when viewOnly is false", () => { + fixture.detectChanges(); + + const text = fixture.nativeElement.textContent as string; + expect(text.includes("autofillWithoutAdding")).toBe(true); + }); + + it("does not show autofillWithoutAdding text on autofill button when viewOnly is true", async () => { + const { fixture: vf } = await createFreshFixture({ viewOnly: true }); + + const text = vf.nativeElement.textContent as string; + expect(text.includes("autofillWithoutAdding")).toBe(false); + }); + + it("shows autofill and save button when viewOnly is false", () => { + component.viewOnly = false; + fixture.detectChanges(); + + const text = fixture.nativeElement.textContent as string; + expect(text.includes("autofillAndAddWebsite")).toBe(true); + }); + + it("does not show autofill and save button when viewOnly is true", async () => { + const { fixture: vf } = await createFreshFixture({ viewOnly: true }); + + const text = vf.nativeElement.textContent as string; + expect(text.includes("autofillAndAddWebsite")).toBe(false); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts new file mode 100644 index 00000000000..fbecabf6b33 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.ts @@ -0,0 +1,104 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, Inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + ButtonModule, + DialogService, + DialogModule, + TypographyModule, + CalloutComponent, + LinkModule, +} from "@bitwarden/components"; + +export interface AutofillConfirmationDialogParams { + savedUrls?: string[]; + currentUrl: string; + viewOnly?: boolean; +} + +export const AutofillConfirmationDialogResult = Object.freeze({ + AutofillAndUrlAdded: "added", + AutofilledOnly: "autofilled", + Canceled: "canceled", +} as const); + +export type AutofillConfirmationDialogResultType = UnionOfValues< + typeof AutofillConfirmationDialogResult +>; + +@Component({ + templateUrl: "./autofill-confirmation-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ButtonModule, + CalloutComponent, + CommonModule, + DialogModule, + LinkModule, + TypographyModule, + JslibModule, + ], +}) +export class AutofillConfirmationDialogComponent { + AutofillConfirmationDialogResult = AutofillConfirmationDialogResult; + + currentUrl: string = ""; + savedUrls: string[] = []; + savedUrlsExpanded = false; + viewOnly: boolean = false; + + constructor( + @Inject(DIALOG_DATA) protected params: AutofillConfirmationDialogParams, + private dialogRef: DialogRef, + ) { + this.currentUrl = Utils.getHostname(params.currentUrl); + this.viewOnly = params.viewOnly ?? false; + this.savedUrls = + params.savedUrls?.map((url) => Utils.getHostname(url) ?? "").filter(Boolean) ?? []; + } + + protected get savedUrlsListClass(): string { + return this.savedUrlsExpanded + ? "" + : `tw-relative + tw-max-h-24 + tw-overflow-hidden + after:tw-pointer-events-none after:tw-content-[''] + after:tw-absolute after:tw-inset-x-0 after:tw-bottom-0 + after:tw-h-8 after:tw-bg-gradient-to-t + after:tw-from-background after:tw-to-transparent + `; + } + + protected viewAllSavedUrls() { + this.savedUrlsExpanded = true; + } + + protected close() { + this.dialogRef.close(AutofillConfirmationDialogResult.Canceled); + } + + protected autofillAndAddUrl() { + this.dialogRef.close(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + } + + protected autofillOnly() { + this.dialogRef.close(AutofillConfirmationDialogResult.AutofilledOnly); + } + + static open( + dialogService: DialogService, + config: DialogConfig, + ) { + return dialogService.open( + AutofillConfirmationDialogComponent, + { ...config }, + ); + } +} 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 3a48f7eb449..b05d19498ac 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 @@ -13,9 +13,17 @@ - + + @if (!(showAutofillConfirmation$ | async)) { + + }
diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts new file mode 100644 index 00000000000..5fcc4f78eb3 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -0,0 +1,397 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { Router } from "@angular/router"; +import { BehaviorSubject, of } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, +} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component"; + +import { ItemMoreOptionsComponent } from "./item-more-options.component"; + +describe("ItemMoreOptionsComponent", () => { + let fixture: ComponentFixture; + let component: ItemMoreOptionsComponent; + + const dialogService = { + openSimpleDialog: jest.fn().mockResolvedValue(true), + open: jest.fn(), + }; + const featureFlag$ = new BehaviorSubject(false); + const configService = { + getFeatureFlag$: jest.fn().mockImplementation(() => featureFlag$.asObservable()), + }; + const cipherService = { + getFullCipherView: jest.fn(), + encrypt: jest.fn(), + updateWithServer: jest.fn(), + softDeleteWithServer: jest.fn(), + }; + const autofillSvc = { + doAutofill: jest.fn(), + doAutofillAndSave: jest.fn(), + currentAutofillTab$: new BehaviorSubject<{ url?: string | null } | null>(null), + autofillAllowed$: new BehaviorSubject(true), + }; + + const passwordRepromptService = { + passwordRepromptCheck: jest.fn().mockResolvedValue(true), + }; + + const uriMatchStrategy$ = new BehaviorSubject(UriMatchStrategy.Domain); + + const domainSettingsService = { + resolvedDefaultUriMatchStrategy$: uriMatchStrategy$.asObservable(), + }; + + const hasSearchText$ = new BehaviorSubject(false); + const vaultPopupItemsService = { + hasSearchText$: hasSearchText$.asObservable(), + }; + + const baseCipher = { + id: "cipher-1", + login: { + uris: [ + { uri: "https://one.example.com" }, + { uri: "" }, + { uri: undefined as unknown as string }, + { uri: "https://two.example.com/a" }, + ], + username: "user", + }, + favorite: false, + reprompt: 0, + type: CipherType.Login, + viewPassword: true, + edit: true, + } as any; + + beforeEach(waitForAsync(async () => { + jest.clearAllMocks(); + + cipherService.getFullCipherView.mockImplementation(async (c) => ({ ...baseCipher, ...c })); + + TestBed.configureTestingModule({ + imports: [ItemMoreOptionsComponent, NoopAnimationsModule], + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: CipherService, useValue: cipherService }, + { provide: VaultPopupAutofillService, useValue: autofillSvc }, + + { provide: I18nService, useValue: { t: (k: string) => k } }, + { provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } }, + { provide: OrganizationService, useValue: { hasOrganizations: () => of(false) } }, + { + provide: CipherAuthorizationService, + useValue: { canDeleteCipher$: () => of(true), canCloneCipher$: () => of(true) }, + }, + { provide: CollectionService, useValue: { decryptedCollections$: () => of([]) } }, + { provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } }, + { provide: CipherArchiveService, useValue: { userCanArchive$: () => of(true) } }, + { provide: ToastService, useValue: { showToast: () => {} } }, + { provide: Router, useValue: { navigate: () => Promise.resolve(true) } }, + { provide: PasswordRepromptService, useValue: passwordRepromptService }, + { + provide: DomainSettingsService, + useValue: domainSettingsService, + }, + { + provide: VaultPopupItemsService, + useValue: vaultPopupItemsService, + }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + TestBed.overrideProvider(DialogService, { useValue: dialogService }); + await TestBed.compileComponents(); + fixture = TestBed.createComponent(ItemMoreOptionsComponent); + component = fixture.componentInstance; + component.cipher = baseCipher; + })); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function mockConfirmDialogResult(result: string) { + const openSpy = jest + .spyOn(AutofillConfirmationDialogComponent, "open") + .mockReturnValue({ closed: of(result) } as any); + return openSpy; + } + + describe("doAutofill", () => { + it("calls the autofill service to autofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(cipherService.getFullCipherView).toHaveBeenCalled(); + expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1); + expect(autofillSvc.doAutofill).toHaveBeenCalledWith( + expect.objectContaining({ id: "cipher-1" }), + true, + true, + ); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("calls the passwordService to passwordRepromptCheck", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); + }); + + it("does nothing if the user fails master password reprompt", async () => { + baseCipher.reprompt = 2; // Master Password reprompt enabled + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false); // Reprompt fails + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => { + // autofill confirmation dialog is not shown when either the feature flag is disabled or search text is not present + uriMatchStrategy$.next(UriMatchStrategy.Exact); + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + describe("autofill confirmation dialog", () => { + beforeEach(() => { + // autofill confirmation dialog is shown when feature flag is enabled and search text is present + featureFlag$.next(true); + hasSearchText$.next(true); + uriMatchStrategy$.next(UriMatchStrategy.Domain); + passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true); + }); + + it("opens the autofill confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); + const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + + await component.doAutofill(); + + expect(openSpy).toHaveBeenCalledTimes(1); + const args = openSpy.mock.calls[0][1]; + expect(args.data.currentUrl).toBe("https://page.example.com/path"); + expect(args.data.savedUrls).toEqual([ + "https://one.example.com", + "https://two.example.com/a", + ]); + }); + + it("does nothing when the user cancels the autofill confirmation dialog", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("calls the autofill service to autofill when the user selects 'AutofilledOnly'", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).toHaveBeenCalledWith( + expect.objectContaining({ id: "cipher-1" }), + true, + true, + ); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + + it("calls the autofill service to doAutofillAndSave when the user selects 'AutofillAndUrlAdded'", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofillAndUrlAdded); + + await component.doAutofill(); + + expect(autofillSvc.doAutofillAndSave).toHaveBeenCalledWith( + expect.objectContaining({ id: "cipher-1" }), + false, + true, + ); + expect(autofillSvc.doAutofillAndSave.mock.calls[0][1]).toBe(false); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + }); + + describe("URI match strategy handling", () => { + describe("when the default URI match strategy is Exact", () => { + beforeEach(() => { + uriMatchStrategy$.next(UriMatchStrategy.Exact); + }); + + it("shows the exact match dialog and not the password dialog", async () => { + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1); + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(passwordRepromptService.passwordRepromptCheck).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + }); + + describe("when the default URI match strategy is not Exact", () => { + beforeEach(() => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + uriMatchStrategy$.next(UriMatchStrategy.Domain); + }); + it("does not show the exact match dialog", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://page.example.com", match: UriMatchStrategy.Domain }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("shows the exact match dialog when the cipher has a single uri with a match strategy of Exact", async () => { + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [{ uri: "https://one.example.com", match: UriMatchStrategy.Exact }], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.objectContaining({ key: "cannotAutofill" }), + content: expect.objectContaining({ key: "cannotAutofillExactMatch" }), + type: "info", + }), + ); + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + }); + + it("does not show the exact match dialog when the cipher has no uris", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + + it("does not show the exact match dialog when the cipher has a uri with a match strategy of Exact and a uri with a match strategy of Domain", async () => { + mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); + cipherService.getFullCipherView.mockImplementation(async (c) => ({ + ...baseCipher, + ...c, + login: { + ...baseCipher.login, + uris: [ + { uri: "https://one.example.com", match: UriMatchStrategy.Exact }, + { uri: "https://page.example.com", match: UriMatchStrategy.Domain }, + ], + }, + })); + + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + + await component.doAutofill(); + + expect(dialogService.openSimpleDialog).not.toHaveBeenCalled(); + }); + }); + + it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const fillAndSaveButton = fixture.nativeElement.querySelector( + "button[bitMenuItem]:not([disabled])", + ); + + const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? ""; + expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false); + }); + + it("does nothing if the user fails master password reprompt", async () => { + baseCipher.reprompt = 2; // Master Password reprompt enabled + autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); + passwordRepromptService.passwordRepromptCheck.mockResolvedValue(false); // Reprompt fails + mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly); + + await component.doAutofill(); + + expect(autofillSvc.doAutofill).not.toHaveBeenCalled(); + expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled(); + }); + }); + }); +}); 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 94016d2670f..7bbef3f79a7 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 @@ -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 { booleanAttribute, Component, Input } from "@angular/core"; import { Router, RouterModule } from "@angular/router"; @@ -11,8 +9,12 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; @@ -32,7 +34,12 @@ import { import { PasswordRepromptService } from "@bitwarden/vault"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; +import { + AutofillConfirmationDialogComponent, + AutofillConfirmationDialogResult, +} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -42,7 +49,7 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], }) export class ItemMoreOptionsComponent { - private _cipher$ = new BehaviorSubject(undefined); + private _cipher$ = new BehaviorSubject({} as CipherViewLike); // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @@ -64,7 +71,7 @@ export class ItemMoreOptionsComponent { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: booleanAttribute }) - showViewOption: boolean; + showViewOption = false; /** * Flag to hide the autofill menu options. Used for items that are @@ -73,10 +80,17 @@ export class ItemMoreOptionsComponent { // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ transform: booleanAttribute }) - hideAutofillOptions: boolean; + hideAutofillOptions = false; protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; + protected showAutofillConfirmation$ = combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.AutofillConfirmation), + this.vaultPopupItemsService.hasSearchText$, + ]).pipe(map(([isFeatureFlagEnabled, hasSearchText]) => isFeatureFlagEnabled && hasSearchText)); + + protected uriMatchStrategy$ = this.domainSettingsService.resolvedDefaultUriMatchStrategy$; + /** * Observable that emits a boolean value indicating if the user is authorized to clone the cipher. * @protected @@ -146,6 +160,9 @@ export class ItemMoreOptionsComponent { private collectionService: CollectionService, private restrictedItemTypesService: RestrictedItemTypesService, private cipherArchiveService: CipherArchiveService, + private configService: ConfigService, + private vaultPopupItemsService: VaultPopupItemsService, + private domainSettingsService: DomainSettingsService, ) {} get canEdit() { @@ -177,14 +194,75 @@ export class ItemMoreOptionsComponent { return this.cipher.favorite ? "unfavorite" : "favorite"; } - async doAutofill() { - const cipher = await this.cipherService.getFullCipherView(this.cipher); - await this.vaultPopupAutofillService.doAutofill(cipher); - } - async doAutofillAndSave() { const cipher = await this.cipherService.getFullCipherView(this.cipher); - await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false); + await this.vaultPopupAutofillService.doAutofillAndSave(cipher); + } + + async doAutofill() { + const cipher = await this.cipherService.getFullCipherView(this.cipher); + + const uris = cipher.login?.uris ?? []; + const cipherHasAllExactMatchLoginUris = + uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact); + + const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$); + const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); + + if ( + showAutofillConfirmation && + (cipherHasAllExactMatchLoginUris || uriMatchStrategy === UriMatchStrategy.Exact) + ) { + await this.dialogService.openSimpleDialog({ + title: { key: "cannotAutofill" }, + content: { key: "cannotAutofillExactMatch" }, + type: "info", + acceptButtonText: { key: "okay" }, + cancelButtonText: null, + }); + return; + } + + if (!(await this.passwordRepromptService.passwordRepromptCheck(this.cipher))) { + return; + } + + if (!showAutofillConfirmation) { + await this.vaultPopupAutofillService.doAutofill(cipher, true, true); + return; + } + + const currentTab = await firstValueFrom(this.vaultPopupAutofillService.currentAutofillTab$); + + if (!currentTab?.url) { + await this.dialogService.openSimpleDialog({ + title: { key: "error" }, + content: { key: "errorGettingAutoFillData" }, + type: "danger", + }); + return; + } + + const ref = AutofillConfirmationDialogComponent.open(this.dialogService, { + data: { + currentUrl: currentTab?.url || "", + savedUrls: cipher.login?.uris?.filter((u) => u.uri).map((u) => u.uri!) ?? [], + viewOnly: !this.cipher.edit, + }, + }); + + const result = await firstValueFrom(ref.closed); + + switch (result) { + case AutofillConfirmationDialogResult.Canceled: + return; + case AutofillConfirmationDialogResult.AutofilledOnly: + await this.vaultPopupAutofillService.doAutofill(cipher, true, true); + return; + case AutofillConfirmationDialogResult.AutofillAndUrlAdded: + await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false, true); + return; + } } async onView() { @@ -204,15 +282,14 @@ export class ItemMoreOptionsComponent { const cipher = await this.cipherService.getFullCipherView(this.cipher); cipher.favorite = !cipher.favorite; - const activeUserId = await firstValueFrom( + const activeUserId = (await firstValueFrom( this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); + )) as UserId; const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId); await this.cipherService.updateWithServer(encryptedCipher); this.toastService.showToast({ variant: "success", - title: null, message: this.i18nService.t( this.cipher.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites", ), diff --git a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts index 9a00bacd6b0..bf63cf1f668 100644 --- a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.spec.ts @@ -2,25 +2,69 @@ import { TestBed } from "@angular/core/testing"; import { Router } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { DialogService } from "@bitwarden/components"; + import { BrowserPremiumUpgradePromptService } from "./browser-premium-upgrade-prompt.service"; describe("BrowserPremiumUpgradePromptService", () => { let service: BrowserPremiumUpgradePromptService; let router: MockProxy; + let configService: MockProxy; + let dialogService: MockProxy; beforeEach(async () => { router = mock(); + configService = mock(); + dialogService = mock(); + await TestBed.configureTestingModule({ - providers: [BrowserPremiumUpgradePromptService, { provide: Router, useValue: router }], + providers: [ + BrowserPremiumUpgradePromptService, + { provide: Router, useValue: router }, + { provide: ConfigService, useValue: configService }, + { provide: DialogService, useValue: dialogService }, + ], }).compileComponents(); service = TestBed.inject(BrowserPremiumUpgradePromptService); }); describe("promptForPremium", () => { - it("navigates to the premium update screen", async () => { + let openSpy: jest.SpyInstance; + + beforeEach(() => { + openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation(); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + it("opens the new premium upgrade dialog when feature flag is enabled", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + expect(openSpy).toHaveBeenCalledWith(dialogService); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it("navigates to the premium update screen when feature flag is disabled", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); expect(router.navigate).toHaveBeenCalledWith(["/premium"]); + expect(openSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts index 2909e3b3bd6..53f7ffd5f5a 100644 --- a/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts +++ b/apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts @@ -1,18 +1,32 @@ import { inject } from "@angular/core"; import { Router } from "@angular/router"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { DialogService } from "@bitwarden/components"; /** * This class handles the premium upgrade process for the browser extension. */ export class BrowserPremiumUpgradePromptService implements PremiumUpgradePromptService { private router = inject(Router); + private configService = inject(ConfigService); + private dialogService = inject(DialogService); async promptForPremium() { - /** - * Navigate to the premium update screen. - */ - await this.router.navigate(["/premium"]); + const showNewDialog = await this.configService.getFeatureFlag( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + + if (showNewDialog) { + PremiumUpgradeDialogComponent.open(this.dialogService); + } else { + /** + * Navigate to the premium update screen. + */ + await this.router.navigate(["/premium"]); + } } } 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 718043b4e85..5818c6e32ff 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 @@ -262,6 +262,18 @@ describe("VaultPopupAutofillService", () => { ); }); + it("skips password prompt when skipPasswordReprompt is true", async () => { + mockCipher.id = "cipher-with-reprompt"; + mockCipher.reprompt = CipherRepromptType.Password; + mockAutofillService.doAutoFill.mockResolvedValue(null); + + const result = await service.doAutofill(mockCipher, true, true); + + expect(result).toBe(true); + expect(mockPasswordRepromptService.showPasswordPrompt).not.toHaveBeenCalled(); + expect(mockAutofillService.doAutoFill).toHaveBeenCalled(); + }); + describe("closePopup", () => { beforeEach(() => { jest.spyOn(BrowserApi, "closePopup").mockImplementation(); 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 3d5b35cded6..6feeec29efc 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 @@ -231,8 +231,10 @@ export class VaultPopupAutofillService { cipher: CipherView, tab: chrome.tabs.Tab, pageDetails: PageDetail[], + skipPasswordReprompt = false, ): Promise { if ( + !skipPasswordReprompt && cipher.reprompt !== CipherRepromptType.None && !(await this.passwordRepromptService.showPasswordPrompt()) ) { @@ -314,12 +316,22 @@ export class VaultPopupAutofillService { * Will copy any TOTP code to the clipboard if available after successful autofill. * @param cipher * @param closePopup If true, will close the popup window after successful autofill. Defaults to true. + * @param skipPasswordReprompt If true, skips the master password reprompt even if the cipher requires it. Defaults to false. */ - async doAutofill(cipher: CipherView, closePopup = true): Promise { + async doAutofill( + cipher: CipherView, + closePopup = true, + skipPasswordReprompt = false, + ): Promise { const tab = await firstValueFrom(this.currentAutofillTab$); const pageDetails = await firstValueFrom(this._currentPageDetails$); - const didAutofill = await this._internalDoAutofill(cipher, tab, pageDetails); + const didAutofill = await this._internalDoAutofill( + cipher, + tab, + pageDetails, + skipPasswordReprompt, + ); if (didAutofill && closePopup) { await this._closePopup(cipher, tab); @@ -350,7 +362,11 @@ export class VaultPopupAutofillService { * @param closePopup If true, will close the popup window after successful autofill. * If false, will show a success toast instead. Defaults to true. */ - async doAutofillAndSave(cipher: CipherView, closePopup = true): Promise { + async doAutofillAndSave( + cipher: CipherView, + closePopup = true, + skipPasswordReprompt = false, + ): Promise { // We can only save URIs for login ciphers if (cipher.type !== CipherType.Login) { return false; @@ -359,7 +375,12 @@ export class VaultPopupAutofillService { const pageDetails = await firstValueFrom(this._currentPageDetails$); const tab = await firstValueFrom(this.currentAutofillTab$); - const didAutofill = await this._internalDoAutofill(cipher, tab, pageDetails); + const didAutofill = await this._internalDoAutofill( + cipher, + tab, + pageDetails, + skipPasswordReprompt, + ); if (!didAutofill) { return false; 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 a1820a975f1..afe9d61d5af 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 @@ -261,6 +261,13 @@ export class VaultPopupItemsService { this.remainingCiphers$.pipe(map(() => false)), ).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 })); + /** Observable that indicates whether there is search text present. + */ + hasSearchText$: Observable = this._hasSearchText.pipe( + distinctUntilChanged(), + shareReplay({ bufferSize: 1, refCount: true }), + ); + /** * Observable that indicates whether a filter or search text is currently applied to the ciphers. */ diff --git a/apps/browser/store/locales/sk/copy.resx b/apps/browser/store/locales/sk/copy.resx index 2b7e903fe52..816f1bf7e8c 100644 --- a/apps/browser/store/locales/sk/copy.resx +++ b/apps/browser/store/locales/sk/copy.resx @@ -118,7 +118,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - Bitwarden – Bezplatný správca hesiel + Bitwarden – správca hesiel Bitwarden zabezpečí všetky vaše heslá, prístupové kľúče a citlivé informácie doma, v práci alebo na cestách. diff --git a/apps/browser/tailwind.config.js b/apps/browser/tailwind.config.js index 1ad56562bb3..134001bbf13 100644 --- a/apps/browser/tailwind.config.js +++ b/apps/browser/tailwind.config.js @@ -10,6 +10,7 @@ config.content = [ "../../libs/vault/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}", "../../libs/vault/src/**/*.{html,ts}", + "../../libs/pricing/src/**/*.{html,ts}", ]; module.exports = config; diff --git a/apps/cli/package.json b/apps/cli/package.json index 02627f80a27..26e1183004a 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.10.1", + "version": "2025.11.0", "keywords": [ "bitwarden", "password", diff --git a/apps/cli/src/commands/list.command.ts b/apps/cli/src/commands/list.command.ts index e5174f67913..ff210cf222d 100644 --- a/apps/cli/src/commands/list.command.ts +++ b/apps/cli/src/commands/list.command.ts @@ -102,7 +102,7 @@ export class ListCommand { if (options.folderId === "notnull" && c.folderId != null) { return true; } - const folderId = options.folderId === "null" ? null : options.folderId; + const folderId = options.folderId === "null" ? undefined : options.folderId; if (folderId === c.folderId) { return true; } @@ -112,7 +112,8 @@ export class ListCommand { if (options.organizationId === "notnull" && c.organizationId != null) { return true; } - const organizationId = options.organizationId === "null" ? null : options.organizationId; + const organizationId = + options.organizationId === "null" ? undefined : options.organizationId; if (organizationId === c.organizationId) { return true; } diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index a0cd1b3dcbf..9a69ca62a1f 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -440,6 +440,23 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "bitwarden_chromium_import_helper" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64", + "chromium_importer", + "clap", + "embed-resource", + "scopeguard", + "sysinfo", + "tokio", + "tracing", + "tracing-subscriber", + "windows 0.61.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -589,8 +606,9 @@ dependencies = [ "async-trait", "base64", "cbc", + "chacha20poly1305", + "dirs", "hex", - "homedir", "oo7", "pbkdf2", "rand 0.9.1", @@ -600,7 +618,9 @@ dependencies = [ "serde_json", "sha1", "tokio", - "winapi", + "tracing", + "tracing-subscriber", + "verifysign", "windows 0.61.1", ] @@ -1040,7 +1060,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1157,6 +1177,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embed-resource" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.5", + "vswhom", + "winreg", +] + [[package]] name = "embed_plist" version = "1.2.2" @@ -3103,6 +3137,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + [[package]] name = "serial_test" version = "3.2.0" @@ -3504,12 +3547,36 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + [[package]] name = "toml_edit" version = "0.22.26" @@ -3517,10 +3584,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.6.9", "winnow", ] +[[package]] +name = "toml_parser" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + [[package]] name = "tracing" version = "0.1.41" @@ -3682,7 +3764,7 @@ dependencies = [ "paste", "serde", "textwrap", - "toml", + "toml 0.5.11", "uniffi_meta", "uniffi_udl", ] @@ -3736,7 +3818,7 @@ dependencies = [ "quote", "serde", "syn", - "toml", + "toml 0.5.11", "uniffi_meta", ] @@ -3824,12 +3906,44 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "verifysign" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ebfe12e38930c3b851aea35e93fab1a6c29279cad7e8e273f29a21678fee8c0" +dependencies = [ + "core-foundation", + "sha1", + "sha2", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -4186,6 +4300,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -4447,6 +4570,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 6a366316328..4b5c1335c6b 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "autotype", + "bitwarden_chromium_import_helper", "chromium_importer", "core", "macos_provider", @@ -68,7 +69,11 @@ tokio = "=1.45.0" tokio-stream = "=0.1.15" tokio-util = "=0.7.13" tracing = "=0.1.41" -tracing-subscriber = { version = "=0.3.20", features = ["fmt", "env-filter", "tracing-log"] } +tracing-subscriber = { version = "=0.3.20", features = [ + "fmt", + "env-filter", + "tracing-log", +] } typenum = "=1.18.0" uniffi = "=0.28.3" widestring = "=1.2.0" @@ -81,10 +86,13 @@ zbus_polkit = "=5.0.0" zeroizing-alloc = "=0.1.0" [workspace.lints.clippy] +disallowed-macros = "deny" + # Dis-allow println and eprintln, which are typically used in debugging. # Use `tracing` and `tracing-subscriber` crates for observability needs. print_stderr = "deny" print_stdout = "deny" + string_slice = "warn" unused_async = "deny" unwrap_used = "deny" diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml new file mode 100644 index 00000000000..dc5358b0c73 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "bitwarden_chromium_import_helper" +version.workspace = true +license.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] + +[target.'cfg(target_os = "windows")'.dependencies] +chromium_importer = { path = "../chromium_importer" } +clap = { version = "=4.5.40", features = ["derive"] } +scopeguard = { workspace = true } +sysinfo = { workspace = true } +windows = { workspace = true, features = [ + "Wdk_System_SystemServices", + "Win32_Security_Cryptography", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_ProcessStatus", + "Win32_System_Services", + "Win32_System_Threading", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } +anyhow = { workspace = true } +base64 = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[build-dependencies] +embed-resource = "=3.0.6" + +[lints] +workspace = true diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/build.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/build.rs new file mode 100644 index 00000000000..326929ec7c8 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/build.rs @@ -0,0 +1,9 @@ +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").expect("to be set by cargo") == "windows" { + println!("cargo:rerun-if-changed=resources.rc"); + + embed_resource::compile("resources.rc", embed_resource::NONE) + .manifest_optional() + .expect("to compile resources"); + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/resources.rc b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/resources.rc new file mode 100644 index 00000000000..c300cc5d77f --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/resources.rc @@ -0,0 +1 @@ +1 ICON "../../resources/icon.ico" diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/main.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/main.rs new file mode 100644 index 00000000000..036e04de16b --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/main.rs @@ -0,0 +1,13 @@ +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "windows")] +#[tokio::main] +async fn main() { + windows::main().await; +} + +#[cfg(not(target_os = "windows"))] +fn main() { + // Empty +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows.rs new file mode 100644 index 00000000000..9abc8c95a1f --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows.rs @@ -0,0 +1,482 @@ +mod windows_binary { + use anyhow::{anyhow, Result}; + use base64::{engine::general_purpose, Engine as _}; + use clap::Parser; + use scopeguard::defer; + use std::{ + ffi::OsString, + os::windows::{ffi::OsStringExt as _, io::AsRawHandle}, + path::{Path, PathBuf}, + ptr, + time::Duration, + }; + use sysinfo::System; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::windows::named_pipe::{ClientOptions, NamedPipeClient}, + time, + }; + use tracing::{debug, error, level_filters::LevelFilter}; + use tracing_subscriber::{ + fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _, + }; + use windows::{ + core::BOOL, + Wdk::System::SystemServices::SE_DEBUG_PRIVILEGE, + Win32::{ + Foundation::{ + CloseHandle, LocalFree, ERROR_PIPE_BUSY, HANDLE, HLOCAL, NTSTATUS, STATUS_SUCCESS, + }, + Security::{ + self, + Cryptography::{CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB}, + DuplicateToken, ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE, + TOKEN_QUERY, + }, + System::{ + Pipes::GetNamedPipeServerProcessId, + Threading::{ + OpenProcess, OpenProcessToken, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, + PROCESS_QUERY_LIMITED_INFORMATION, + }, + }, + UI::Shell::IsUserAnAdmin, + }, + }; + + use chromium_importer::chromium::{verify_signature, ADMIN_TO_USER_PIPE_NAME}; + + #[derive(Parser)] + #[command(name = "bitwarden_chromium_import_helper")] + #[command(about = "Admin tool for ABE service management")] + struct Args { + /// Base64 encoded encrypted data to process + #[arg(long, help = "Base64 encoded encrypted data string")] + encrypted: String, + } + + // Enable this to log to a file. The way this executable is used, it's not easy to debug and the stdout gets lost. + // This is intended for development time only. All the logging is wrapped in `dbg_log!`` macro that compiles to + // no-op when logging is disabled. This is needed to avoid any sensitive data being logged in production. Normally + // all the logging code is present in the release build and could be enabled via RUST_LOG environment variable. + // We don't want that! + const ENABLE_DEVELOPER_LOGGING: bool = false; + const LOG_FILENAME: &str = "c:\\path\\to\\log.txt"; // This is an example filename, replace it with you own + + // This should be enabled for production + const ENABLE_SERVER_SIGNATURE_VALIDATION: bool = true; + + // List of SYSTEM process names to try to impersonate + const SYSTEM_PROCESS_NAMES: [&str; 2] = ["services.exe", "winlogon.exe"]; + + // Macro wrapper around debug! that compiles to no-op when ENABLE_DEVELOPER_LOGGING is false + macro_rules! dbg_log { + ($($arg:tt)*) => { + if ENABLE_DEVELOPER_LOGGING { + debug!($($arg)*); + } + }; + } + + async fn open_pipe_client(pipe_name: &'static str) -> Result { + let max_attempts = 5; + for _ in 0..max_attempts { + match ClientOptions::new().open(pipe_name) { + Ok(client) => { + dbg_log!("Successfully connected to the pipe!"); + return Ok(client); + } + Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => { + dbg_log!("Pipe is busy, retrying in 50ms..."); + } + Err(e) => { + dbg_log!("Failed to connect to pipe: {}", &e); + return Err(e.into()); + } + } + + time::sleep(Duration::from_millis(50)).await; + } + + Err(anyhow!( + "Failed to connect to pipe after {} attempts", + max_attempts + )) + } + + async fn send_message_with_client( + client: &mut NamedPipeClient, + message: &str, + ) -> Result { + client.write_all(message.as_bytes()).await?; + + // Try to receive a response for this message + let mut buffer = vec![0u8; 64 * 1024]; + match client.read(&mut buffer).await { + Ok(0) => Err(anyhow!( + "Server closed the connection (0 bytes read) on message" + )), + Ok(bytes_received) => { + let response = String::from_utf8_lossy(&buffer[..bytes_received]); + Ok(response.to_string()) + } + Err(e) => Err(anyhow!("Failed to receive response for message: {}", e)), + } + } + + fn get_named_pipe_server_pid(client: &NamedPipeClient) -> Result { + let handle = HANDLE(client.as_raw_handle() as _); + let mut pid: u32 = 0; + unsafe { GetNamedPipeServerProcessId(handle, &mut pid) }?; + Ok(pid) + } + + fn resolve_process_executable_path(pid: u32) -> Result { + dbg_log!("Resolving process executable path for PID {}", pid); + + // Open the process handle + let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?; + dbg_log!("Opened process handle for PID {}", pid); + + // Close when no longer needed + defer! { + dbg_log!("Closing process handle for PID {}", pid); + unsafe { + _ = CloseHandle(hprocess); + } + }; + + let mut exe_name = vec![0u16; 32 * 1024]; + let mut exe_name_length = exe_name.len() as u32; + unsafe { + QueryFullProcessImageNameW( + hprocess, + PROCESS_NAME_WIN32, + windows::core::PWSTR(exe_name.as_mut_ptr()), + &mut exe_name_length, + ) + }?; + dbg_log!( + "QueryFullProcessImageNameW returned {} bytes", + exe_name_length + ); + + exe_name.truncate(exe_name_length as usize); + Ok(PathBuf::from(OsString::from_wide(&exe_name))) + } + + async fn send_error_to_user(client: &mut NamedPipeClient, error_message: &str) { + _ = send_to_user(client, &format!("!{}", error_message)).await + } + + async fn send_to_user(client: &mut NamedPipeClient, message: &str) -> Result<()> { + let _ = send_message_with_client(client, message).await?; + Ok(()) + } + + fn is_admin() -> bool { + unsafe { IsUserAnAdmin().as_bool() } + } + + fn decrypt_data_base64(data_base64: &str, expect_appb: bool) -> Result { + dbg_log!("Decrypting data base64: {}", data_base64); + + let data = general_purpose::STANDARD.decode(data_base64).map_err(|e| { + dbg_log!("Failed to decode base64: {} APPB: {}", e, expect_appb); + e + })?; + + let decrypted = decrypt_data(&data, expect_appb)?; + let decrypted_base64 = general_purpose::STANDARD.encode(decrypted); + + Ok(decrypted_base64) + } + + fn decrypt_data(data: &[u8], expect_appb: bool) -> Result> { + if expect_appb && !data.starts_with(b"APPB") { + dbg_log!("Decoded data does not start with 'APPB'"); + return Err(anyhow!("Decoded data does not start with 'APPB'")); + } + + let data = if expect_appb { &data[4..] } else { data }; + + let in_blob = CRYPT_INTEGER_BLOB { + cbData: data.len() as u32, + pbData: data.as_ptr() as *mut u8, + }; + + let mut out_blob = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: ptr::null_mut(), + }; + + let result = unsafe { + CryptUnprotectData( + &in_blob, + None, + None, + None, + None, + CRYPTPROTECT_UI_FORBIDDEN, + &mut out_blob, + ) + }; + + if result.is_ok() && !out_blob.pbData.is_null() && out_blob.cbData > 0 { + let decrypted = unsafe { + std::slice::from_raw_parts(out_blob.pbData, out_blob.cbData as usize).to_vec() + }; + + // Free the memory allocated by CryptUnprotectData + unsafe { LocalFree(Some(HLOCAL(out_blob.pbData as *mut _))) }; + + Ok(decrypted) + } else { + dbg_log!("CryptUnprotectData failed"); + Err(anyhow!("CryptUnprotectData failed")) + } + } + + // + // Impersonate a SYSTEM process + // + + fn start_impersonating() -> Result { + // Need to enable SE_DEBUG_PRIVILEGE to enumerate and open SYSTEM processes + enable_debug_privilege()?; + + // Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, so try several. + let (token, pid, name) = find_system_process_with_token(get_system_pid_list())?; + + // Impersonate the SYSTEM process + unsafe { + ImpersonateLoggedOnUser(token)?; + }; + dbg_log!("Impersonating system process '{}' (PID: {})", name, pid); + + Ok(token) + } + + fn stop_impersonating(token: HANDLE) -> Result<()> { + unsafe { + RevertToSelf()?; + CloseHandle(token)?; + }; + Ok(()) + } + + fn find_system_process_with_token( + pids: Vec<(u32, &'static str)>, + ) -> Result<(HANDLE, u32, &'static str)> { + for (pid, name) in pids { + match get_system_token_from_pid(pid) { + Err(_) => { + dbg_log!( + "Failed to open process handle '{}' (PID: {}), skipping", + name, + pid + ); + continue; + } + Ok(system_handle) => { + return Ok((system_handle, pid, name)); + } + } + } + Err(anyhow!("Failed to get system token from any process")) + } + + fn get_system_token_from_pid(pid: u32) -> Result { + let handle = get_process_handle(pid)?; + let token = get_system_token(handle)?; + unsafe { + CloseHandle(handle)?; + }; + Ok(token) + } + + fn get_system_token(handle: HANDLE) -> Result { + let token_handle = unsafe { + let mut token_handle = HANDLE::default(); + OpenProcessToken(handle, TOKEN_DUPLICATE | TOKEN_QUERY, &mut token_handle)?; + token_handle + }; + + let duplicate_token = unsafe { + let mut duplicate_token = HANDLE::default(); + DuplicateToken( + token_handle, + Security::SECURITY_IMPERSONATION_LEVEL(2), + &mut duplicate_token, + )?; + CloseHandle(token_handle)?; + duplicate_token + }; + + Ok(duplicate_token) + } + + fn get_system_pid_list() -> Vec<(u32, &'static str)> { + let sys = System::new_all(); + SYSTEM_PROCESS_NAMES + .iter() + .flat_map(|&name| { + sys.processes_by_exact_name(name.as_ref()) + .map(move |process| (process.pid().as_u32(), name)) + }) + .collect() + } + + fn get_process_handle(pid: u32) -> Result { + let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?; + Ok(hprocess) + } + + #[link(name = "ntdll")] + unsafe extern "system" { + unsafe fn RtlAdjustPrivilege( + privilege: i32, + enable: BOOL, + current_thread: BOOL, + previous_value: *mut BOOL, + ) -> NTSTATUS; + } + + fn enable_debug_privilege() -> Result<()> { + let mut previous_value = BOOL(0); + let status = unsafe { + dbg_log!("Setting SE_DEBUG_PRIVILEGE to 1 via RtlAdjustPrivilege"); + RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, BOOL(1), BOOL(0), &mut previous_value) + }; + + match status { + STATUS_SUCCESS => { + dbg_log!( + "SE_DEBUG_PRIVILEGE set to 1, was {} before", + previous_value.as_bool() + ); + Ok(()) + } + _ => { + dbg_log!("RtlAdjustPrivilege failed with status: 0x{:X}", status.0); + Err(anyhow!("Failed to adjust privilege")) + } + } + } + + // + // Pipe + // + + async fn open_and_validate_pipe_server(pipe_name: &'static str) -> Result { + let client = open_pipe_client(pipe_name).await?; + + if ENABLE_SERVER_SIGNATURE_VALIDATION { + let server_pid = get_named_pipe_server_pid(&client)?; + dbg_log!("Connected to pipe server PID {}", server_pid); + + // Validate the server end process signature + let exe_path = resolve_process_executable_path(server_pid)?; + + dbg_log!("Pipe server executable path: {}", exe_path.display()); + + if !verify_signature(&exe_path)? { + return Err(anyhow!("Pipe server signature is not valid")); + } + + dbg_log!("Pipe server signature verified for PID {}", server_pid); + } + + Ok(client) + } + + fn run() -> Result { + dbg_log!("Starting bitwarden_chromium_import_helper.exe"); + + let args = Args::try_parse()?; + + if !is_admin() { + return Err(anyhow!("Expected to run with admin privileges")); + } + + dbg_log!("Running as ADMINISTRATOR"); + + // Impersonate a SYSTEM process to be able to decrypt data encrypted for the machine + let system_decrypted_base64 = { + let system_token = start_impersonating()?; + defer! { + dbg_log!("Stopping impersonation"); + _ = stop_impersonating(system_token); + } + let system_decrypted_base64 = decrypt_data_base64(&args.encrypted, true)?; + dbg_log!("Decrypted data with system"); + system_decrypted_base64 + }; + + // This is just to check that we're decrypting Chrome keys and not something else sent to us by a malicious actor. + // Now that we're back from SYSTEM, we need to decrypt one more time just to verify. + // Chrome keys are double encrypted: once at SYSTEM level and once at USER level. + // When the decryption fails, it means that we're decrypting something unexpected. + // We don't send this result back since the library will decrypt again at USER level. + + _ = decrypt_data_base64(&system_decrypted_base64, false).map_err(|e| { + dbg_log!("User level decryption check failed: {}", e); + e + })?; + + dbg_log!("User level decryption check passed"); + + Ok(system_decrypted_base64) + } + + fn init_logging(log_path: &Path, file_level: LevelFilter) { + // We only log to a file. It's impossible to see stdout/stderr when this exe is launched from ShellExecuteW. + match std::fs::File::create(log_path) { + Ok(file) => { + let file_filter = EnvFilter::builder() + .with_default_directive(file_level.into()) + .from_env_lossy(); + + let file_layer = fmt::layer() + .with_writer(file) + .with_ansi(false) + .with_filter(file_filter); + + tracing_subscriber::registry().with(file_layer).init(); + } + Err(error) => { + error!(%error, ?log_path, "Could not create log file."); + } + } + } + + pub(crate) async fn main() { + if ENABLE_DEVELOPER_LOGGING { + init_logging(LOG_FILENAME.as_ref(), LevelFilter::DEBUG); + } + + let mut client = match open_and_validate_pipe_server(ADMIN_TO_USER_PIPE_NAME).await { + Ok(client) => client, + Err(e) => { + error!( + "Failed to open pipe {} to send result/error: {}", + ADMIN_TO_USER_PIPE_NAME, e + ); + return; + } + }; + + match run() { + Ok(system_decrypted_base64) => { + dbg_log!("Sending response back to user"); + let _ = send_to_user(&mut client, &system_decrypted_base64).await; + } + Err(e) => { + dbg_log!("Error: {}", e); + send_error_to_user(&mut client, &format!("{}", e)).await; + } + } + } +} + +pub(crate) use windows_binary::*; diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index 8b13fcc6eb3..a7ed89a9c17 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -45,6 +45,25 @@ function buildProxyBin(target, release = true) { } } +function buildImporterBinaries(target, release = true) { + // These binaries are only built for Windows, so we can skip them on other platforms + if (process.platform !== "win32") { + return; + } + + const bin = "bitwarden_chromium_import_helper"; + const targetArg = target ? `--target ${target}` : ""; + const releaseArg = release ? "--release" : ""; + child_process.execSync(`cargo build --bin ${bin} ${releaseArg} ${targetArg}`); + + if (target) { + // Copy the resulting binary to the dist folder + const targetFolder = release ? "release" : "debug"; + const nodeArch = rustTargetsMap[target].nodeArch; + fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `${bin}.exe`), path.join(__dirname, "dist", `${bin}.${process.platform}-${nodeArch}.exe`)); + } +} + function buildProcessIsolation() { if (process.platform !== "linux") { return; @@ -67,6 +86,7 @@ if (!crossPlatform && !target) { console.log(`Building native modules in ${mode} mode for the native architecture`); buildNapiModule(false, mode === "release"); buildProxyBin(false, mode === "release"); + buildImporterBinaries(false, mode === "release"); buildProcessIsolation(); return; } @@ -76,6 +96,7 @@ if (target) { installTarget(target); buildNapiModule(target, mode === "release"); buildProxyBin(target, mode === "release"); + buildImporterBinaries(false, mode === "release"); buildProcessIsolation(); return; } @@ -94,5 +115,6 @@ platformTargets.forEach(([target, _]) => { installTarget(target); buildNapiModule(target); buildProxyBin(target); + buildImporterBinaries(target); buildProcessIsolation(); }); diff --git a/apps/desktop/desktop_native/chromium_importer/Cargo.toml b/apps/desktop/desktop_native/chromium_importer/Cargo.toml index 648a36543c2..51ad450a6fc 100644 --- a/apps/desktop/desktop_native/chromium_importer/Cargo.toml +++ b/apps/desktop/desktop_native/chromium_importer/Cargo.toml @@ -12,22 +12,38 @@ anyhow = { workspace = true } async-trait = "=0.1.88" base64 = { workspace = true } cbc = { workspace = true, features = ["alloc"] } +dirs = { workspace = true } hex = { workspace = true } -homedir = { workspace = true } pbkdf2 = "=0.12.2" rand = { workspace = true } rusqlite = { version = "=0.37.0", features = ["bundled"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha1 = "=0.10.6" +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] security-framework = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] -tokio = { workspace = true, features = ["full"] } -winapi = { version = "=0.3.9", features = ["dpapi", "memoryapi"] } -windows = { workspace = true, features = ["Win32_Security", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_Services", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +chacha20poly1305 = { workspace = true } +windows = { workspace = true, features = [ + "Wdk_System_SystemServices", + "Win32_Security_Cryptography", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Memory", + "Win32_System_Pipes", + "Win32_System_ProcessStatus", + "Win32_System_Services", + "Win32_System_Threading", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } +verifysign = "=0.2.4" [target.'cfg(target_os = "linux")'.dependencies] oo7 = { workspace = true } diff --git a/apps/desktop/desktop_native/chromium_importer/README.md b/apps/desktop/desktop_native/chromium_importer/README.md index dd563697e5b..cec477c34a3 100644 --- a/apps/desktop/desktop_native/chromium_importer/README.md +++ b/apps/desktop/desktop_native/chromium_importer/README.md @@ -9,155 +9,124 @@ get access to the passwords. ### Overview -The Windows Application Bound Encryption (ABE) consists of three main components that work together: +The Windows **Application Bound Encryption (ABE)** subsystem consists of two main components that work together: -- **client library** -- Library that is part of the desktop client application -- **admin.exe** -- Service launcher running as ADMINISTRATOR -- **service.exe** -- Background Windows service running as SYSTEM +- **client library** — a library that is part of the desktop client application +- **bitwarden_chromium_import_helper.exe** — a password decryptor running as **ADMINISTRATOR** and later as **SYSTEM** -_(The names of the binaries will be changed for the released product.)_ +See the last section for a concise summary of the entire process. -### The goal +### Goal -The goal of this subsystem is to decrypt the master encryption key with which the login information -is encrypted on the local system in Windows. This applies to the most recent versions of Chrome and -Edge (untested yet) that are using the ABE/v20 encryption scheme for some of the local profiles. +The goal of this subsystem is to decrypt the master encryption key used to encrypt login information on the local +Windows system. This applies to the most recent versions of Chrome, Brave, and (untested) Edge that use the ABE/v20 +encryption scheme for some local profiles. -The general idea of this encryption scheme is that Chrome generates a unique random encryption key, -then encrypts it at the user level with a fixed key. It then sends it to the Windows Data Protection -API at the user level, and then, using an installed service, encrypts it with the Windows Data -Protection API at the system level on top of that. This triply encrypted key is later stored in the -`Local State` file. +The general idea of this encryption scheme is as follows: -The next paragraphs describe what is done at each level to decrypt the key. +1. Chrome generates a unique random encryption key. +2. This key is first encrypted at the **user level** with a fixed key. +3. It is then encrypted at the **user level** again using the Windows **Data Protection API (DPAPI)**. +4. Finally, it is sent to a special service that encrypts it with DPAPI at the **system level**. -### 1. Client library +This triply encrypted key is stored in the `Local State` file. -This is a Rust module that is part of the Chromium importer. It only compiles and runs on Windows -(see `abe.rs` and `abe_config.rs`). Its main task is to launch `admin.exe` with elevated privileges -by presenting the user with the UAC screen. See the `abe::decrypt_with_admin_and_service` invocation -in `windows.rs`. +The following sections describe how the key is decrypted at each level. -This function takes three arguments: +### 1. Client Library -1. Absolute path to `admin.exe` -2. Absolute path to `service.exe` -3. Base64 string of the ABE key extracted from the browser's local state +This is a Rust module that is part of the Chromium importer. It compiles and runs only on Windows (see `abe.rs` and +`abe_config.rs`). Its main task is to launch `bitwarden_chromium_import_helper.exe` with elevated privileges, presenting +the user with the UAC prompt. See the `abe::decrypt_with_admin` call in `windows.rs`. -It's not possible to install the service from the user-level executable. So first, we have to -elevate the privileges and run `admin.exe` as ADMINISTRATOR. This is done by calling `ShellExecute` -with the `runas` verb. Since it's not trivial to read the standard output from an application -launched in this way, a named pipe server is created at the user level, which waits for the response -from `admin.exe` after it has been launched. +This function takes two arguments: -The name of the service executable and the data to be decrypted are passed via the command line to -`admin.exe` like this: +1. Absolute path to `bitwarden_chromium_import_helper.exe` +2. Base64 string of the ABE key extracted from the browser's local state + +First, `bitwarden_chromium_import_helper.exe` is launched by calling a variant of `ShellExecute` with the `runas` verb. +This displays the UAC screen. If the user accepts, `bitwarden_chromium_import_helper.exe` starts with **ADMINISTRATOR** +privileges. + +> **The user must approve the UAC prompt or the process is aborted.** + +Because it is not possible to read the standard output of an application launched in this way, a named pipe server is +created at the user level before `bitwarden_chromium_import_helper.exe` is launched. This pipe is used to send the +decryption result from `bitwarden_chromium_import_helper.exe` back to the client. + +The data to be decrypted are passed via the command line to `bitwarden_chromium_import_helper.exe` like this: ```bat -admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..." +bitwarden_chromium_import_helper.exe --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..." ``` -**At this point, the user must permit the action to be performed on the UAC screen.** +### 2. Admin Executable -### 2. Admin executable +Although the process starts with **ADMINISTRATOR** privileges, its ultimate goal is to elevate to **SYSTEM**. To achieve +this, it uses a technique to impersonate a system-level process. -This executable receives the full path of `service.exe` and the data to be decrypted. +First, `bitwarden_chromium_import_helper.exe` ensures that the `SE_DEBUG_PRIVILEGE` privilege is enabled by calling +`RtlAdjustPrivilege`. This allows it to enumerate running system-level processes. -First, it installs the service to run as SYSTEM and waits for it to start running. The service -creates a named pipe server that the admin-level executable communicates with (see the `service.exe` -description further down). +Next, it finds an instance of `services.exe` or `winlogon.exe`, which are known to run at the **SYSTEM** level. Once a +system process is found, its token is duplicated by calling `DuplicateToken`. -It sends the base64 string to the pipe server in a raw message and waits for the answer. The answer -could be a success or a failure. In case of success, it's a base64 string decrypted at the system -level. In case of failure, it's an error message prefixed with an `!`. In either case, the response -is sent to the named pipe server created by the user. The user responds with `ok` (ignored). +With the duplicated token, `ImpersonateLoggedOnUser` is called to impersonate a system-level process. -After that, the executable stops and uninstalls the service and then exits. +> **At this point `bitwarden_chromium_import_helper.exe` is running as SYSTEM.** -### 3. System service +The received encryption key can now be decrypted using DPAPI at the system level. -The service starts and creates a named pipe server for communication between `admin.exe` and the -system service. Please note that it is not possible to communicate between the user and the system -service directly via a named pipe. Thus, this three-layered approach is necessary. +The decrypted result is sent back to the client via the named pipe. `bitwarden_chromium_import_helper.exe` connects to +the pipe and writes the result. -Once the service is started, it waits for the incoming message via the named pipe. The expected -message is a base64 string to be decrypted. The data is decrypted via the Windows Data Protection -API `CryptUnprotectData` and sent back in response to this incoming message in base64 encoding. In -case of an error, the error message is sent back prefixed with an `!`. +The response can indicate success or failure: -The service keeps running and servicing more requests if there are any, until it's stopped and -removed from the system. Even though we send only one request, the service is designed to handle as -many clients with as many messages as needed and could be installed on the system permanently if -necessary. +- On success: a Base64-encoded string. +- On failure: an error message prefixed with `!`. -### 4. Back to client library +In either case, the response is sent to the named pipe server created by the client. The client responds with `ok` +(ignored). -The decrypted base64-encoded string comes back from the admin executable to the named pipe server at -the user level. At this point, it has been decrypted only once at the system level. +Finally, `bitwarden_chromium_import_helper.exe` exits. -In the next step, the string is decrypted at the user level with the same Windows Data Protection -API. +### 3. Back to the Client Library -And as the third step, it's decrypted with a hard-coded key found in the `elevation_service.exe` -from the Chrome installation. Based on the version of the encrypted string (encoded in the string -itself), it's either AES-256-GCM or ChaCha20Poly1305 encryption scheme. The details can be found in -`windows.rs`. +The decrypted Base64-encoded string is returned from `bitwarden_chromium_import_helper.exe` to the named pipe server at +the user level. At this point it has been decrypted only once—at the system level. -After all of these steps, we have the master key which can be used to decrypt the password -information stored in the local database. +Next, the string is decrypted at the **user level** with DPAPI. -### Summary +Finally, for Google Chrome (but not Brave), it is decrypted again with a hard-coded key found in `elevation_service.exe` +from the Chrome installation. Based on the version of the encrypted string (encoded within the string itself), this step +uses either **AES-256-GCM** or **ChaCha20-Poly1305**. See `windows.rs` for details. -The Windows ABE decryption process involves a three-tier architecture with named pipe communication: +After these steps, the master key is available and can be used to decrypt the password information stored in the +browser’s local database. -```mermaid -sequenceDiagram - participant Client as Client Library (User) - participant Admin as admin.exe (Administrator) - participant Service as service.exe (System) +### TL;DR Steps - Client->>Client: Create named pipe server - Note over Client: \\.\pipe\BitwardenEncryptionService-admin-user +1. **Client side:** - Client->>Admin: Launch with UAC elevation - Note over Client,Admin: --service-exe c:\path\to\service.exe - Note over Client,Admin: --encrypted QVBQQgEAAADQjJ3fARXRE... + 1. Extract the encrypted key from Chrome’s settings. + 2. Create a named pipe server. + 3. Launch `bitwarden_chromium_import_helper.exe` with **ADMINISTRATOR** privileges, passing the key to be decrypted + via CLI arguments. + 4. Wait for the response from `bitwarden_chromium_import_helper.exe`. - Client->>Client: Wait for response +2. **Admin side:** - Admin->>Service: Install & start service - Note over Admin,Service: c:\path\to\service.exe + 1. Start. + 2. Ensure `SE_DEBUG_PRIVILEGE` is enabled (not strictly necessary in tests). + 3. Impersonate a system process such as `services.exe` or `winlogon.exe`. + 4. Decrypt the key using DPAPI at the **SYSTEM** level. + 5. Send the result or error back via the named pipe. + 6. Exit. - Service->>Service: Create named pipe server - Note over Service: \\.\pipe\BitwardenEncryptionService-service-admin - - Service->>Service: Wait for message - - Admin->>Service: Send encrypted data via admin-service pipe - Note over Admin,Service: QVBQQgEAAADQjJ3fARXRE... - - Admin->>Admin: Wait for response - - Service->>Service: Decrypt with system-level DPAPI - - Service->>Admin: Return decrypted data via admin-service pipe - Note over Service,Admin: EjRWeXN0ZW0gU2VydmljZQ... - - Admin->>Client: Send result via named user-admin pipe - Note over Client,Admin: EjRWeXN0ZW0gU2VydmljZQ... - - Client->>Admin: Send ACK to admin - Note over Client,Admin: ok - - Admin->>Service: Stop & uninstall service - Service-->>Admin: Exit - - Admin-->>Client: Exit - - Client->>Client: Decrypt with user-level DPAPI - - Client->>Client: Decrypt with hardcoded key - Note over Client: AES-256-GCM or ChaCha20Poly1305 - - Client->>Client: Done -``` +3. **Back on the client side:** + 1. Receive the encryption key. + 2. Shutdown the pipe server. + 3. Decrypt it with DPAPI at the **USER** level. + 4. (For Chrome only) Decrypt again with the hard-coded key. + 5. Obtain the fully decrypted master key. + 6. Use the master key to read and decrypt stored passwords from Chrome, Brave, Edge, etc. diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs index 55728460436..471e35da23e 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs @@ -3,12 +3,17 @@ use std::sync::LazyLock; use anyhow::{anyhow, Result}; use async_trait::async_trait; +use dirs; use hex::decode; -use homedir::my_home; use rusqlite::{params, Connection}; mod platform; +#[cfg(target_os = "windows")] +pub use platform::{ + verify_signature, ADMIN_TO_USER_PIPE_NAME, EXPECTED_SIGNATURE_SHA256_THUMBPRINT, +}; + pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS; // @@ -52,7 +57,6 @@ pub trait InstalledBrowserRetriever { pub struct DefaultInstalledBrowserRetriever {} impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever { - // TODO: Make thus async fn get_installed_browsers() -> Result> { let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len()); @@ -67,7 +71,6 @@ impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever { } } -// TODO: Make thus async pub fn get_available_profiles(browser_name: &String) -> Result> { let (_, local_state) = load_local_state_for_browser(browser_name)?; Ok(get_profile_info(&local_state)) @@ -123,8 +126,7 @@ pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock< }); fn get_browser_data_dir(config: &BrowserConfig) -> Result { - let dir = my_home() - .map_err(|_| anyhow!("Home directory not found"))? + let dir = dirs::home_dir() .ok_or_else(|| anyhow!("Home directory not found"))? .join(config.data_dir); Ok(dir) diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs index 2a21ef23d82..fe497de0773 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/mod.rs @@ -1,7 +1,9 @@ // Platform-specific code #[cfg_attr(target_os = "linux", path = "linux.rs")] -#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "windows", path = "windows/mod.rs")] #[cfg_attr(target_os = "macos", path = "macos.rs")] mod native; -pub(crate) use native::*; +// Windows exposes public const +#[allow(unused_imports)] +pub use native::*; diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs deleted file mode 100644 index 79c462c29a1..00000000000 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows.rs +++ /dev/null @@ -1,204 +0,0 @@ -use aes_gcm::aead::Aead; -use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; -use anyhow::{anyhow, Result}; -use async_trait::async_trait; -use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use winapi::shared::minwindef::{BOOL, BYTE, DWORD}; -use winapi::um::{dpapi::CryptUnprotectData, wincrypt::DATA_BLOB}; -use windows::Win32::Foundation::{LocalFree, HLOCAL}; - -use crate::chromium::{BrowserConfig, CryptoService, LocalState}; - -use crate::util; - -// -// Public API -// - -pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ - BrowserConfig { - name: "Brave", - data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", - }, - BrowserConfig { - name: "Chrome", - data_dir: "AppData/Local/Google/Chrome/User Data", - }, - BrowserConfig { - name: "Chromium", - data_dir: "AppData/Local/Chromium/User Data", - }, - BrowserConfig { - name: "Microsoft Edge", - data_dir: "AppData/Local/Microsoft/Edge/User Data", - }, - BrowserConfig { - name: "Opera", - data_dir: "AppData/Roaming/Opera Software/Opera Stable", - }, - BrowserConfig { - name: "Vivaldi", - data_dir: "AppData/Local/Vivaldi/User Data", - }, -]; - -pub(crate) fn get_crypto_service( - _browser_name: &str, - local_state: &LocalState, -) -> Result> { - Ok(Box::new(WindowsCryptoService::new(local_state))) -} - -// -// CryptoService -// -struct WindowsCryptoService { - master_key: Option>, - encrypted_key: Option, -} - -impl WindowsCryptoService { - pub(crate) fn new(local_state: &LocalState) -> Self { - Self { - master_key: None, - encrypted_key: local_state - .os_crypt - .as_ref() - .and_then(|c| c.encrypted_key.clone()), - } - } -} - -#[async_trait] -impl CryptoService for WindowsCryptoService { - async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { - if encrypted.is_empty() { - return Ok(String::new()); - } - - // On Windows only v10 and v20 are supported at the moment - let (version, no_prefix) = - util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?; - - // v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag] - const IV_SIZE: usize = 12; - const TAG_SIZE: usize = 16; - const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE; - - if no_prefix.len() < MIN_LENGTH { - return Err(anyhow!( - "Corrupted entry: expected at least {} bytes, got {} bytes", - MIN_LENGTH, - no_prefix.len() - )); - } - - // Allow empty passwords - if no_prefix.len() == MIN_LENGTH { - return Ok(String::new()); - } - - if self.master_key.is_none() { - self.master_key = Some(self.get_master_key(version)?); - } - - let key = self - .master_key - .as_ref() - .ok_or_else(|| anyhow!("Failed to retrieve key"))?; - let key = Key::::from_slice(key); - let cipher = Aes256Gcm::new(key); - let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]); - - let decrypted_bytes = cipher - .decrypt(nonce, no_prefix[IV_SIZE..].as_ref()) - .map_err(|e| anyhow!("Decryption failed: {}", e))?; - - let plaintext = String::from_utf8(decrypted_bytes) - .map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?; - - Ok(plaintext) - } -} - -impl WindowsCryptoService { - fn get_master_key(&mut self, version: &str) -> Result> { - match version { - "v10" => self.get_master_key_v10(), - _ => Err(anyhow!("Unsupported version: {}", version)), - } - } - - fn get_master_key_v10(&mut self) -> Result> { - if self.encrypted_key.is_none() { - return Err(anyhow!( - "Encrypted master key is not found in the local browser state" - )); - } - - let key = self - .encrypted_key - .as_ref() - .ok_or_else(|| anyhow!("Failed to retrieve key"))?; - let key_bytes = BASE64_STANDARD - .decode(key) - .map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?; - - if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" { - return Err(anyhow!("Encrypted master key is not encrypted with DPAPI")); - } - - let key = unprotect_data_win(&key_bytes[5..]) - .map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?; - - Ok(key) - } -} - -fn unprotect_data_win(data: &[u8]) -> Result> { - if data.is_empty() { - return Ok(Vec::new()); - } - - let mut data_in = DATA_BLOB { - cbData: data.len() as DWORD, - pbData: data.as_ptr() as *mut BYTE, - }; - - let mut data_out = DATA_BLOB { - cbData: 0, - pbData: std::ptr::null_mut(), - }; - - let result: BOOL = unsafe { - // BOOL from winapi (i32) - CryptUnprotectData( - &mut data_in, - std::ptr::null_mut(), // ppszDataDescr: *mut LPWSTR (*mut *mut u16) - std::ptr::null_mut(), // pOptionalEntropy: *mut DATA_BLOB - std::ptr::null_mut(), // pvReserved: LPVOID (*mut c_void) - std::ptr::null_mut(), // pPromptStruct: *mut CRYPTPROTECT_PROMPTSTRUCT - 0, // dwFlags: DWORD - &mut data_out, - ) - }; - - if result == 0 { - return Err(anyhow!("CryptUnprotectData failed")); - } - - if data_out.pbData.is_null() || data_out.cbData == 0 { - return Ok(Vec::new()); - } - - let output_slice = - unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) }; - - unsafe { - if !data_out.pbData.is_null() { - LocalFree(Some(HLOCAL(data_out.pbData as *mut std::ffi::c_void))); - } - } - - Ok(output_slice.to_vec()) -} diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs new file mode 100644 index 00000000000..943727690f2 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs @@ -0,0 +1,178 @@ +use super::abe_config; +use anyhow::{anyhow, Result}; +use std::{ffi::OsStr, os::windows::ffi::OsStrExt}; +use tokio::{ + io::{self, AsyncReadExt, AsyncWriteExt}, + net::windows::named_pipe::{NamedPipeServer, ServerOptions}, + sync::mpsc::channel, + task::JoinHandle, + time::{timeout, Duration}, +}; +use tracing::debug; +use windows::{ + core::PCWSTR, + Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_HIDE}, +}; + +const WAIT_FOR_ADMIN_MESSAGE_TIMEOUT_SECS: u64 = 30; + +fn start_tokio_named_pipe_server( + pipe_name: &'static str, + process_message: F, +) -> Result>> +where + F: Fn(&str) -> String + Send + Sync + Clone + 'static, +{ + debug!("Starting Tokio named pipe server on: {}", pipe_name); + + // The first server needs to be constructed early so that clients can be correctly + // connected. Otherwise calling .wait will cause the client to error. + // Here we also make use of `first_pipe_instance`, which will ensure that + // there are no other servers up and running already. + let mut server = ServerOptions::new() + .first_pipe_instance(true) + .create(pipe_name)?; + + debug!("Named pipe server created and listening..."); + + // Spawn the server loop. + let server_task = tokio::spawn(async move { + loop { + // Wait for a client to connect. + match server.connect().await { + Ok(_) => { + debug!("Client connected to named pipe"); + let connected_client = server; + + // Construct the next server to be connected before sending the one + // we already have off to a task. This ensures that the server + // isn't closed (after it's done in the task) before a new one is + // available. Otherwise the client might error with + // `io::ErrorKind::NotFound`. + server = ServerOptions::new().create(pipe_name)?; + + // Handle the connected client in a separate task + let process_message_clone = process_message.clone(); + let _client_task = tokio::spawn(async move { + if let Err(e) = handle_client(connected_client, process_message_clone).await + { + debug!("Error handling client: {}", e); + } + }); + } + Err(e) => { + debug!("Failed to connect to client: {}", e); + continue; + } + } + } + }); + + Ok(server_task) +} + +async fn handle_client(mut client: NamedPipeServer, process_message: F) -> Result<()> +where + F: Fn(&str) -> String, +{ + debug!("Handling new client connection"); + + loop { + // Read a message from the client + let mut buffer = vec![0u8; 64 * 1024]; + match client.read(&mut buffer).await { + Ok(0) => { + debug!("Client disconnected (0 bytes read)"); + return Ok(()); + } + Ok(bytes_read) => { + let message = String::from_utf8_lossy(&buffer[..bytes_read]); + let preview = message.chars().take(16).collect::(); + debug!( + "Received from client: '{}...' ({} bytes)", + preview, bytes_read, + ); + + let response = process_message(&message); + match client.write_all(response.as_bytes()).await { + Ok(_) => { + debug!("Sent response to client ({} bytes)", response.len()); + } + Err(e) => { + return Err(anyhow!("Failed to send response to client: {}", e)); + } + } + } + Err(e) => { + return Err(anyhow!("Failed to read from client: {}", e)); + } + } + } +} + +pub(crate) async fn decrypt_with_admin_exe(admin_exe: &str, encrypted: &str) -> Result { + let (tx, mut rx) = channel::(1); + + debug!( + "Starting named pipe server at '{}'...", + abe_config::ADMIN_TO_USER_PIPE_NAME + ); + + let server = match start_tokio_named_pipe_server( + abe_config::ADMIN_TO_USER_PIPE_NAME, + move |message: &str| { + let _ = tx.try_send(message.to_string()); + "ok".to_string() + }, + ) { + Ok(server) => server, + Err(e) => return Err(anyhow!("Failed to start named pipe server: {}", e)), + }; + + debug!("Launching '{}' as ADMINISTRATOR...", admin_exe); + decrypt_with_admin_exe_internal(admin_exe, encrypted); + + debug!("Waiting for message from {}...", admin_exe); + let message = match timeout( + Duration::from_secs(WAIT_FOR_ADMIN_MESSAGE_TIMEOUT_SECS), + rx.recv(), + ) + .await + { + Ok(Some(msg)) => msg, + Ok(None) => return Err(anyhow!("Channel closed without message from {}", admin_exe)), + Err(_) => return Err(anyhow!("Timeout waiting for message from {}", admin_exe)), + }; + + debug!("Shutting down the pipe server..."); + server.abort(); + + Ok(message) +} + +fn decrypt_with_admin_exe_internal(admin_exe: &str, encrypted: &str) { + // Convert strings to wide strings for Windows API + let exe_wide = OsStr::new(admin_exe) + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let runas_wide = OsStr::new("runas") + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let parameters = OsStr::new(&format!(r#"--encrypted "{}""#, encrypted)) + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + + unsafe { + ShellExecuteW( + None, + PCWSTR(runas_wide.as_ptr()), + PCWSTR(exe_wide.as_ptr()), + PCWSTR(parameters.as_ptr()), + None, + SW_HIDE, + ); + } +} diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe_config.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe_config.rs new file mode 100644 index 00000000000..66b1d3b8435 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe_config.rs @@ -0,0 +1,2 @@ +pub const ADMIN_TO_USER_PIPE_NAME: &str = + r"\\.\pipe\bitwarden-to-bitwarden-chromium-importer-helper"; diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs new file mode 100644 index 00000000000..a8045cf1182 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs @@ -0,0 +1,434 @@ +use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit, Nonce}; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use chacha20poly1305::ChaCha20Poly1305; +use std::path::{Path, PathBuf}; +use windows::Win32::{ + Foundation::{LocalFree, HLOCAL}, + Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB}, +}; + +use crate::chromium::{BrowserConfig, CryptoService, LocalState}; +use crate::util; +mod abe; +mod abe_config; +mod signature; + +pub use abe_config::ADMIN_TO_USER_PIPE_NAME; +pub use signature::*; + +// +// Public API +// + +pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ + BrowserConfig { + name: "Brave", + data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", + }, + BrowserConfig { + name: "Chrome", + data_dir: "AppData/Local/Google/Chrome/User Data", + }, + BrowserConfig { + name: "Chromium", + data_dir: "AppData/Local/Chromium/User Data", + }, + BrowserConfig { + name: "Microsoft Edge", + data_dir: "AppData/Local/Microsoft/Edge/User Data", + }, + BrowserConfig { + name: "Opera", + data_dir: "AppData/Roaming/Opera Software/Opera Stable", + }, + BrowserConfig { + name: "Vivaldi", + data_dir: "AppData/Local/Vivaldi/User Data", + }, +]; + +pub(crate) fn get_crypto_service( + _browser_name: &str, + local_state: &LocalState, +) -> Result> { + Ok(Box::new(WindowsCryptoService::new(local_state))) +} + +// +// Private +// + +const ADMIN_EXE_FILENAME: &str = "bitwarden_chromium_import_helper.exe"; + +// This should be enabled for production +const ENABLE_SIGNATURE_VALIDATION: bool = true; + +// +// CryptoService +// +struct WindowsCryptoService { + master_key: Option>, + encrypted_key: Option, + app_bound_encrypted_key: Option, +} + +impl WindowsCryptoService { + pub(crate) fn new(local_state: &LocalState) -> Self { + Self { + master_key: None, + encrypted_key: local_state + .os_crypt + .as_ref() + .and_then(|c| c.encrypted_key.clone()), + app_bound_encrypted_key: local_state + .os_crypt + .as_ref() + .and_then(|c| c.app_bound_encrypted_key.clone()), + } + } +} + +#[async_trait] +impl CryptoService for WindowsCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + if encrypted.is_empty() { + return Ok(String::new()); + } + + // On Windows only v10 and v20 are supported at the moment + let (version, no_prefix) = + util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?; + + // v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag] + const IV_SIZE: usize = 12; + const TAG_SIZE: usize = 16; + const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE; + + if no_prefix.len() < MIN_LENGTH { + return Err(anyhow!( + "Corrupted entry: expected at least {} bytes, got {} bytes", + MIN_LENGTH, + no_prefix.len() + )); + } + + // Allow empty passwords + if no_prefix.len() == MIN_LENGTH { + return Ok(String::new()); + } + + if self.master_key.is_none() { + self.master_key = Some(self.get_master_key(version).await?); + } + + let key = self + .master_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key = Key::::from_slice(key); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]); + + let decrypted_bytes = cipher + .decrypt(nonce, no_prefix[IV_SIZE..].as_ref()) + .map_err(|e| anyhow!("Decryption failed: {}", e))?; + + let plaintext = String::from_utf8(decrypted_bytes) + .map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?; + + Ok(plaintext) + } +} + +impl WindowsCryptoService { + async fn get_master_key(&mut self, version: &str) -> Result> { + match version { + "v10" => self.get_master_key_v10(), + "v20" => self.get_master_key_v20().await, + _ => Err(anyhow!("Unsupported version: {}", version)), + } + } + + fn get_master_key_v10(&mut self) -> Result> { + if self.encrypted_key.is_none() { + return Err(anyhow!( + "Encrypted master key is not found in the local browser state" + )); + } + + let key = self + .encrypted_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key_bytes = BASE64_STANDARD + .decode(key) + .map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?; + + if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" { + return Err(anyhow!("Encrypted master key is not encrypted with DPAPI")); + } + + let key = unprotect_data_win(&key_bytes[5..]) + .map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?; + + Ok(key) + } + + async fn get_master_key_v20(&mut self) -> Result> { + if self.app_bound_encrypted_key.is_none() { + return Err(anyhow!( + "Encrypted master key is not found in the local browser state" + )); + } + + let admin_exe_path = get_admin_exe_path()?; + + if ENABLE_SIGNATURE_VALIDATION && !verify_signature(&admin_exe_path)? { + return Err(anyhow!("Helper executable signature is not valid")); + } + + let admin_exe_str = admin_exe_path + .to_str() + .ok_or_else(|| anyhow!("Failed to convert {} path to string", ADMIN_EXE_FILENAME))?; + + let key_base64 = abe::decrypt_with_admin_exe( + admin_exe_str, + self.app_bound_encrypted_key + .as_ref() + .expect("app_bound_encrypted_key should not be None"), + ) + .await?; + + if let Some(error_message) = key_base64.strip_prefix('!') { + return Err(anyhow!( + "Failed to decrypt the master key: {}", + error_message + )); + } + + let key_bytes = BASE64_STANDARD.decode(&key_base64)?; + let key = unprotect_data_win(&key_bytes)?; + + Self::decode_abe_key_blob(key.as_slice()) + } + + fn decode_abe_key_blob(blob_data: &[u8]) -> Result> { + let header_len = u32::from_le_bytes(blob_data[0..4].try_into()?) as usize; + // Ignore the header + + let content_len_offset = 4 + header_len; + let content_len = + u32::from_le_bytes(blob_data[content_len_offset..content_len_offset + 4].try_into()?) + as usize; + + if content_len < 1 { + return Err(anyhow!( + "Corrupted ABE key blob: content length is less than 1" + )); + } + + let content_offset = content_len_offset + 4; + let content = &blob_data[content_offset..content_offset + content_len]; + + // When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, Brave, possibly Edge + if content_len == 32 { + return Ok(content.to_vec()); + } + + let version = content[0]; + let key_blob = &content[1..]; + match version { + // Google Chrome v1 key encrypted with a hardcoded AES key + 1_u8 => Self::decrypt_abe_key_blob_chrome_aes(key_blob), + // Google Chrome v2 key encrypted with a hardcoded ChaCha20 key + 2_u8 => Self::decrypt_abe_key_blob_chrome_chacha20(key_blob), + // Google Chrome v3 key encrypted with CNG APIs + 3_u8 => Self::decrypt_abe_key_blob_chrome_cng(key_blob), + v => Err(anyhow!("Unsupported ABE key blob version: {}", v)), + } + } + + // TODO: DRY up with decrypt_abe_key_blob_chrome_chacha20 + fn decrypt_abe_key_blob_chrome_aes(blob: &[u8]) -> Result> { + if blob.len() < 60 { + return Err(anyhow!( + "Corrupted ABE key blob: expected at least 60 bytes, got {} bytes", + blob.len() + )); + } + + let iv: [u8; 12] = blob[0..12].try_into()?; + let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?; + + const GOOGLE_AES_KEY: &[u8] = &[ + 0xB3, 0x1C, 0x6E, 0x24, 0x1A, 0xC8, 0x46, 0x72, 0x8D, 0xA9, 0xC1, 0xFA, 0xC4, 0x93, + 0x66, 0x51, 0xCF, 0xFB, 0x94, 0x4D, 0x14, 0x3A, 0xB8, 0x16, 0x27, 0x6B, 0xCC, 0x6D, + 0xA0, 0x28, 0x47, 0x87, + ]; + let aes_key = Key::::from_slice(GOOGLE_AES_KEY); + let cipher = Aes256Gcm::new(aes_key); + + let decrypted = cipher + .decrypt((&iv).into(), ciphertext.as_ref()) + .map_err(|e| anyhow!("Failed to decrypt v20 key with Google AES key: {}", e))?; + + Ok(decrypted) + } + + fn decrypt_abe_key_blob_chrome_chacha20(blob: &[u8]) -> Result> { + if blob.len() < 60 { + return Err(anyhow!( + "Corrupted ABE key blob: expected at least 60 bytes, got {} bytes", + blob.len() + )); + } + + let chacha20_key = chacha20poly1305::Key::from_slice(GOOGLE_CHACHA20_KEY); + let cipher = ChaCha20Poly1305::new(chacha20_key); + + const GOOGLE_CHACHA20_KEY: &[u8] = &[ + 0xE9, 0x8F, 0x37, 0xD7, 0xF4, 0xE1, 0xFA, 0x43, 0x3D, 0x19, 0x30, 0x4D, 0xC2, 0x25, + 0x80, 0x42, 0x09, 0x0E, 0x2D, 0x1D, 0x7E, 0xEA, 0x76, 0x70, 0xD4, 0x1F, 0x73, 0x8D, + 0x08, 0x72, 0x96, 0x60, + ]; + + let iv: [u8; 12] = blob[0..12].try_into()?; + let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?; + + let decrypted = cipher + .decrypt((&iv).into(), ciphertext.as_ref()) + .map_err(|e| anyhow!("Failed to decrypt v20 key with Google ChaCha20 key: {}", e))?; + + Ok(decrypted) + } + + fn decrypt_abe_key_blob_chrome_cng(blob: &[u8]) -> Result> { + if blob.len() < 92 { + return Err(anyhow!( + "Corrupted ABE key blob: expected at least 92 bytes, got {} bytes", + blob.len() + )); + } + + let _encrypted_aes_key: [u8; 32] = blob[0..32].try_into()?; + let _iv: [u8; 12] = blob[32..32 + 12].try_into()?; + let _ciphertext: [u8; 48] = blob[44..44 + 48].try_into()?; + + // TODO: Decrypt the AES key using CNG APIs + // TODO: Implement this in the future once we run into a browser that uses this scheme + + // There's no way to test this at the moment. This encryption scheme is not used in any of the browsers I've tested. + Err(anyhow!("Google ABE CNG flavor is not supported yet")) + } +} + +fn unprotect_data_win(data: &[u8]) -> Result> { + if data.is_empty() { + return Ok(Vec::new()); + } + + let data_in = CRYPT_INTEGER_BLOB { + cbData: data.len() as u32, + pbData: data.as_ptr() as *mut u8, + }; + + let mut data_out = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + + let result = unsafe { + CryptUnprotectData( + &data_in, + None, // ppszDataDescr: Option<*mut PWSTR> + None, // pOptionalEntropy: Option<*const CRYPT_INTEGER_BLOB> + None, // pvReserved: Option<*const std::ffi::c_void> + None, // pPromptStruct: Option<*const CRYPTPROTECT_PROMPTSTRUCT> + 0, // dwFlags: u32 + &mut data_out, + ) + }; + + if result.is_err() { + return Err(anyhow!("CryptUnprotectData failed")); + } + + if data_out.pbData.is_null() || data_out.cbData == 0 { + return Ok(Vec::new()); + } + + let output_slice = + unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) }; + + unsafe { + if !data_out.pbData.is_null() { + LocalFree(Some(HLOCAL(data_out.pbData as *mut std::ffi::c_void))); + } + } + + Ok(output_slice.to_vec()) +} + +fn get_admin_exe_path() -> Result { + let current_exe_full_path = std::env::current_exe() + .map_err(|e| anyhow!("Failed to get current executable path: {}", e))?; + + let exe_name = current_exe_full_path + .file_name() + .ok_or_else(|| anyhow!("Failed to get file name from current executable path"))?; + + let admin_exe_full_path = if exe_name.eq_ignore_ascii_case("electron.exe") { + get_debug_admin_exe_path()? + } else { + get_dist_admin_exe_path(¤t_exe_full_path)? + }; + + // check if bitwarden_chromium_import_helper.exe exists + if !admin_exe_full_path.exists() { + return Err(anyhow!( + "{} not found at path: {:?}", + ADMIN_EXE_FILENAME, + admin_exe_full_path + )); + } + + Ok(admin_exe_full_path) +} + +fn get_dist_admin_exe_path(current_exe_full_path: &Path) -> Result { + let admin_exe = current_exe_full_path + .parent() + .map(|p| p.join(ADMIN_EXE_FILENAME)) + .ok_or_else(|| anyhow!("Failed to get parent directory of current executable"))?; + + Ok(admin_exe) +} + +// Try to find bitwarden_chromium_import_helper.exe in debug build folders. This might not cover all the cases. +// Tested on `npm run electron` from apps/desktop and apps/desktop/desktop_native. +fn get_debug_admin_exe_path() -> Result { + let current_dir = std::env::current_dir()?; + let folder_name = current_dir + .file_name() + .ok_or_else(|| anyhow!("Failed to get folder name from current directory"))?; + match folder_name.to_str() { + Some("desktop") => Ok(get_target_admin_exe_path( + current_dir.join("desktop_native"), + )), + Some("desktop_native") => Ok(get_target_admin_exe_path(current_dir)), + _ => Err(anyhow!( + "Cannot determine {} path from current directory: {}", + ADMIN_EXE_FILENAME, + current_dir.display() + )), + } +} + +fn get_target_admin_exe_path(desktop_native_dir: PathBuf) -> PathBuf { + desktop_native_dir + .join("target") + .join("debug") + .join(ADMIN_EXE_FILENAME) +} diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/signature.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/signature.rs new file mode 100644 index 00000000000..a30b396db28 --- /dev/null +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/signature.rs @@ -0,0 +1,28 @@ +use anyhow::{anyhow, Result}; +use std::path::Path; +use tracing::{debug, info}; +use verifysign::CodeSignVerifier; + +pub const EXPECTED_SIGNATURE_SHA256_THUMBPRINT: &str = + "9f6680c4720dbf66d1cb8ed6e328f58e42523badc60d138c7a04e63af14ea40d"; + +pub fn verify_signature(path: &Path) -> Result { + info!("verifying signature of: {}", path.display()); + + let verifier = CodeSignVerifier::for_file(path) + .map_err(|e| anyhow!("verifysign init failed for {}: {:?}", path.display(), e))?; + + let signature = verifier + .verify() + .map_err(|e| anyhow!("verifysign verify failed for {}: {:?}", path.display(), e))?; + + // Dump signature fields for debugging/inspection + debug!("Signature fields:"); + debug!(" Subject Name: {:?}", signature.subject_name()); + debug!(" Issuer Name: {:?}", signature.issuer_name()); + debug!(" SHA1 Thumbprint: {:?}", signature.sha1_thumbprint()); + debug!(" SHA256 Thumbprint: {:?}", signature.sha256_thumbprint()); + debug!(" Serial Number: {:?}", signature.serial()); + + Ok(signature.sha256_thumbprint() == EXPECTED_SIGNATURE_SHA256_THUMBPRINT) +} diff --git a/apps/desktop/desktop_native/clippy.toml b/apps/desktop/desktop_native/clippy.toml index a29e019ac02..4441a038635 100644 --- a/apps/desktop/desktop_native/clippy.toml +++ b/apps/desktop/desktop_native/clippy.toml @@ -1,2 +1,10 @@ allow-unwrap-in-tests=true allow-expect-in-tests=true + +disallowed-macros = [ + { path = "log::trace", reason = "Use tracing for logging needs", replacement = "tracing::trace" }, + { path = "log::debug", reason = "Use tracing for logging needs", replacement = "tracing::debug" }, + { path = "log::info", reason = "Use tracing for logging needs", replacement = "tracing::info" }, + { path = "log::warn", reason = "Use tracing for logging needs", replacement = "tracing::warn" }, + { path = "log::error", reason = "Use tracing for logging needs", replacement = "tracing::error" }, +] diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs b/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs new file mode 100644 index 00000000000..44cba4a9e5b --- /dev/null +++ b/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs @@ -0,0 +1,141 @@ +//! This file implements Polkit based system unlock. +//! +//! # Security +//! This section describes the assumed security model and security guarantees achieved. In the required security +//! guarantee is that a locked vault - a running app - cannot be unlocked when the device (user-space) +//! is compromised in this state. +//! +//! When first unlocking the app, the app sends the user-key to this module, which holds it in secure memory, +//! protected by memfd_secret. This makes it inaccessible to other processes, even if they compromise root, a kernel compromise +//! has circumventable best-effort protections. While the app is running this key is held in memory, even if locked. +//! When unlocking, the app will prompt the user via `polkit` to get a yes/no decision on whether to release the key to the app. + +use anyhow::{anyhow, Result}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{debug, warn}; +use zbus::Connection; +use zbus_polkit::policykit1::{AuthorityProxy, CheckAuthorizationFlags, Subject}; + +use crate::secure_memory::*; + +pub struct BiometricLockSystem { + // The userkeys that are held in memory MUST be protected from memory dumping attacks, to ensure + // locked vaults cannot be unlocked + secure_memory: Arc>, +} + +impl BiometricLockSystem { + pub fn new() -> Self { + Self { + secure_memory: Arc::new(Mutex::new( + crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore::new(), + )), + } + } +} + +impl Default for BiometricLockSystem { + fn default() -> Self { + Self::new() + } +} + +impl super::BiometricTrait for BiometricLockSystem { + async fn authenticate(&self, _hwnd: Vec, _message: String) -> Result { + polkit_authenticate_bitwarden_policy().await + } + + async fn authenticate_available(&self) -> Result { + polkit_is_bitwarden_policy_available().await + } + + async fn enroll_persistent(&self, _user_id: &str, _key: &[u8]) -> Result<()> { + // Not implemented + Ok(()) + } + + async fn provide_key(&self, user_id: &str, key: &[u8]) { + self.secure_memory + .lock() + .await + .put(user_id.to_string(), key); + } + + async fn unlock(&self, user_id: &str, _hwnd: Vec) -> Result> { + if !polkit_authenticate_bitwarden_policy().await? { + return Err(anyhow!("Authentication failed")); + } + + self.secure_memory + .lock() + .await + .get(user_id) + .ok_or(anyhow!("No key found")) + } + + async fn unlock_available(&self, user_id: &str) -> Result { + Ok(self.secure_memory.lock().await.has(user_id)) + } + + async fn has_persistent(&self, _user_id: &str) -> Result { + Ok(false) + } + + async fn unenroll(&self, user_id: &str) -> Result<(), anyhow::Error> { + self.secure_memory.lock().await.remove(user_id); + Ok(()) + } +} + +/// Perform a polkit authorization against the bitwarden unlock policy. Note: This relies on no custom +/// rules in the system skipping the authorization check, in which case this counts as UV / authentication. +async fn polkit_authenticate_bitwarden_policy() -> Result { + debug!("[Polkit] Authenticating / performing UV"); + + let connection = Connection::system().await?; + let proxy = AuthorityProxy::new(&connection).await?; + let subject = Subject::new_for_owner(std::process::id(), None, None)?; + let details = std::collections::HashMap::new(); + let authorization_result = proxy + .check_authorization( + &subject, + "com.bitwarden.Bitwarden.unlock", + &details, + CheckAuthorizationFlags::AllowUserInteraction.into(), + "", + ) + .await; + + match authorization_result { + Ok(result) => Ok(result.is_authorized), + Err(e) => { + warn!("[Polkit] Error performing authentication: {:?}", e); + Ok(false) + } + } +} + +async fn polkit_is_bitwarden_policy_available() -> Result { + let connection = Connection::system().await?; + let proxy = AuthorityProxy::new(&connection).await?; + let actions = proxy.enumerate_actions("en").await?; + for action in actions { + if action.action_id == "com.bitwarden.Bitwarden.unlock" { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore] + async fn test_polkit_authenticate() { + let result = polkit_authenticate_bitwarden_policy().await; + assert!(result.is_ok()); + } +} diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs index e37a101e2ae..669267b7829 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs @@ -1,7 +1,7 @@ use anyhow::Result; #[allow(clippy::module_inception)] -#[cfg_attr(target_os = "linux", path = "unimplemented.rs")] +#[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "macos", path = "unimplemented.rs")] #[cfg_attr(target_os = "windows", path = "windows.rs")] mod biometric_v2; diff --git a/apps/desktop/desktop_native/core/src/ipc/mod.rs b/apps/desktop/desktop_native/core/src/ipc/mod.rs index 41215b3a0ee..5d4cc9e27f7 100644 --- a/apps/desktop/desktop_native/core/src/ipc/mod.rs +++ b/apps/desktop/desktop_native/core/src/ipc/mod.rs @@ -35,7 +35,7 @@ fn internal_ipc_codec(inner: T) -> Framed std::path::PathBuf { #[cfg(target_os = "windows")] { - // Use a unique IPC pipe //./pipe/xxxxxxxxxxxxxxxxx.app.bitwarden per user. + // Use a unique IPC pipe //./pipe/xxxxxxxxxxxxxxxxx.s.bw per user (s for socket). // Hashing prevents problems with reserved characters and file length limitations. use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use sha2::Digest; @@ -43,7 +43,7 @@ pub fn path(name: &str) -> std::path::PathBuf { let hash = sha2::Sha256::digest(home.as_os_str().as_encoded_bytes()); let hash_b64 = URL_SAFE_NO_PAD.encode(hash.as_slice()); - format!(r"\\.\pipe\{hash_b64}.app.{name}").into() + format!(r"\\.\pipe\{hash_b64}.s.{name}").into() } #[cfg(target_os = "macos")] @@ -65,11 +65,11 @@ pub fn path(name: &str) -> std::path::PathBuf { home.pop(); } - let tmp = home.join("Library/Group Containers/LTZ2PFU5D6.com.bitwarden.desktop/tmp"); + let tmp = home.join("Library/Group Containers/LTZ2PFU5D6.com.bitwarden.desktop"); // The tmp directory might not exist, so create it let _ = std::fs::create_dir_all(&tmp); - return tmp.join(format!("app.{name}")); + return tmp.join(format!("s.{name}")); } } @@ -81,6 +81,6 @@ pub fn path(name: &str) -> std::path::PathBuf { // The cache directory might not exist, so create it let _ = std::fs::create_dir_all(&path_dir); - path_dir.join(format!("app.{name}")) + path_dir.join(format!("s.{name}")) } } diff --git a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs index 8695904758e..d4323ce40dd 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/mod.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/mod.rs @@ -1,7 +1,7 @@ #[cfg(target_os = "windows")] pub(crate) mod dpapi; -mod encrypted_memory_store; +pub(crate) mod encrypted_memory_store; mod secure_key; /// The secure memory store provides an ephemeral key-value store for sensitive data. diff --git a/apps/desktop/desktop_native/macos_provider/src/lib.rs b/apps/desktop/desktop_native/macos_provider/src/lib.rs index 789a56d3048..a5a134b0bfe 100644 --- a/apps/desktop/desktop_native/macos_provider/src/lib.rs +++ b/apps/desktop/desktop_native/macos_provider/src/lib.rs @@ -1,4 +1,5 @@ #![cfg(target_os = "macos")] +#![allow(clippy::disallowed_macros)] // uniffi macros trip up clippy's evaluation use std::{ collections::HashMap, @@ -96,7 +97,7 @@ impl MacOSProviderClient { response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())), }; - let path = desktop_core::ipc::path("autofill"); + let path = desktop_core::ipc::path("af"); let queue = client.response_callbacks_queue.clone(); diff --git a/apps/desktop/desktop_native/proxy/src/main.rs b/apps/desktop/desktop_native/proxy/src/main.rs index bad3a9deb54..c2c525b865a 100644 --- a/apps/desktop/desktop_native/proxy/src/main.rs +++ b/apps/desktop/desktop_native/proxy/src/main.rs @@ -68,7 +68,7 @@ async fn main() { #[cfg(target_os = "windows")] let should_foreground = windows::allow_foreground(); - let sock_path = desktop_core::ipc::path("bitwarden"); + let sock_path = desktop_core::ipc::path("bw"); let log_path = { let mut path = sock_path.clone(); diff --git a/apps/desktop/electron-builder.beta.json b/apps/desktop/electron-builder.beta.json index 5b792097623..630a956560d 100644 --- a/apps/desktop/electron-builder.beta.json +++ b/apps/desktop/electron-builder.beta.json @@ -36,6 +36,10 @@ { "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", "to": "desktop_proxy.exe" + }, + { + "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "to": "bitwarden_chromium_import_helper.exe" } ] }, diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index f7dcfb65044..eaa299db508 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": "36.9.3", + "electronVersion": "37.7.0", "generateUpdatesFilesForAllChannels": true, "publish": { "provider": "generic", @@ -96,6 +96,10 @@ { "from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe", "to": "desktop_proxy.exe" + }, + { + "from": "desktop_native/dist/bitwarden_chromium_import_helper.${platform}-${arch}.exe", + "to": "bitwarden_chromium_import_helper.exe" } ] }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 19ab9e783d4..23a3dbcac11 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.10.2", + "version": "2025.11.0", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 7666e9bef1b..abebdfa5fc3 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -67,6 +67,8 @@ import { DesktopSettingsService } from "../../platform/services/desktop-settings import { DesktopPremiumUpgradePromptService } from "../../services/desktop-premium-upgrade-prompt.service"; import { NativeMessagingManifestService } from "../services/native-messaging-manifest.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-settings", templateUrl: "settings.component.html", diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index a809a1b23a2..b6e86ba19ff 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; +import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/environment-selector/environment-selector.component"; import { authGuard, @@ -65,7 +66,7 @@ const routes: Routes = [ canActivate: [redirectGuard({ loggedIn: "/vault", loggedOut: "/login", locked: "/lock" })], }, { - path: "authentication-timeout", + path: AuthRoute.AuthenticationTimeout, component: AnonLayoutWrapperComponent, children: [ { @@ -81,7 +82,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "device-verification", + path: AuthRoute.NewDeviceVerification, component: AnonLayoutWrapperComponent, canActivate: [unauthGuardFn(), activeAuthGuard()], children: [{ path: "", component: NewDeviceVerificationComponent }], @@ -123,7 +124,7 @@ const routes: Routes = [ component: AnonLayoutWrapperComponent, children: [ { - path: "signup", + path: AuthRoute.SignUp, canActivate: [unauthGuardFn()], data: { pageIcon: RegistrationUserAddIcon, @@ -141,13 +142,13 @@ const routes: Routes = [ component: RegistrationStartSecondaryComponent, outlet: "secondary", data: { - loginRoute: "/login", + loginRoute: `/${AuthRoute.Login}`, } satisfies RegistrationStartSecondaryComponentData, }, ], }, { - path: "finish-signup", + path: AuthRoute.FinishSignUp, canActivate: [unauthGuardFn()], data: { pageIcon: LockIcon, @@ -160,7 +161,7 @@ const routes: Routes = [ ], }, { - path: "login", + path: AuthRoute.Login, canActivate: [maxAccountsGuardFn()], data: { pageTitle: { @@ -179,7 +180,7 @@ const routes: Routes = [ ], }, { - path: "login-initiated", + path: AuthRoute.LoginInitiated, canActivate: [tdeDecryptionRequiredGuard()], data: { pageIcon: DevicesIcon, @@ -187,7 +188,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginDecryptionOptionsComponent }], }, { - path: "sso", + path: AuthRoute.Sso, data: { pageIcon: VaultIcon, pageTitle: { @@ -207,7 +208,7 @@ const routes: Routes = [ ], }, { - path: "login-with-device", + path: AuthRoute.LoginWithDevice, data: { pageIcon: DevicesIcon, pageTitle: { @@ -227,7 +228,7 @@ const routes: Routes = [ ], }, { - path: "admin-approval-requested", + path: AuthRoute.AdminApprovalRequested, data: { pageIcon: DevicesIcon, pageTitle: { @@ -240,7 +241,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginViaAuthRequestComponent }], }, { - path: "hint", + path: AuthRoute.PasswordHint, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -278,7 +279,7 @@ const routes: Routes = [ ], }, { - path: "2fa", + path: AuthRoute.TwoFactor, canActivate: [unauthGuardFn(), TwoFactorAuthGuard], children: [ { @@ -295,7 +296,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "set-initial-password", + path: AuthRoute.SetInitialPassword, canActivate: [authGuard], component: SetInitialPasswordComponent, data: { @@ -304,7 +305,7 @@ const routes: Routes = [ } satisfies AnonLayoutWrapperData, }, { - path: "change-password", + path: AuthRoute.ChangePassword, component: ChangePasswordComponent, canActivate: [authGuard], data: { diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 7f7eddcfe95..4b6dcab0dff 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -91,6 +91,8 @@ const BroadcasterSubscriptionId = "AppComponent"; const IdleTimeout = 60000 * 10; // 10 minutes const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-root", styles: [], @@ -115,14 +117,26 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours standalone: false, }) export class AppComponent implements OnInit, OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("settings", { read: ViewContainerRef, static: true }) settingsRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("premium", { read: ViewContainerRef, static: true }) premiumRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("passwordHistory", { read: ViewContainerRef, static: true }) passwordHistoryRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("exportVault", { read: ViewContainerRef, static: true }) exportVaultModalRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("appGenerator", { read: ViewContainerRef, static: true }) generatorModalRef: ViewContainerRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("loginApproval", { read: ViewContainerRef, static: true }) loginApprovalModalRef: ViewContainerRef; diff --git a/apps/desktop/src/app/components/avatar.component.ts b/apps/desktop/src/app/components/avatar.component.ts index 1fba864686c..d17ebb5b942 100644 --- a/apps/desktop/src/app/components/avatar.component.ts +++ b/apps/desktop/src/app/components/avatar.component.ts @@ -5,20 +5,38 @@ import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-avatar", template: ``, standalone: false, }) export class AvatarComponent implements OnChanges, OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() size = 45; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() charCount = 2; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() fontSize = 20; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() dynamic = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() circle = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() color?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() id?: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() text?: string; private svgCharCount = 2; diff --git a/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts b/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts index 713dc07e803..5d3c777f333 100644 --- a/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts +++ b/apps/desktop/src/app/components/browser-sync-verification-dialog.component.ts @@ -7,6 +7,8 @@ export type BrowserSyncVerificationDialogParams = { fingerprint: string[]; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "browser-sync-verification-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], diff --git a/apps/desktop/src/app/components/user-verification.component.ts b/apps/desktop/src/app/components/user-verification.component.ts index 31d38b10183..e19916c3d6b 100644 --- a/apps/desktop/src/app/components/user-verification.component.ts +++ b/apps/desktop/src/app/components/user-verification.component.ts @@ -11,6 +11,8 @@ import { FormFieldModule } from "@bitwarden/components"; * @deprecated Jan 24, 2024: Use new libs/auth UserVerificationDialogComponent or UserVerificationFormInputComponent instead. * Each client specific component should eventually be converted over to use one of these new components. */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-user-verification", imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, FormsModule], diff --git a/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts b/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts index 72284d007b6..14c2b137d73 100644 --- a/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts +++ b/apps/desktop/src/app/components/verify-native-messaging-dialog.component.ts @@ -7,6 +7,8 @@ export type VerifyNativeMessagingDialogData = { applicationName: string; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "verify-native-messaging-dialog.component.html", imports: [JslibModule, ButtonModule, DialogModule], diff --git a/apps/desktop/src/app/layout/account-switcher.component.ts b/apps/desktop/src/app/layout/account-switcher.component.ts index a54674c3a1e..6a7e274ade4 100644 --- a/apps/desktop/src/app/layout/account-switcher.component.ts +++ b/apps/desktop/src/app/layout/account-switcher.component.ts @@ -31,6 +31,8 @@ type InactiveAccount = ActiveAccount & { authenticationStatus: AuthenticationStatus; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-account-switcher", templateUrl: "account-switcher.component.html", diff --git a/apps/desktop/src/app/layout/header.component.ts b/apps/desktop/src/app/layout/header.component.ts index 9aef093423f..9630e3b1914 100644 --- a/apps/desktop/src/app/layout/header.component.ts +++ b/apps/desktop/src/app/layout/header.component.ts @@ -1,5 +1,7 @@ import { Component } from "@angular/core"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-header", templateUrl: "header.component.html", diff --git a/apps/desktop/src/app/layout/nav.component.ts b/apps/desktop/src/app/layout/nav.component.ts index bcc2b57fb17..72064a4de51 100644 --- a/apps/desktop/src/app/layout/nav.component.ts +++ b/apps/desktop/src/app/layout/nav.component.ts @@ -4,6 +4,8 @@ import { RouterLink, RouterLinkActive } from "@angular/router"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-nav", templateUrl: "nav.component.html", diff --git a/apps/desktop/src/app/layout/search/search.component.ts b/apps/desktop/src/app/layout/search/search.component.ts index 70196d74dda..c0b088a13d9 100644 --- a/apps/desktop/src/app/layout/search/search.component.ts +++ b/apps/desktop/src/app/layout/search/search.component.ts @@ -8,6 +8,8 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { SearchBarService, SearchBarState } from "./search-bar.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-search", templateUrl: "search.component.html", diff --git a/apps/desktop/src/app/tools/send/add-edit.component.ts b/apps/desktop/src/app/tools/send/add-edit.component.ts index b817adda848..076b0f6c9d5 100644 --- a/apps/desktop/src/app/tools/send/add-edit.component.ts +++ b/apps/desktop/src/app/tools/send/add-edit.component.ts @@ -19,14 +19,23 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CalloutModule, DialogService, ToastService } from "@bitwarden/components"; +import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-send-add-edit", templateUrl: "add-edit.component.html", imports: [CommonModule, JslibModule, ReactiveFormsModule, CalloutModule], + providers: [ + { + provide: PremiumUpgradePromptService, + useClass: DesktopPremiumUpgradePromptService, + }, + ], }) export class AddEditComponent extends BaseAddEditComponent { constructor( @@ -45,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent { billingAccountProfileStateService: BillingAccountProfileStateService, accountService: AccountService, toastService: ToastService, + premiumUpgradePromptService: PremiumUpgradePromptService, ) { super( i18nService, @@ -62,6 +72,7 @@ export class AddEditComponent extends BaseAddEditComponent { billingAccountProfileStateService, accountService, toastService, + premiumUpgradePromptService, ); } diff --git a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts index 0fe3d7e95e7..04a2f389781 100644 --- a/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/desktop.biometrics.service.ts @@ -18,4 +18,7 @@ export abstract class DesktopBiometricsService extends BiometricsService { /* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */ abstract enableWindowsV2Biometrics(): Promise; abstract isWindowsV2BiometricsEnabled(): Promise; + /* Enables the v2 biometrics re-write. This will stay enabled until the application is restarted. */ + abstract enableLinuxV2Biometrics(): Promise; + abstract isLinuxV2BiometricsEnabled(): Promise; } diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts index 24bb5495da0..db7c7c8f7fa 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics-ipc.listener.ts @@ -62,6 +62,10 @@ export class MainBiometricsIPCListener { return await this.biometricService.enableWindowsV2Biometrics(); case BiometricAction.IsWindowsV2Enabled: return await this.biometricService.isWindowsV2BiometricsEnabled(); + case BiometricAction.EnableLinuxV2: + return await this.biometricService.enableLinuxV2Biometrics(); + case BiometricAction.IsLinuxV2Enabled: + return await this.biometricService.isLinuxV2BiometricsEnabled(); default: return; } diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts index d1aff17646a..da532828314 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.ts @@ -10,13 +10,14 @@ import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-manageme import { WindowMain } from "../../main/window.main"; import { DesktopBiometricsService } from "./desktop.biometrics.service"; -import { WindowsBiometricsSystem } from "./native-v2"; +import { LinuxBiometricsSystem, WindowsBiometricsSystem } from "./native-v2"; import { OsBiometricService } from "./os-biometrics.service"; export class MainBiometricsService extends DesktopBiometricsService { private osBiometricsService: OsBiometricService; private shouldAutoPrompt = true; private windowsV2BiometricsEnabled = false; + private linuxV2BiometricsEnabled = false; constructor( private i18nService: I18nService, @@ -170,4 +171,16 @@ export class MainBiometricsService extends DesktopBiometricsService { async isWindowsV2BiometricsEnabled(): Promise { return this.windowsV2BiometricsEnabled; } + + async enableLinuxV2Biometrics(): Promise { + if (this.platform === "linux" && !this.linuxV2BiometricsEnabled) { + this.logService.info("[BiometricsMain] Loading native biometrics module v2 for linux"); + this.osBiometricsService = new LinuxBiometricsSystem(); + this.linuxV2BiometricsEnabled = true; + } + } + + async isLinuxV2BiometricsEnabled(): Promise { + return this.linuxV2BiometricsEnabled; + } } diff --git a/apps/desktop/src/key-management/biometrics/native-v2/index.ts b/apps/desktop/src/key-management/biometrics/native-v2/index.ts index 030224bbd74..94de850b759 100644 --- a/apps/desktop/src/key-management/biometrics/native-v2/index.ts +++ b/apps/desktop/src/key-management/biometrics/native-v2/index.ts @@ -1 +1,2 @@ export { default as WindowsBiometricsSystem } from "./os-biometrics-windows.service"; +export { default as LinuxBiometricsSystem } from "./os-biometrics-linux.service"; diff --git a/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.spec.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.spec.ts new file mode 100644 index 00000000000..91e2caba0cb --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.spec.ts @@ -0,0 +1,96 @@ +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { biometrics_v2, passwords } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; + +import OsBiometricsServiceLinux from "./os-biometrics-linux.service"; + +jest.mock("@bitwarden/desktop-napi", () => ({ + biometrics_v2: { + initBiometricSystem: jest.fn(() => "mockSystem"), + provideKey: jest.fn(), + unenroll: jest.fn(), + unlock: jest.fn(), + authenticate: jest.fn(), + authenticateAvailable: jest.fn(), + unlockAvailable: jest.fn(), + }, + passwords: { + isAvailable: jest.fn(), + }, +})); + +const mockKey = new Uint8Array(64); + +jest.mock("../../../utils", () => ({ + isFlatpak: jest.fn(() => false), + isLinux: jest.fn(() => true), + isSnapStore: jest.fn(() => false), +})); + +describe("OsBiometricsServiceLinux", () => { + const userId = "user-id" as UserId; + const key = { toEncoded: () => ({ buffer: Buffer.from(mockKey) }) } as SymmetricCryptoKey; + let service: OsBiometricsServiceLinux; + + beforeEach(() => { + service = new OsBiometricsServiceLinux(); + jest.clearAllMocks(); + }); + + it("should set biometric key", async () => { + await service.setBiometricKey(userId, key); + expect(biometrics_v2.provideKey).toHaveBeenCalled(); + }); + + it("should delete biometric key", async () => { + await service.deleteBiometricKey(userId); + expect(biometrics_v2.unenroll).toHaveBeenCalled(); + }); + + it("should get biometric key", async () => { + (biometrics_v2.unlock as jest.Mock).mockResolvedValue(mockKey); + const result = await service.getBiometricKey(userId); + expect(result).toBeInstanceOf(SymmetricCryptoKey); + }); + + it("should return null if no biometric key", async () => { + (biometrics_v2.unlock as jest.Mock).mockResolvedValue(null); + const result = await service.getBiometricKey(userId); + expect(result).toBeNull(); + }); + + it("should authenticate biometric", async () => { + (biometrics_v2.authenticate as jest.Mock).mockResolvedValue(true); + const result = await service.authenticateBiometric(); + expect(result).toBe(true); + }); + + it("should check if biometrics is supported", async () => { + (passwords.isAvailable as jest.Mock).mockResolvedValue(true); + const result = await service.supportsBiometrics(); + expect(result).toBe(true); + }); + + it("should check if setup is needed", async () => { + (biometrics_v2.authenticateAvailable as jest.Mock).mockResolvedValue(false); + const result = await service.needsSetup(); + expect(result).toBe(true); + }); + + it("should check if can auto setup", async () => { + const result = await service.canAutoSetup(); + expect(result).toBe(true); + }); + + it("should get biometrics first unlock status for user", async () => { + (biometrics_v2.unlockAvailable as jest.Mock).mockResolvedValue(true); + const result = await service.getBiometricsFirstUnlockStatusForUser(userId); + expect(result).toBe(BiometricsStatus.Available); + }); + + it("should return false for hasPersistentKey", async () => { + const result = await service.hasPersistentKey(userId); + expect(result).toBe(false); + }); +}); diff --git a/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.ts b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.ts new file mode 100644 index 00000000000..110db23ec79 --- /dev/null +++ b/apps/desktop/src/key-management/biometrics/native-v2/os-biometrics-linux.service.ts @@ -0,0 +1,118 @@ +import { spawn } from "child_process"; + +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; +import { biometrics_v2, passwords } from "@bitwarden/desktop-napi"; +import { BiometricsStatus } from "@bitwarden/key-management"; + +import { isSnapStore, isFlatpak, isLinux } from "../../../utils"; +import { OsBiometricService } from "../os-biometrics.service"; + +const polkitPolicy = ` + + + + + Unlock Bitwarden + Authenticate to unlock Bitwarden + + no + no + auth_self + + +`; +const policyFileName = "com.bitwarden.Bitwarden.policy"; +const policyPath = "/usr/share/polkit-1/actions/"; + +export default class OsBiometricsServiceLinux implements OsBiometricService { + private biometricsSystem: biometrics_v2.BiometricLockSystem; + + constructor() { + this.biometricsSystem = biometrics_v2.initBiometricSystem(); + } + + async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise { + await biometrics_v2.provideKey( + this.biometricsSystem, + userId, + Buffer.from(key.toEncoded().buffer), + ); + } + + async deleteBiometricKey(userId: UserId): Promise { + await biometrics_v2.unenroll(this.biometricsSystem, userId); + } + + async getBiometricKey(userId: UserId): Promise { + const result = await biometrics_v2.unlock(this.biometricsSystem, userId, Buffer.from("")); + return result ? new SymmetricCryptoKey(Uint8Array.from(result)) : null; + } + + async authenticateBiometric(): Promise { + return await biometrics_v2.authenticate( + this.biometricsSystem, + Buffer.from(""), + "Authenticate to unlock", + ); + } + + async supportsBiometrics(): Promise { + // We assume all linux distros have some polkit implementation + // that either has bitwarden set up or not, which is reflected in osBiomtricsNeedsSetup. + // Snap does not have access at the moment to polkit + // This could be dynamically detected on dbus in the future. + // We should check if a libsecret implementation is available on the system + // because otherwise we cannot offlod the protected userkey to secure storage. + return await passwords.isAvailable(); + } + + async needsSetup(): Promise { + if (isSnapStore()) { + return false; + } + + // check whether the polkit policy is loaded via dbus call to polkit + return !(await biometrics_v2.authenticateAvailable(this.biometricsSystem)); + } + + async canAutoSetup(): Promise { + // We cannot auto setup on snap or flatpak since the filesystem is sandboxed. + // The user needs to manually set up the polkit policy outside of the sandbox + // since we allow access to polkit via dbus for the sandboxed clients, the authentication works from + // the sandbox, once the policy is set up outside of the sandbox. + return isLinux() && !isSnapStore() && !isFlatpak(); + } + + async runSetup(): Promise { + const process = spawn("pkexec", [ + "bash", + "-c", + `echo '${polkitPolicy}' > ${policyPath + policyFileName} && chown root:root ${policyPath + policyFileName} && chcon system_u:object_r:usr_t:s0 ${policyPath + policyFileName}`, + ]); + + await new Promise((resolve, reject) => { + process.on("close", (code) => { + if (code !== 0) { + reject("Failed to set up polkit policy"); + } else { + resolve(null); + } + }); + }); + } + + async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise { + return (await biometrics_v2.unlockAvailable(this.biometricsSystem, userId)) + ? BiometricsStatus.Available + : BiometricsStatus.UnlockNeeded; + } + + async enrollPersistent(userId: UserId, key: SymmetricCryptoKey): Promise {} + + async hasPersistentKey(userId: UserId): Promise { + return false; + } +} diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts index bc3631ad1b8..63d2225b7c6 100644 --- a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts @@ -84,4 +84,12 @@ export class RendererBiometricsService extends DesktopBiometricsService { async isWindowsV2BiometricsEnabled(): Promise { return await ipc.keyManagement.biometric.isWindowsV2BiometricsEnabled(); } + + async enableLinuxV2Biometrics(): Promise { + return await ipc.keyManagement.biometric.enableLinuxV2Biometrics(); + } + + async isLinuxV2BiometricsEnabled(): Promise { + return await ipc.keyManagement.biometric.isLinuxV2BiometricsEnabled(); + } } diff --git a/apps/desktop/src/key-management/preload.ts b/apps/desktop/src/key-management/preload.ts index a9565790b86..d317b1f6ce0 100644 --- a/apps/desktop/src/key-management/preload.ts +++ b/apps/desktop/src/key-management/preload.ts @@ -69,6 +69,14 @@ const biometric = { ipcRenderer.invoke("biometric", { action: BiometricAction.IsWindowsV2Enabled, } satisfies BiometricMessage), + enableLinuxV2Biometrics: (): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.EnableLinuxV2, + } satisfies BiometricMessage), + isLinuxV2BiometricsEnabled: (): Promise => + ipcRenderer.invoke("biometric", { + action: BiometricAction.IsLinuxV2Enabled, + } satisfies BiometricMessage), }; export default { diff --git a/apps/desktop/src/locales/af/messages.json b/apps/desktop/src/locales/af/messages.json index 0701eb833da..0a3cbd229cd 100644 --- a/apps/desktop/src/locales/af/messages.json +++ b/apps/desktop/src/locales/af/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Dien In" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/ar/messages.json b/apps/desktop/src/locales/ar/messages.json index 7e7f9faf5fe..7636c30576b 100644 --- a/apps/desktop/src/locales/ar/messages.json +++ b/apps/desktop/src/locales/ar/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "استخدام تسجيل دخول واحد" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "إرسال" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/az/messages.json b/apps/desktop/src/locales/az/messages.json index 4289d577aac..37761036c3e 100644 --- a/apps/desktop/src/locales/az/messages.json +++ b/apps/desktop/src/locales/az/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Vahid daxil olma üsulunu istifadə et" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Göndər" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Arxivlənmiş elementlər ümumi axtarış nəticələrindən və avto-doldurma təkliflərindən xaric ediləcək. Bu elementi arxivləmək istədiyinizə əminsiniz?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Poçt kodu" + }, + "cardNumberLabel": { + "message": "Kart nömrəsi" } } diff --git a/apps/desktop/src/locales/be/messages.json b/apps/desktop/src/locales/be/messages.json index 1f2ac683790..2e5a58e0e24 100644 --- a/apps/desktop/src/locales/be/messages.json +++ b/apps/desktop/src/locales/be/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Адправіць" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/bg/messages.json b/apps/desktop/src/locales/bg/messages.json index 995d992db3e..03b6c4d5090 100644 --- a/apps/desktop/src/locales/bg/messages.json +++ b/apps/desktop/src/locales/bg/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Използване на еднократна идентификация" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Подаване" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Архивираните елементи са изключени от общите резултати при търсене и от предложенията за автоматично попълване. Наистина ли искате да архивирате този елемент?" + }, + "zipPostalCodeLabel": { + "message": "Пощенски код" + }, + "cardNumberLabel": { + "message": "Номер на картата" } } diff --git a/apps/desktop/src/locales/bn/messages.json b/apps/desktop/src/locales/bn/messages.json index 7a64fec30da..e47df9a26cb 100644 --- a/apps/desktop/src/locales/bn/messages.json +++ b/apps/desktop/src/locales/bn/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "জমা দিন" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/bs/messages.json b/apps/desktop/src/locales/bs/messages.json index 150b579b09d..5b453c176dc 100644 --- a/apps/desktop/src/locales/bs/messages.json +++ b/apps/desktop/src/locales/bs/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Potvrdi" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index dec07e3efe0..bb3dc27d957 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Usa inici de sessió únic" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Envia" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/cs/messages.json b/apps/desktop/src/locales/cs/messages.json index 50b9cdf8844..f20911eceb7 100644 --- a/apps/desktop/src/locales/cs/messages.json +++ b/apps/desktop/src/locales/cs/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Použít jednotné přihlášení" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Odeslat" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archivované položky jsou vyloučeny z obecných výsledků vyhledávání a z návrhů automatického vyplňování. Jste si jisti, že chcete tuto položku archivovat?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / PSČ" + }, + "cardNumberLabel": { + "message": "Číslo karty" } } diff --git a/apps/desktop/src/locales/cy/messages.json b/apps/desktop/src/locales/cy/messages.json index 03a29097352..af0a7029865 100644 --- a/apps/desktop/src/locales/cy/messages.json +++ b/apps/desktop/src/locales/cy/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/da/messages.json b/apps/desktop/src/locales/da/messages.json index a5a45db979a..b1e1ad6d201 100644 --- a/apps/desktop/src/locales/da/messages.json +++ b/apps/desktop/src/locales/da/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Brug Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Indsend" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 002ef104b96..580c9f42313 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Single Sign-on verwenden" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Absenden" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archivierte Einträge werden von allgemeinen Suchergebnissen und Auto-Ausfüllen-Vorschlägen ausgeschlossen. Bist du sicher, dass du diesen Eintrag archivieren möchtest?" + }, + "zipPostalCodeLabel": { + "message": "PLZ / Postleitzahl" + }, + "cardNumberLabel": { + "message": "Kartennummer" } } diff --git a/apps/desktop/src/locales/el/messages.json b/apps/desktop/src/locales/el/messages.json index 6d381b8fa66..232c8448d98 100644 --- a/apps/desktop/src/locales/el/messages.json +++ b/apps/desktop/src/locales/el/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Χρήση single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Υποβολή" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 32545a0c1cd..e2032bf27b1 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -1032,6 +1035,9 @@ "selfHostedEnvFormInvalid": { "message": "You must add either the base Server URL or at least one custom environment." }, + "selfHostedEnvMustUseHttps": { + "message": "URLs must use HTTPS." + }, "customEnvironment": { "message": "Custom environment" }, @@ -4187,5 +4193,29 @@ }, "cardNumberLabel": { "message": "Card number" + }, + "upgradeNow": { + "message": "Upgrade now" + }, + "builtInAuthenticator": { + "message": "Built-in authenticator" + }, + "secureFileStorage": { + "message": "Secure file storage" + }, + "emergencyAccess": { + "message": "Emergency access" + }, + "breachMonitoring": { + "message": "Breach monitoring" + }, + "andMoreFeatures": { + "message": "And more!" + }, + "planDescPremium": { + "message": "Complete online security" + }, + "upgradeToPremium": { + "message": "Upgrade to Premium" } } diff --git a/apps/desktop/src/locales/en_GB/messages.json b/apps/desktop/src/locales/en_GB/messages.json index 6594b2812e3..a2c96c63f51 100644 --- a/apps/desktop/src/locales/en_GB/messages.json +++ b/apps/desktop/src/locales/en_GB/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/en_IN/messages.json b/apps/desktop/src/locales/en_IN/messages.json index 20745ccfaf1..f746504d8e7 100644 --- a/apps/desktop/src/locales/en_IN/messages.json +++ b/apps/desktop/src/locales/en_IN/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "PIN" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/eo/messages.json b/apps/desktop/src/locales/eo/messages.json index 14972f29f79..44fdd715cbd 100644 --- a/apps/desktop/src/locales/eo/messages.json +++ b/apps/desktop/src/locales/eo/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/es/messages.json b/apps/desktop/src/locales/es/messages.json index 2850044205f..0e9b137c2b1 100644 --- a/apps/desktop/src/locales/es/messages.json +++ b/apps/desktop/src/locales/es/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Usar inicio de sesión único" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Enviar" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/et/messages.json b/apps/desktop/src/locales/et/messages.json index 75395b451b6..3bf585f0351 100644 --- a/apps/desktop/src/locales/et/messages.json +++ b/apps/desktop/src/locales/et/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Kinnita" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/eu/messages.json b/apps/desktop/src/locales/eu/messages.json index 0f5ebaca284..a79964b304b 100644 --- a/apps/desktop/src/locales/eu/messages.json +++ b/apps/desktop/src/locales/eu/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Bidali" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/fa/messages.json b/apps/desktop/src/locales/fa/messages.json index f097a21b7b7..24e45b6cac0 100644 --- a/apps/desktop/src/locales/fa/messages.json +++ b/apps/desktop/src/locales/fa/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "استفاده از ورود تک مرحله‌ای" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "ثبت" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 725f1ebb7f2..c95934a1f36 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Käytä kertakirjautumista" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Jatka" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/fil/messages.json b/apps/desktop/src/locales/fil/messages.json index a23e6913c06..317f8808af7 100644 --- a/apps/desktop/src/locales/fil/messages.json +++ b/apps/desktop/src/locales/fil/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Isumite" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index 10885ea46f4..ddab6285e01 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Utiliser l'authentification unique" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Soumettre" }, @@ -1826,7 +1829,7 @@ "message": "Code PIN invalide." }, "tooManyInvalidPinEntryAttemptsLoggingOut": { - "message": "Trop de tentatives de saisie du code PIN incorrectes. Déconnexion." + "message": "Trop de tentatives de saisie du code NIP incorrectes. Déconnexion." }, "unlockWithWindowsHello": { "message": "Déverrouiller avec Windows Hello" @@ -1853,10 +1856,10 @@ "message": "Verrouiller avec le mot de passe principal au redémarrage" }, "requireMasterPasswordOrPinOnAppRestart": { - "message": "Require master password or PIN on app restart" + "message": "Exiger un mot de passe principal ou un code NIP au redémarrage de l'application" }, "requireMasterPasswordOnAppRestart": { - "message": "Require master password on app restart" + "message": "Exiger un mot de passe principal redémarrage de l'application" }, "deleteAccount": { "message": "Supprimer le compte" @@ -2446,10 +2449,10 @@ "message": "Essayez de nouveau" }, "verificationRequiredForActionSetPinToContinue": { - "message": "Vérification requise pour cette action. Définissez un code PIN pour continuer." + "message": "Vérification requise pour cette action. Définissez un code NIP pour continuer." }, "setPin": { - "message": "Définir un code PIN" + "message": "Définir un code NIP" }, "verifyWithBiometrics": { "message": "Vérifier par biométrie" @@ -2467,7 +2470,7 @@ "message": "Utiliser le mot de passe principal" }, "usePin": { - "message": "Utiliser le code PIN" + "message": "Utiliser le code NIP" }, "useBiometrics": { "message": "Utiliser la biométrie" @@ -2556,7 +2559,7 @@ } }, "vaultCustomTimeoutMinimum": { - "message": "Minimum custom timeout is 1 minute." + "message": "Le délai d'expiration personnalisé minimum est de 1 minute." }, "inviteAccepted": { "message": "Invitation acceptée" @@ -3583,7 +3586,7 @@ "message": "Code incorrect" }, "incorrectPin": { - "message": "Code PIN incorrect" + "message": "Code NIP incorrect" }, "multifactorAuthenticationFailed": { "message": "Authentification multifacteur échouée" @@ -4159,7 +4162,7 @@ "description": "Verb" }, "unArchive": { - "message": "Unarchive" + "message": "Désarchiver" }, "itemsInArchive": { "message": "Éléments dans l'archive" @@ -4171,15 +4174,21 @@ "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." }, "itemWasSentToArchive": { - "message": "Item was sent to archive" + "message": "L'élément a été envoyé à l'archive" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "L'élément a été désarchivé" }, "archiveItem": { "message": "Archiver l'élément" }, "archiveItemConfirmDesc": { "message": "Les éléments archivés sont exclus des résultats de recherche généraux et des suggestions de remplissage automatique. Êtes-vous sûr de vouloir archiver cet élément ?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Code postal" + }, + "cardNumberLabel": { + "message": "Numéro de carte" } } diff --git a/apps/desktop/src/locales/gl/messages.json b/apps/desktop/src/locales/gl/messages.json index 3073fef032a..c6856f3375a 100644 --- a/apps/desktop/src/locales/gl/messages.json +++ b/apps/desktop/src/locales/gl/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index fcbd038adf3..dc41911950b 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "השתמש בכניסה יחידה" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "שלח" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/hi/messages.json b/apps/desktop/src/locales/hi/messages.json index ca2b4cbced1..5a4895e20a1 100644 --- a/apps/desktop/src/locales/hi/messages.json +++ b/apps/desktop/src/locales/hi/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/hr/messages.json b/apps/desktop/src/locales/hr/messages.json index 129dd27b09a..fd2cc31685e 100644 --- a/apps/desktop/src/locales/hr/messages.json +++ b/apps/desktop/src/locales/hr/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Jedinstvena prijava (SSO)" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Pošalji" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Arhivirane stavke biti će izuzete iz rezultata općih pretraga i preporuka auto-ispune. Sigurno želiš arhivirati?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/hu/messages.json b/apps/desktop/src/locales/hu/messages.json index 8e06affda49..c0918cfba4b 100644 --- a/apps/desktop/src/locales/hu/messages.json +++ b/apps/desktop/src/locales/hu/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Egyszeri bejelentkezés használata" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Beküldés" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Az archivált elemek ki vannak zárva az általános keresési eredményekből és az automatikus kitöltési javaslatokból. Biztosan archiválni szeretnénk ezt az elemet?" + }, + "zipPostalCodeLabel": { + "message": "Irányítószám" + }, + "cardNumberLabel": { + "message": "Kártya szám" } } diff --git a/apps/desktop/src/locales/id/messages.json b/apps/desktop/src/locales/id/messages.json index 2aea4e5f1ab..f41c13de593 100644 --- a/apps/desktop/src/locales/id/messages.json +++ b/apps/desktop/src/locales/id/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Kirim" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/it/messages.json b/apps/desktop/src/locales/it/messages.json index c851dc2b298..39501439da9 100644 --- a/apps/desktop/src/locales/it/messages.json +++ b/apps/desktop/src/locales/it/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Usa il Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Invia" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/ja/messages.json b/apps/desktop/src/locales/ja/messages.json index 1b61929ac38..543ac4027f7 100644 --- a/apps/desktop/src/locales/ja/messages.json +++ b/apps/desktop/src/locales/ja/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "シングルサインオンを使用する" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "送信" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/ka/messages.json b/apps/desktop/src/locales/ka/messages.json index 769cc602815..fec326e1158 100644 --- a/apps/desktop/src/locales/ka/messages.json +++ b/apps/desktop/src/locales/ka/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "გადაცემა" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/km/messages.json b/apps/desktop/src/locales/km/messages.json index 3073fef032a..c6856f3375a 100644 --- a/apps/desktop/src/locales/km/messages.json +++ b/apps/desktop/src/locales/km/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/kn/messages.json b/apps/desktop/src/locales/kn/messages.json index e987d3d811b..c33d2ca3d4e 100644 --- a/apps/desktop/src/locales/kn/messages.json +++ b/apps/desktop/src/locales/kn/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "ಒಪ್ಪಿಸು" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/ko/messages.json b/apps/desktop/src/locales/ko/messages.json index 55da2761122..2c84d22640b 100644 --- a/apps/desktop/src/locales/ko/messages.json +++ b/apps/desktop/src/locales/ko/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Single sign-on(SSO) 사용" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "보내기" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/lt/messages.json b/apps/desktop/src/locales/lt/messages.json index 38971c8c675..f053f0806ca 100644 --- a/apps/desktop/src/locales/lt/messages.json +++ b/apps/desktop/src/locales/lt/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Išsaugoti" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/lv/messages.json b/apps/desktop/src/locales/lv/messages.json index 487991ddfa4..6fb441b1f7b 100644 --- a/apps/desktop/src/locales/lv/messages.json +++ b/apps/desktop/src/locales/lv/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Izmantot vienoto pieteikšanos" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Iesniegt" }, @@ -1378,7 +1381,7 @@ "message": "Valoda" }, "languageDesc": { - "message": "Mainīt lietotnes valodu. Ir nepieciešama pārsāknēšana." + "message": "Mainīt lietotnes valodu. Ir nepieciešama atkārtota palaišana." }, "theme": { "message": "Izskats" @@ -1414,7 +1417,7 @@ "message": "Pārsāknēt, lai atjauninātu" }, "restartToUpdateDesc": { - "message": "Versija $VERSION_NUM$ ir gatava uzstādīšanai. Ir jāpārsāknē lietotne, lai pabeigtu uzstādīšanu. Vai pārsāknēt un atjaunināt tagad?", + "message": "Versija $VERSION_NUM$ ir gatava uzstādīšanai. Lietotne ir jāpalaiž no jauna, lai pabeigtu uzstādīšanu. Vai palaist no jauna un atjaunināt tagad?", "placeholders": { "version_num": { "content": "$1", @@ -1853,10 +1856,10 @@ "message": "Aizslēgt ar galveno paroli pēc pārsāknēšanas" }, "requireMasterPasswordOrPinOnAppRestart": { - "message": "Pieprasīt galveno paroli vai PIN pēc lietotnes pārsāknēšanas" + "message": "Pieprasīt galveno paroli vai PIN pēc lietotnes atkārtotas palaišanas" }, "requireMasterPasswordOnAppRestart": { - "message": "Pieprasīt galveno paroli pēc lietotnes pārsāknēšanas" + "message": "Pieprasīt galveno paroli pēc lietotnes atkārtotas palaišanas" }, "deleteAccount": { "message": "Izdzēst kontu" @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Arhivētie vienumi netiek iekļauti vispārējās meklēšanas iznākumos un automātiskās aizpildes ieteikumos. Vai tiešām ahrivēt šo vienumu?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Pasta indekss" + }, + "cardNumberLabel": { + "message": "Kartes numurs" } } diff --git a/apps/desktop/src/locales/me/messages.json b/apps/desktop/src/locales/me/messages.json index 298d13ce2dd..4d1779b5cd0 100644 --- a/apps/desktop/src/locales/me/messages.json +++ b/apps/desktop/src/locales/me/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Podnesi" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/ml/messages.json b/apps/desktop/src/locales/ml/messages.json index 456ade5aec7..14b215bc346 100644 --- a/apps/desktop/src/locales/ml/messages.json +++ b/apps/desktop/src/locales/ml/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "സമർപ്പിക്കുക" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/mr/messages.json b/apps/desktop/src/locales/mr/messages.json index 3073fef032a..c6856f3375a 100644 --- a/apps/desktop/src/locales/mr/messages.json +++ b/apps/desktop/src/locales/mr/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/my/messages.json b/apps/desktop/src/locales/my/messages.json index 7d754800060..7bd62d93bac 100644 --- a/apps/desktop/src/locales/my/messages.json +++ b/apps/desktop/src/locales/my/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/nb/messages.json b/apps/desktop/src/locales/nb/messages.json index 7c70d751245..0047f866933 100644 --- a/apps/desktop/src/locales/nb/messages.json +++ b/apps/desktop/src/locales/nb/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Bruk singulær pålogging" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Send inn" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/ne/messages.json b/apps/desktop/src/locales/ne/messages.json index bf78d49ff23..1f7b8ab49e1 100644 --- a/apps/desktop/src/locales/ne/messages.json +++ b/apps/desktop/src/locales/ne/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/nl/messages.json b/apps/desktop/src/locales/nl/messages.json index fd568c9bbb4..28aaa851d42 100644 --- a/apps/desktop/src/locales/nl/messages.json +++ b/apps/desktop/src/locales/nl/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Single sign-on gebruiken" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Opslaan" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Gearchiveerde items worden uitgesloten van algemene zoekresultaten en automatische invulsuggesties. Weet je zeker dat je dit item wilt archiveren?" + }, + "zipPostalCodeLabel": { + "message": "Postcode" + }, + "cardNumberLabel": { + "message": "Kaartnummer" } } diff --git a/apps/desktop/src/locales/nn/messages.json b/apps/desktop/src/locales/nn/messages.json index bee0f8ed4fc..899ce7cc927 100644 --- a/apps/desktop/src/locales/nn/messages.json +++ b/apps/desktop/src/locales/nn/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Send inn" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/or/messages.json b/apps/desktop/src/locales/or/messages.json index 32c05bd53ff..1878cbc6a8b 100644 --- a/apps/desktop/src/locales/or/messages.json +++ b/apps/desktop/src/locales/or/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/pl/messages.json b/apps/desktop/src/locales/pl/messages.json index 08585674532..ef35183ccce 100644 --- a/apps/desktop/src/locales/pl/messages.json +++ b/apps/desktop/src/locales/pl/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Użyj logowania jednokrotnego" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Wyślij" }, @@ -1853,10 +1856,10 @@ "message": "Zablokuj hasłem głównym po uruchomieniu ponownym" }, "requireMasterPasswordOrPinOnAppRestart": { - "message": "Require master password or PIN on app restart" + "message": "Wymagaj hasła głównego lub PIN po uruchomieniu aplikacji" }, "requireMasterPasswordOnAppRestart": { - "message": "Require master password on app restart" + "message": "Wymagaj hasła głównego po uruchomieniu aplikacji" }, "deleteAccount": { "message": "Usuń konto" @@ -3810,7 +3813,7 @@ "message": "Potwierdź użycie klucza SSH" }, "agentForwardingWarningTitle": { - "message": "Warning: Agent Forwarding" + "message": "Ostrzeżenie: Przekazywanie agenta" }, "agentForwardingWarningText": { "message": "Żądanie pochodzi ze zdalnego zalogowanego urządzenia" @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Zarchiwizowane elementy są wykluczone z wyników wyszukiwania i sugestii autouzupełniania. Czy na pewno chcesz archiwizować element?" + }, + "zipPostalCodeLabel": { + "message": "Kod pocztowy" + }, + "cardNumberLabel": { + "message": "Numer karty" } } diff --git a/apps/desktop/src/locales/pt_BR/messages.json b/apps/desktop/src/locales/pt_BR/messages.json index 4e80920cbf9..5113e99cacf 100644 --- a/apps/desktop/src/locales/pt_BR/messages.json +++ b/apps/desktop/src/locales/pt_BR/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Usar autenticação única" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Enviar" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Itens arquivados são excluídos dos resultados da pesquisa geral e das sugestões de preenchimento automático. Tem certeza de que deseja arquivar este item?" + }, + "zipPostalCodeLabel": { + "message": "CEP / Código postal" + }, + "cardNumberLabel": { + "message": "Número do cartão" } } diff --git a/apps/desktop/src/locales/pt_PT/messages.json b/apps/desktop/src/locales/pt_PT/messages.json index 0bb2142ba5a..5fdf0e85cdc 100644 --- a/apps/desktop/src/locales/pt_PT/messages.json +++ b/apps/desktop/src/locales/pt_PT/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Utilizar início de sessão único" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "A sua organização exige o início de sessão único." + }, "submit": { "message": "Submeter" }, @@ -1292,7 +1295,7 @@ "message": "Na suspensão do sistema" }, "onLocked": { - "message": "No bloqueio do sistema" + "message": "Ao bloquear o sistema" }, "onRestart": { "message": "Ao reiniciar" @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Os itens arquivados são excluídos dos resultados gerais da pesquisa e das sugestões de preenchimento automático. Tem a certeza de que pretende arquivar este item?" + }, + "zipPostalCodeLabel": { + "message": "Código postal" + }, + "cardNumberLabel": { + "message": "Número do cartão" } } diff --git a/apps/desktop/src/locales/ro/messages.json b/apps/desktop/src/locales/ro/messages.json index dab5ed8112e..5a6f4b0276e 100644 --- a/apps/desktop/src/locales/ro/messages.json +++ b/apps/desktop/src/locales/ro/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Trimitere" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index 684adf61875..63837c98e5a 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Использовать единый вход" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Отправить" }, @@ -3870,7 +3873,7 @@ "message": "Изменить пароль, подверженный риску" }, "changeAtRiskPasswordAndAddWebsite": { - "message": "Этот логин находится под угрозой и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." + "message": "Этот логин подвержен риску и у него отсутствует веб-сайт. Добавьте веб-сайт и смените пароль для большей безопасности." }, "missingWebsite": { "message": "Отсутствует сайт" @@ -4120,16 +4123,16 @@ "message": "Bitwarden не проверяет местоположение ввода, поэтому, прежде чем использовать ярлык, убедитесь, что вы находитесь в нужном окне и поле." }, "typeShortcut": { - "message": "Type shortcut" + "message": "Введите сочетание клавиш" }, "editAutotypeShortcutDescription": { - "message": "Include one or two of the following modifiers: Ctrl, Alt, Win, or Shift, and a letter." + "message": "Включите один или два из следующих модификаторов: Ctrl, Alt, Win или Shift и букву." }, "invalidShortcut": { - "message": "Invalid shortcut" + "message": "Недопустимое сочетание клавиш" }, "moreBreadcrumbs": { - "message": "Больше хлебных крошек", + "message": "Дополнительная навигация", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." }, "next": { @@ -4142,7 +4145,7 @@ "message": "Подтвердить" }, "enableAutotypeShortcutPreview": { - "message": "Enable autotype shortcut (Feature Preview)" + "message": "Включить сочетание клавиш ввода (предварительная версия функции)" }, "enableAutotypeShortcutDescription": { "message": "Прежде чем использовать ярлык, убедитесь, что вы поставили курсор в нужное поле, чтобы избежать ввода данных в неправильное место." @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Архивированные элементы исключены из общих результатов поиска и предложений автозаполнения. Вы уверены, что хотите архивировать этот элемент?" + }, + "zipPostalCodeLabel": { + "message": "Почтовый индекс" + }, + "cardNumberLabel": { + "message": "Номер карты" } } diff --git a/apps/desktop/src/locales/si/messages.json b/apps/desktop/src/locales/si/messages.json index 397bbbe23c7..7f191bf2161 100644 --- a/apps/desktop/src/locales/si/messages.json +++ b/apps/desktop/src/locales/si/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index 66a42c52182..aaf45ac65b2 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -131,7 +131,7 @@ "message": "Spustiť" }, "copyValue": { - "message": "Skopírovať hodnotu", + "message": "Kopírovať hodnotu", "description": "Copy value to clipboard" }, "minimizeOnCopyToClipboard": { @@ -144,7 +144,7 @@ "message": "Prepnúť viditeľnosť" }, "toggleCollapse": { - "message": "Prepnúť zloženie", + "message": "Prepnúť zbalenie", "description": "Toggling an expand/collapse state." }, "cardholderName": { @@ -364,7 +364,7 @@ "message": "Krstné meno" }, "middleName": { - "message": "Druhé meno" + "message": "Stredné meno" }, "lastName": { "message": "Priezvisko" @@ -409,7 +409,7 @@ "message": "Upraviť" }, "authenticatorKeyTotp": { - "message": "Kľúč overovateľa (TOTP)" + "message": "Overovací kľúč (TOTP)" }, "authenticatorKey": { "message": "Overovací kľúč" @@ -514,10 +514,10 @@ "message": "Meno je povinné." }, "addedItem": { - "message": "Položka pridaná" + "message": "Položka bola pridaná" }, "editedItem": { - "message": "Položka upravená" + "message": "Položka bola uložená" }, "deleteItem": { "message": "Odstrániť položku" @@ -557,7 +557,7 @@ "message": "Vygenerovať nové heslo" }, "copyPassword": { - "message": "Skopírovať heslo" + "message": "Kopírovať heslo" }, "regenerateSshKey": { "message": "Generovať nový kľúč SSH" @@ -659,10 +659,10 @@ "message": "Zavrieť" }, "minNumbers": { - "message": "Minimálny počet číslic" + "message": "Minimum číslic" }, "minSpecial": { - "message": "Minimum špeciálnych", + "message": "Minimum špeciálnych znakov", "description": "Minimum Special Characters" }, "ambiguous": { @@ -687,20 +687,20 @@ "message": "Hľadať v obľúbených" }, "searchType": { - "message": "Search type", + "message": "Typ vyhľadávania", "description": "Search item type" }, "newAttachment": { "message": "Pridať novú prílohu" }, "deletedAttachment": { - "message": "Príloha odstránená" + "message": "Príloha bola odstránená" }, "deleteAttachmentConfirmation": { "message": "Naozaj chcete odstrániť prílohu?" }, "attachmentSaved": { - "message": "Príloha bola uložená." + "message": "Príloha bola uložená" }, "addAttachment": { "message": "Priložiť prílohu" @@ -712,7 +712,7 @@ "message": "Súbor" }, "selectFile": { - "message": "Vybrať súbor." + "message": "Vyberte súbor" }, "maxFileSize": { "message": "Maximálna veľkosť súboru je 500 MB." @@ -721,16 +721,16 @@ "message": "Staršie šifrovanie už nie je podporované. Ak chcete obnoviť svoj účet, obráťte sa na podporu." }, "editedFolder": { - "message": "Priečinok upravený" + "message": "Priečinok bol upravený" }, "addedFolder": { - "message": "Priečinok pridaný" + "message": "Priečinok bol pridaný" }, "deleteFolderConfirmation": { "message": "Naozaj chcete odstrániť tento priečinok?" }, "deletedFolder": { - "message": "Priečinok odstránený" + "message": "Priečinok bol odstránený" }, "loginOrCreateNewAccount": { "message": "Prihláste sa alebo si vytvorte nový účet, aby ste mohli pristupovať k vášmu bezpečnému trezoru." @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Použiť jednotné prihlásenie" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Potvrdiť" }, @@ -955,7 +958,7 @@ "description": "Select another two-step login method" }, "useYourRecoveryCode": { - "message": "Použiť obnovovací kód" + "message": "Použiť kód na obnovenie" }, "insertU2f": { "message": "Vložte váš bezpečnostný kľúč do USB portu počítača. Ak má tlačidlo, stlačte ho." @@ -981,7 +984,7 @@ "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "duoOrganizationDesc": { - "message": "Overiť sa prostredníctvom Duo Security vašej organizácie použitím Duo Mobile aplikácie, SMS, telefonátu alebo U2F bezpečnostným kľúčom.", + "message": "Overenie pomocou Duo Security pre vašu organizáciu pomocou aplikácie Duo Mobile, SMS, telefonického hovoru alebo bezpečnostného kľúča U2F.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "verifyYourIdentity": { @@ -997,7 +1000,7 @@ "message": "FIDO2 WebAuthn" }, "webAuthnDesc": { - "message": "Použiť akýkoľvek WebAuthn bezpečnostný kľúč pre prístup k vášmu účtu." + "message": "Použite akýkoľvek kompatibilný bezpečnostný kľúč WebAuthn na prístup k svojmu účtu." }, "emailTitle": { "message": "E-mail" @@ -1006,7 +1009,7 @@ "message": "Zadajte kód zaslaný na váš e-mail." }, "loginUnavailable": { - "message": "Prihlásenie nedostupné" + "message": "Prihlásenie nie je dostupné" }, "noTwoStepProviders": { "message": "Tento účet má povolené dvojstupňové prihlásenie, ale žiadny z nakonfigurovaných poskytovateľov nie je podporovaný na tomto zariadení." @@ -1021,7 +1024,7 @@ "message": "Vyberte metódu dvojstupňového prihlásenia" }, "selfHostedEnvironment": { - "message": "Prevádzkované vo vlastnom prostredí" + "message": "Prostredie s vlastným hostingom" }, "selfHostedBaseUrlHint": { "message": "Zadajte základnú URL adresu lokálne hosťovanej inštalácie Bitwarden. Napríklad: https://bitwarden.company.com" @@ -1058,13 +1061,13 @@ "message": "URL servera identít" }, "notificationsUrl": { - "message": "URL adresa servera pre oznámenia" + "message": "URL servera pre upozornenia" }, "iconsUrl": { "message": "URL servera ikon" }, "environmentSaved": { - "message": "URL prostredia boli uložené." + "message": "URL adresy prostredia boli uložené" }, "ok": { "message": "Ok" @@ -1115,10 +1118,10 @@ "message": "Odhlásiť sa" }, "addNewLogin": { - "message": "Pridať nové prihlasovacie údaje" + "message": "Nové prihlasovacie údaje" }, "addNewItem": { - "message": "Pridať novú položku" + "message": "Nová položka" }, "view": { "message": "Zobraziť" @@ -1154,7 +1157,7 @@ "message": "Sledujte nás" }, "syncVault": { - "message": "Synchronizovať trezor teraz" + "message": "Synchronizovať trezor" }, "changeMasterPass": { "message": "Zmeniť hlavné heslo" @@ -1166,7 +1169,7 @@ "message": "Hlavné heslo si môžete zmeniť vo webovej aplikácii Bitwarden." }, "fingerprintPhrase": { - "message": "Fráza odtlačku", + "message": "Jedinečný identifikátor", "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." }, "yourAccountsFingerprint": { @@ -1177,13 +1180,13 @@ "message": "Prejsť do webového trezora" }, "getMobileApp": { - "message": "Získajte mobilnú aplikáciu" + "message": "Získať mobilnú aplikáciu" }, "getBrowserExtension": { "message": "Získať rozšírenie pre prehliadač" }, "syncingComplete": { - "message": "Synchronizácia dokončená" + "message": "Synchronizácia bola dokončená" }, "syncingFailed": { "message": "Synchronizácia zlyhala" @@ -1232,7 +1235,7 @@ } }, "twoStepLoginConfirmation": { - "message": "Dvojstupňové prihlasovanie robí váš účet bezpečnejším vďaka vyžadovaniu bezpečnostného kódu z overovacej aplikácie vždy, keď sa prihlásite. Dvojstupňové prihlasovanie môžete povoliť vo webovom trezore bitwarden.com. Chcete teraz navštíviť túto stránku?" + "message": "Dvojstupňové prihlásenie zvyšuje bezpečnosť vášho účtu tým, že vyžaduje overenie prihlásenia pomocou iného zariadenia, napríklad bezpečnostného kľúča, overovacej aplikácie, SMS, telefonického hovoru alebo e-mailu. Dvojstupňové prihlásenie môžete nastaviť na bitwarden.com. Chcete stránku navštíviť teraz?" }, "twoStepLogin": { "message": "Dvojstupňové prihlásenie" @@ -1339,7 +1342,7 @@ "message": "Namiesto zatvorenia okna zobraziť ikonu na paneli úloh." }, "enableTray": { - "message": "Povoliť ikonu na systémovej lište" + "message": "Povoliť ikonu na paneli úloh" }, "enableTrayDesc": { "message": "Vždy zobraziť ikonu na systémovej lište." @@ -1411,7 +1414,7 @@ } }, "restartToUpdate": { - "message": "Reštartovať pre dokončenie aktualizácie" + "message": "Reštartovať na dokončenie aktualizácie" }, "restartToUpdateDesc": { "message": "Verzia $VERSION_NUM$ je pripravená na inštaláciu. Je nutné reštartovať aplikáciu, aby sa inštalácia mohla dokončiť. Chcete ju reštartovať a aktualizovať teraz?", @@ -1476,7 +1479,7 @@ "message": "Momentálne nie ste prémiovým členom." }, "premiumSignUpAndGet": { - "message": "Zaregistrujte sa pre prémiové členstvo a získajte:" + "message": "Zaregistrujte sa na prémiové členstvo a získajte:" }, "premiumSignUpStorage": { "message": "1 GB šifrovaného úložiska." @@ -1518,7 +1521,7 @@ } }, "refreshComplete": { - "message": "Obnova kompletná" + "message": "Obnova bola dokončená" }, "passwordHistory": { "message": "História hesla" @@ -1563,7 +1566,7 @@ "description": "Paste from clipboard" }, "selectAll": { - "message": "Označiť všetko" + "message": "Vybrať Všetko" }, "zoomIn": { "message": "Priblížiť" @@ -1572,7 +1575,7 @@ "message": "Oddialiť" }, "resetZoom": { - "message": "Obnoviť pôvodné zobrazenie" + "message": "Obnoviť priblíženie" }, "toggleFullScreen": { "message": "Prepnúť na celú obrazovku" @@ -1581,7 +1584,7 @@ "message": "Znovu načítať" }, "toggleDevTools": { - "message": "Prepnúť vývojárske nástroje" + "message": "Prepnúť nástroje pre vývojárov" }, "minimize": { "message": "Minimalizovať", @@ -1591,7 +1594,7 @@ "message": "Priblíženie" }, "bringAllToFront": { - "message": "Preniesť všetko dopredu", + "message": "Presunúť všetko dopredu", "description": "Bring all windows to front (foreground)" }, "aboutBitwarden": { @@ -3819,10 +3822,10 @@ "message": "žiada o prístup k" }, "sshkeyApprovalMessageSuffix": { - "message": "in order to" + "message": "aby bolo možné" }, "sshActionLogin": { - "message": "authenticate to a server" + "message": "overenie na serveri" }, "sshActionSign": { "message": "podpísať správu" @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archivované položky sú vylúčené zo všeobecného vyhľadávania a z návrhov automatického vypĺňania. Naozaj chcete archivovať túto položku?" + }, + "zipPostalCodeLabel": { + "message": "PSČ" + }, + "cardNumberLabel": { + "message": "Číslo karty" } } diff --git a/apps/desktop/src/locales/sl/messages.json b/apps/desktop/src/locales/sl/messages.json index 597cb62b4ea..c2380687634 100644 --- a/apps/desktop/src/locales/sl/messages.json +++ b/apps/desktop/src/locales/sl/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Potrdi" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/sr/messages.json b/apps/desktop/src/locales/sr/messages.json index 20e55677171..1e94787f824 100644 --- a/apps/desktop/src/locales/sr/messages.json +++ b/apps/desktop/src/locales/sr/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Употребити једнократну пријаву" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Пошаљи" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Архивиране ставке су искључене из општих резултата претраге и предлога за ауто попуњавање. Јесте ли сигурни да желите да архивирате ову ставку?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index 575e5755441..6811a6b8583 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Använd Single Sign-On" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Skicka" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Arkiverade objekt är uteslutna från allmänna sökresultat och förslag för autofyll. Är du säker på att du vill arkivera detta objekt?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/ta/messages.json b/apps/desktop/src/locales/ta/messages.json index 0ee270c981b..5e6bccf2a6e 100644 --- a/apps/desktop/src/locales/ta/messages.json +++ b/apps/desktop/src/locales/ta/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "ஒற்றை உள்நுழையைப் பயன்படுத்து" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "சமர்ப்பி" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/te/messages.json b/apps/desktop/src/locales/te/messages.json index 3073fef032a..c6856f3375a 100644 --- a/apps/desktop/src/locales/te/messages.json +++ b/apps/desktop/src/locales/te/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Submit" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/th/messages.json b/apps/desktop/src/locales/th/messages.json index a2637894dc4..e2d4509ddf4 100644 --- a/apps/desktop/src/locales/th/messages.json +++ b/apps/desktop/src/locales/th/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Use single sign-on" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "ส่งข้อมูล" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/tr/messages.json b/apps/desktop/src/locales/tr/messages.json index 0d93d84fa2a..88ee8f4e635 100644 --- a/apps/desktop/src/locales/tr/messages.json +++ b/apps/desktop/src/locales/tr/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Çoklu oturum açma kullan" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Kuruluşunuz çoklu oturum açma gerektiriyor." + }, "submit": { "message": "Gönder" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / posta kodu" + }, + "cardNumberLabel": { + "message": "Kart numarası" } } diff --git a/apps/desktop/src/locales/uk/messages.json b/apps/desktop/src/locales/uk/messages.json index 577ce4a5d78..5c4583aa9b6 100644 --- a/apps/desktop/src/locales/uk/messages.json +++ b/apps/desktop/src/locales/uk/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Використати єдиний вхід" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Відправити" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/vi/messages.json b/apps/desktop/src/locales/vi/messages.json index f1341734453..dd7747dab9f 100644 --- a/apps/desktop/src/locales/vi/messages.json +++ b/apps/desktop/src/locales/vi/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "Dùng đăng nhập một lần" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "Gửi" }, @@ -1244,7 +1247,7 @@ "message": "Đóng kho sau" }, "vaultTimeout1": { - "message": "Quá hạn" + "message": "Thời gian chờ" }, "vaultTimeoutAction1": { "message": "Hành động sau khi đóng kho" @@ -1853,10 +1856,10 @@ "message": "Khóa bằng mật khẩu chính khi khởi động lại" }, "requireMasterPasswordOrPinOnAppRestart": { - "message": "Require master password or PIN on app restart" + "message": "Yêu cầu mật khẩu chính hoặc mã PIN khi khởi động lại ứng dụng" }, "requireMasterPasswordOnAppRestart": { - "message": "Require master password on app restart" + "message": "Yêu cầu mật khẩu chính khi khởi động lại ứng dụng" }, "deleteAccount": { "message": "Xóa tài khoản" @@ -4174,12 +4177,18 @@ "message": "Mục đã được chuyển vào kho lưu trữ" }, "itemWasUnarchived": { - "message": "Item was unarchived" + "message": "Mục đã được bỏ lưu trữ" }, "archiveItem": { "message": "Lưu trữ mục" }, "archiveItemConfirmDesc": { "message": "Các mục đã lưu trữ sẽ bị loại khỏi kết quả tìm kiếm chung và gợi ý tự động điền. Bạn có chắc chắn muốn lưu trữ mục này không?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / Postal code" + }, + "cardNumberLabel": { + "message": "Card number" } } diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 9a8aa724778..7a4e9f7bc7b 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "使用单点登录" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "提交" }, @@ -1238,13 +1241,13 @@ "message": "两步登录" }, "vaultTimeoutHeader": { - "message": "密码库超时时间" + "message": "密码库超时" }, "vaultTimeout": { - "message": "密码库超时时间" + "message": "密码库超时" }, "vaultTimeout1": { - "message": "超时" + "message": "超时时间" }, "vaultTimeoutAction1": { "message": "超时动作" @@ -1964,7 +1967,7 @@ "message": "永久删除" }, "vaultTimeoutLogOutConfirmation": { - "message": "超时后注销将解除对密码库的所有访问权限,并需要进行在线身份验证。确定使用此设置吗?" + "message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定要使用此设置吗?" }, "vaultTimeoutLogOutConfirmationTitle": { "message": "超时动作确认" @@ -2431,7 +2434,7 @@ "message": "您的主密码不符合某一项或多项组织策略要求。要访问密码库,必须立即更新您的主密码。继续操作将使您退出当前会话,并要求您重新登录。其他设备上的活动会话可能会继续保持活动状态长达一小时。" }, "changePasswordWarning": { - "message": "更改密码后,您需要使用新密码登录。 在其他设备上的活动会话将在一小时内注销。" + "message": "更改密码后,您需要使用新密码登录。在其他设备上的活动会话将在一小时内注销。" }, "accountRecoveryUpdateMasterPasswordSubtitle": { "message": "更改您的主密码以完成账户恢复。" @@ -2498,7 +2501,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "您的组织策略正在影响您的密码库超时时间。最大允许的密码库超时时间是 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", + "message": "您的组织策略正在影响您的密码库超时。最大允许的密码库超时为 $HOURS$ 小时 $MINUTES$ 分钟。您的密码库超时动作被设置为 $ACTION$。", "placeholders": { "hours": { "content": "$1", @@ -2515,7 +2518,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "您的组织策略已将您的密码库超时动作设置为 $ACTION$。", + "message": "您的组织策略已将您的密码库超时动作设置为「$ACTION$」。", "placeholders": { "action": { "content": "$1", @@ -2524,13 +2527,13 @@ } }, "vaultTimeoutTooLarge": { - "message": "您的密码库超时时间超出了组织设置的限制。" + "message": "您的密码库超时超出了您组织设置的限制。" }, "vaultTimeoutPolicyAffectingOptions": { "message": "企业策略要求已应用到您的超时选项中" }, "vaultTimeoutPolicyInEffect": { - "message": "您的组织策略已将您最大允许的密码库超时时间设置为 $HOURS$ 小时 $MINUTES$ 分钟。", + "message": "您的组织策略已将您最大允许的密码库超时设置为 $HOURS$ 小时 $MINUTES$ 分钟。", "placeholders": { "hours": { "content": "$1", @@ -2543,7 +2546,7 @@ } }, "vaultTimeoutPolicyMaximumError": { - "message": "超时时间超出了您组织设置的限制:最多 $HOURS$ 小时 $MINUTES$ 分钟", + "message": "超时超出了您组织设置的限制:最多 $HOURS$ 小时 $MINUTES$ 分钟", "placeholders": { "hours": { "content": "$1", @@ -2556,7 +2559,7 @@ } }, "vaultCustomTimeoutMinimum": { - "message": "自定义超时时间最小为 1 分钟。" + "message": "自定义超时最少为 1 分钟。" }, "inviteAccepted": { "message": "邀请已接受" @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "已归档的项目将被排除在一般搜索结果和自动填充建议之外。确定要归档此项目吗?" + }, + "zipPostalCodeLabel": { + "message": "ZIP / 邮政编码" + }, + "cardNumberLabel": { + "message": "卡号" } } diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index 9b29eb12a2d..78a4f950f40 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -771,6 +771,9 @@ "useSingleSignOn": { "message": "使用單一登入" }, + "yourOrganizationRequiresSingleSignOn": { + "message": "Your organization requires single sign-on." + }, "submit": { "message": "送出" }, @@ -4181,5 +4184,11 @@ }, "archiveItemConfirmDesc": { "message": "封存的項目將不會出現在一般搜尋結果或自動填入建議中。確定要封存此項目嗎?" + }, + "zipPostalCodeLabel": { + "message": "郵編 / 郵政代碼" + }, + "cardNumberLabel": { + "message": "支付卡號碼" } } diff --git a/apps/desktop/src/main/native-messaging.main.ts b/apps/desktop/src/main/native-messaging.main.ts index 93525164ff5..ba5d8616752 100644 --- a/apps/desktop/src/main/native-messaging.main.ts +++ b/apps/desktop/src/main/native-messaging.main.ts @@ -78,7 +78,7 @@ export class NativeMessagingMain { this.ipcServer.stop(); } - this.ipcServer = await ipc.IpcServer.listen("bitwarden", (error, msg) => { + this.ipcServer = await ipc.IpcServer.listen("bw", (error, msg) => { switch (msg.kind) { case ipc.IpcMessageType.Connected: { this.connected.push(msg.clientId); diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 88be6ebd4f5..512f8c638ef 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.10.2", + "version": "2025.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.10.2", + "version": "2025.11.0", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index d122978f943..a24bd703248 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.10.2", + "version": "2025.11.0", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts index f66eea180cf..71cfcab84ba 100644 --- a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts +++ b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts @@ -35,7 +35,7 @@ export class NativeAutofillMain { ); this.ipcServer = await autofill.IpcServer.listen( - "autofill", + "af", // RegistrationCallback (error, clientId, sequenceNumber, request) => { if (error) { diff --git a/apps/desktop/src/services/biometric-message-handler.service.ts b/apps/desktop/src/services/biometric-message-handler.service.ts index 6d07c4a2aa0..022ccffcc91 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.ts @@ -125,6 +125,11 @@ export class BiometricMessageHandlerService { if (windowsV2Enabled) { await this.biometricsService.enableWindowsV2Biometrics(); } + + const linuxV2Enabled = await this.configService.getFeatureFlag(FeatureFlag.LinuxBiometricsV2); + if (linuxV2Enabled) { + await this.biometricsService.enableLinuxV2Biometrics(); + } } async handleMessage(msg: LegacyMessageWrapper) { diff --git a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts index 3b33116ea5a..1eee4cd54f6 100644 --- a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts +++ b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts @@ -1,20 +1,31 @@ import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { DialogService } from "@bitwarden/components"; import { DesktopPremiumUpgradePromptService } from "./desktop-premium-upgrade-prompt.service"; describe("DesktopPremiumUpgradePromptService", () => { let service: DesktopPremiumUpgradePromptService; let messager: MockProxy; + let configService: MockProxy; + let dialogService: MockProxy; beforeEach(async () => { messager = mock(); + configService = mock(); + dialogService = mock(); + await TestBed.configureTestingModule({ providers: [ DesktopPremiumUpgradePromptService, { provide: MessagingService, useValue: messager }, + { provide: ConfigService, useValue: configService }, + { provide: DialogService, useValue: dialogService }, ], }).compileComponents(); @@ -22,9 +33,38 @@ describe("DesktopPremiumUpgradePromptService", () => { }); describe("promptForPremium", () => { - it("navigates to the premium update screen", async () => { + let openSpy: jest.SpyInstance; + + beforeEach(() => { + openSpy = jest.spyOn(PremiumUpgradeDialogComponent, "open").mockImplementation(); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + it("opens the new premium upgrade dialog when feature flag is enabled", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + expect(openSpy).toHaveBeenCalledWith(dialogService); + expect(messager.send).not.toHaveBeenCalled(); + }); + + it("sends openPremium message when feature flag is disabled", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + await service.promptForPremium(); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); expect(messager.send).toHaveBeenCalledWith("openPremium"); + expect(openSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts index f2375ecfebb..5004e5ed547 100644 --- a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts +++ b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts @@ -1,15 +1,29 @@ import { inject } from "@angular/core"; +import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { DialogService } from "@bitwarden/components"; /** * This class handles the premium upgrade process for the desktop. */ export class DesktopPremiumUpgradePromptService implements PremiumUpgradePromptService { private messagingService = inject(MessagingService); + private configService = inject(ConfigService); + private dialogService = inject(DialogService); async promptForPremium() { - this.messagingService.send("openPremium"); + const showNewDialog = await this.configService.getFeatureFlag( + FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog, + ); + + if (showNewDialog) { + PremiumUpgradeDialogComponent.open(this.dialogService); + } else { + this.messagingService.send("openPremium"); + } } } diff --git a/apps/desktop/src/types/biometric-message.ts b/apps/desktop/src/types/biometric-message.ts index a0a3967f468..62aa9fb1ce2 100644 --- a/apps/desktop/src/types/biometric-message.ts +++ b/apps/desktop/src/types/biometric-message.ts @@ -19,6 +19,9 @@ export enum BiometricAction { EnableWindowsV2 = "enableWindowsV2", IsWindowsV2Enabled = "isWindowsV2Enabled", + + EnableLinuxV2 = "enableLinuxV2", + IsLinuxV2Enabled = "isLinuxV2Enabled", } export type BiometricMessage = 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 19c9cffeeb2..bdade04bacd 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -792,7 +792,7 @@ export class VaultV2Component async cancelCipher(cipher: CipherView) { this.cipherId = cipher.id; this.cipher = cipher; - this.action = this.cipherId != null ? "view" : null; + this.action = this.cipherId ? "view" : null; await this.go().catch(() => {}); } diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js index bf65ae8d7cb..e67c0c38010 100644 --- a/apps/desktop/tailwind.config.js +++ b/apps/desktop/tailwind.config.js @@ -9,6 +9,7 @@ config.content = [ "../../libs/key-management-ui/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}", "../../libs/vault/src/**/*.{html,ts,mdx}", + "../../libs/pricing/src/**/*.{html,ts}", ]; module.exports = config; diff --git a/apps/web/package.json b/apps/web/package.json index 1052630acd0..ddcf1576743 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2025.10.1", + "version": "2025.11.0", "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/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index eb4e47e0ffd..f827dda9a9b 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 @@ -794,6 +794,9 @@ export class VaultComponent implements OnInit, OnDestroy { case "viewEvents": await this.viewEvents(event.item); break; + case "editCipher": + await this.editCipher(event.item); + break; } } finally { this.processingEvent$.next(false); @@ -856,7 +859,7 @@ export class VaultComponent implements OnInit, OnDestroy { * @param cipherView - When set, the cipher to be edited * @param cloneCipher - `true` when the cipher should be cloned. */ - async editCipher(cipher: CipherView | undefined, cloneCipher: boolean) { + async editCipher(cipher: CipherView | undefined, cloneCipher?: boolean) { if ( cipher && cipher.reprompt !== 0 && diff --git a/apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts b/apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts new file mode 100644 index 00000000000..5964601fbe7 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/guards/org-policy.guard.ts @@ -0,0 +1,70 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { firstValueFrom, Observable, switchMap, tap } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { ToastService } from "@bitwarden/components"; +import { UserId } from "@bitwarden/user-core"; + +/** + * This guard is intended to prevent members of an organization from accessing + * routes based on compliance with organization + * policies. e.g Emergency access, which is a non-organization + * feature is restricted by the Auto Confirm policy. + */ +export function organizationPolicyGuard( + featureCallback: ( + userId: UserId, + configService: ConfigService, + policyService: PolicyService, + ) => Observable, +): CanActivateFn { + return async () => { + const router = inject(Router); + const toastService = inject(ToastService); + const i18nService = inject(I18nService); + const accountService = inject(AccountService); + const policyService = inject(PolicyService); + const configService = inject(ConfigService); + const syncService = inject(SyncService); + + const synced = await firstValueFrom( + accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => syncService.lastSync$(userId)), + ), + ); + + if (synced == null) { + await syncService.fullSync(false); + } + + const compliant = await firstValueFrom( + accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => featureCallback(userId, configService, policyService)), + tap((compliant) => { + if (typeof compliant !== "boolean") { + throw new Error("Feature callback must return a boolean."); + } + }), + ), + ); + + if (!compliant) { + toastService.showToast({ + variant: "error", + message: i18nService.t("noPageAccess"), + }); + + return router.createUrlTree(["/"]); + } + + return compliant; + }; +} diff --git a/apps/web/src/app/admin-console/organizations/manage/events.component.ts b/apps/web/src/app/admin-console/organizations/manage/events.component.ts index 78a6d6c0dac..62f6539cc16 100644 --- a/apps/web/src/app/admin-console/organizations/manage/events.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/events.component.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, filter, firstValueFrom, lastValueFrom, map, switchMap, takeUntil } from "rxjs"; +import { concatMap, firstValueFrom, lastValueFrom, map, of, switchMap, takeUntil, tap } from "rxjs"; import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; @@ -143,17 +143,23 @@ export class EventsComponent extends BaseEventsComponent implements OnInit, OnDe getUserId, switchMap((userId) => this.providerService.get$(this.organization.providerId, userId)), map((provider) => provider != null && provider.canManageUsers), - filter((result) => result), - switchMap(() => this.apiService.getProviderUsers(this.organization.id)), - map((providerUsersResponse) => - providerUsersResponse.data.forEach((u) => { - const name = this.userNamePipe.transform(u); - this.orgUsersUserIdMap.set(u.userId, { - name: `${name} (${this.organization.providerName})`, - email: u.email, + switchMap((canManage) => { + if (canManage) { + return this.apiService.getProviderUsers(this.organization.providerId); + } + return of(null); + }), + tap((providerUsersResponse) => { + if (providerUsersResponse) { + providerUsersResponse.data.forEach((u) => { + const name = this.userNamePipe.transform(u); + this.orgUsersUserIdMap.set(u.userId, { + name: `${name} (${this.organization.providerName})`, + email: u.email, + }); }); - }), - ), + } + }), ), ); } catch (e) { diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts index 615d2ece463..aef4dd00312 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.spec.ts @@ -1,13 +1,17 @@ import { TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; import { + CollectionService, OrganizationUserApiService, OrganizationUserUserDetailsResponse, } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { OrganizationId } from "@bitwarden/common/types/guid"; +import { KeyService } from "@bitwarden/key-management"; import { GroupApiService } from "../../../core"; @@ -18,6 +22,9 @@ describe("OrganizationMembersService", () => { let organizationUserApiService: jest.Mocked; let groupService: jest.Mocked; let apiService: jest.Mocked; + let keyService: jest.Mocked; + let accountService: jest.Mocked; + let collectionService: jest.Mocked; const mockOrganizationId = "org-123" as OrganizationId; @@ -51,6 +58,7 @@ describe("OrganizationMembersService", () => { const createMockCollection = (id: string, name: string) => ({ id, name, + organizationId: mockOrganizationId, }); beforeEach(() => { @@ -66,12 +74,27 @@ describe("OrganizationMembersService", () => { getCollections: jest.fn(), } as any; + keyService = { + orgKeys$: jest.fn(), + } as any; + + accountService = { + activeAccount$: of({ id: "user-123" } as any), + } as any; + + collectionService = { + decryptMany$: jest.fn(), + } as any; + TestBed.configureTestingModule({ providers: [ OrganizationMembersService, { provide: OrganizationUserApiService, useValue: organizationUserApiService }, { provide: GroupApiService, useValue: groupService }, { provide: ApiService, useValue: apiService }, + { provide: KeyService, useValue: keyService }, + { provide: AccountService, useValue: accountService }, + { provide: CollectionService, useValue: collectionService }, ], }); @@ -88,11 +111,15 @@ describe("OrganizationMembersService", () => { data: [mockUser], } as any; const mockCollections = [createMockCollection("col-1", "Collection 1")]; + const mockOrgKey = { [mockOrganizationId]: {} as any }; + const mockDecryptedCollections = [{ id: "col-1", name: "Collection 1" }]; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: mockCollections, } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any)); const result = await service.loadUsers(organization); @@ -171,11 +198,19 @@ describe("OrganizationMembersService", () => { createMockCollection("col-2", "Alpha Collection"), createMockCollection("col-3", "Beta Collection"), ]; + const mockOrgKey = { [mockOrganizationId]: {} as any }; + const mockDecryptedCollections = [ + { id: "col-1", name: "Zebra Collection" }, + { id: "col-2", name: "Alpha Collection" }, + { id: "col-3", name: "Beta Collection" }, + ]; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: mockCollections, } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any)); const result = await service.loadUsers(organization); @@ -223,11 +258,19 @@ describe("OrganizationMembersService", () => { // col-2 is missing - should be filtered out createMockCollection("col-3", "Collection 3"), ]; + const mockOrgKey = { [mockOrganizationId]: {} as any }; + const mockDecryptedCollections = [ + { id: "col-1", name: "Collection 1" }, + // col-2 is missing - should be filtered out + { id: "col-3", name: "Collection 3" }, + ]; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: mockCollections, } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of(mockDecryptedCollections as any)); const result = await service.loadUsers(organization); @@ -269,11 +312,14 @@ describe("OrganizationMembersService", () => { const mockUsersResponse: ListResponse = { data: null as any, } as any; + const mockOrgKey = { [mockOrganizationId]: {} as any }; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: [], } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of([])); const result = await service.loadUsers(organization); @@ -285,11 +331,14 @@ describe("OrganizationMembersService", () => { const mockUsersResponse: ListResponse = { data: undefined as any, } as any; + const mockOrgKey = { [mockOrganizationId]: {} as any }; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: [], } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of([])); const result = await service.loadUsers(organization); @@ -322,11 +371,14 @@ describe("OrganizationMembersService", () => { const mockUsersResponse: ListResponse = { data: [mockUser], } as any; + const mockOrgKey = { [mockOrganizationId]: {} as any }; organizationUserApiService.getAllUsers.mockResolvedValue(mockUsersResponse); apiService.getCollections.mockResolvedValue({ data: [], } as any); + keyService.orgKeys$.mockReturnValue(of(mockOrgKey)); + collectionService.decryptMany$.mockReturnValue(of([])); const result = await service.loadUsers(organization); diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts index 613c7c1b9c0..0dc417cc2c6 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-members-service/organization-members.service.ts @@ -1,8 +1,18 @@ import { Injectable } from "@angular/core"; +import { combineLatest, firstValueFrom, from, map, switchMap } from "rxjs"; -import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { + Collection, + CollectionData, + CollectionDetailsResponse, + CollectionService, + OrganizationUserApiService, +} from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; 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 { KeyService } from "@bitwarden/key-management"; import { GroupApiService } from "../../../core"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; @@ -13,6 +23,9 @@ export class OrganizationMembersService { private organizationUserApiService: OrganizationUserApiService, private groupService: GroupApiService, private apiService: ApiService, + private keyService: KeyService, + private accountService: AccountService, + private collectionService: CollectionService, ) {} async loadUsers(organization: Organization): Promise { @@ -62,15 +75,38 @@ export class OrganizationMembersService { } private async getCollectionNameMap(organization: Organization): Promise> { - const response = this.apiService - .getCollections(organization.id) - .then((res) => - res.data.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name })), - ); + const collections$ = from(this.apiService.getCollections(organization.id)).pipe( + map((response) => { + return response.data.map((r) => + Collection.fromCollectionData(new CollectionData(r as CollectionDetailsResponse)), + ); + }), + ); - const collections = await response; - const collectionMap = new Map(); - collections.forEach((c: { id: string; name: string }) => collectionMap.set(c.id, c.name)); - return collectionMap; + const orgKey$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.keyService.orgKeys$(userId)), + map((orgKeys) => { + if (orgKeys == null) { + throw new Error("Organization keys not found for provided User."); + } + return orgKeys; + }), + ); + + return await firstValueFrom( + combineLatest([orgKey$, collections$]).pipe( + switchMap(([orgKey, collections]) => + this.collectionService.decryptMany$(collections, orgKey), + ), + map((decryptedCollections) => { + const collectionMap: Map = new Map(); + decryptedCollections.forEach((c) => { + collectionMap.set(c.id, c.name); + }); + return collectionMap; + }), + ), + ); } } diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html index 2388bb06bd8..b85f79f6038 100644 --- a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.html @@ -63,7 +63,9 @@ bitFormButton type="submit" > - @if (autoConfirmEnabled$ | async) { + @let autoConfirmEnabled = autoConfirmEnabled$ | async; + @let managePoliciesOnly = managePolicies$ | async; + @if (autoConfirmEnabled || managePoliciesOnly) { {{ "save" | i18n }} } @else { {{ "continue" | i18n }} diff --git a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts index 04e8e5d482a..994a0678bab 100644 --- a/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/policies/auto-confirm-edit-policy-dialog.component.ts @@ -27,6 +27,7 @@ import { } from "rxjs"; import { AutomaticUserConfirmationService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -35,6 +36,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv 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 { getById } from "@bitwarden/common/platform/misc"; import { DIALOG_DATA, DialogConfig, @@ -88,6 +90,12 @@ export class AutoConfirmPolicyDialogComponent switchMap((userId) => this.policyService.policies$(userId)), map((policies) => policies.find((p) => p.type === PolicyType.AutoConfirm)?.enabled ?? false), ); + protected managePolicies$: Observable = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.organizationService.organizations$(userId)), + getById(this.data.organizationId), + map((organization) => (!organization?.isAdmin && organization?.canManagePolicies) ?? false), + ); private readonly submitPolicy: Signal | undefined> = viewChild("step0"); private readonly openExtension: Signal | undefined> = viewChild("step1"); @@ -110,6 +118,7 @@ export class AutoConfirmPolicyDialogComponent toastService: ToastService, configService: ConfigService, keyService: KeyService, + private organizationService: OrganizationService, private policyService: PolicyService, private router: Router, private autoConfirmService: AutomaticUserConfirmationService, @@ -164,22 +173,34 @@ export class AutoConfirmPolicyDialogComponent tap((singleOrgPolicyEnabled) => this.policyComponent?.setSingleOrgEnabled(singleOrgPolicyEnabled), ), - map((singleOrgPolicyEnabled) => [ - { - sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false), - footerContent: this.submitPolicy, - titleContent: this.submitPolicyTitle, - }, - { - sideEffect: () => this.openBrowserExtension(), - footerContent: this.openExtension, - titleContent: this.openExtensionTitle, - }, - ]), + switchMap((singleOrgPolicyEnabled) => this.buildMultiStepSubmit(singleOrgPolicyEnabled)), shareReplay({ bufferSize: 1, refCount: true }), ); } + private buildMultiStepSubmit(singleOrgPolicyEnabled: boolean): Observable { + return this.managePolicies$.pipe( + map((managePoliciesOnly) => { + const submitSteps = [ + { + sideEffect: () => this.handleSubmit(singleOrgPolicyEnabled ?? false), + footerContent: this.submitPolicy, + titleContent: this.submitPolicyTitle, + }, + ]; + + if (!managePoliciesOnly) { + submitSteps.push({ + sideEffect: () => this.openBrowserExtension(), + footerContent: this.openExtension, + titleContent: this.openExtensionTitle, + }); + } + return submitSteps; + }), + ); + } + private async handleSubmit(singleOrgEnabled: boolean) { if (!singleOrgEnabled) { await this.submitSingleOrg(); diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html index 8334b451d22..cb6cf5f9bee 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/auto-confirm-policy.component.html @@ -27,7 +27,7 @@ {{ "autoConfirmSingleOrgRequired" | i18n }} } - {{ "autoConfirmSingleOrgRequiredDescription" | i18n }} + {{ "autoConfirmSingleOrgRequiredDesc" | i18n }}
  • diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts index 7373e1ff888..9b46e228af9 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/index.ts @@ -10,6 +10,7 @@ export { RestrictedItemTypesPolicy } from "./restricted-item-types.component"; export { SendOptionsPolicy } from "./send-options.component"; export { SingleOrgPolicy } from "./single-org.component"; export { TwoFactorAuthenticationPolicy } from "./two-factor-authentication.component"; +export { UriMatchDefaultPolicy } from "./uri-match-default.component"; export { vNextOrganizationDataOwnershipPolicy, vNextOrganizationDataOwnershipPolicyComponent, diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.html new file mode 100644 index 00000000000..399a4ad2dcd --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.html @@ -0,0 +1,22 @@ + + {{ "requireSsoPolicyReq" | i18n }} + + + + + {{ "turnOn" | i18n }} + + +
    + + {{ "uriMatchDetectionOptionsLabel" | i18n }} + + + + +
    diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.ts new file mode 100644 index 00000000000..5c0b667bea2 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/uri-match-default.component.ts @@ -0,0 +1,72 @@ +import { Component, ChangeDetectionStrategy } from "@angular/core"; +import { FormBuilder, FormControl, Validators } from "@angular/forms"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request"; +import { + UriMatchStrategy, + UriMatchStrategySetting, +} from "@bitwarden/common/models/domain/domain-service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { SharedModule } from "../../../../shared"; +import { BasePolicyEditDefinition, BasePolicyEditComponent } from "../base-policy-edit.component"; + +export class UriMatchDefaultPolicy extends BasePolicyEditDefinition { + name = "uriMatchDetectionPolicy"; + description = "uriMatchDetectionPolicyDesc"; + type = PolicyType.UriMatchDefaults; + component = UriMatchDefaultPolicyComponent; +} +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: "uri-match-default.component.html", + imports: [SharedModule], +}) +export class UriMatchDefaultPolicyComponent extends BasePolicyEditComponent { + uriMatchOptions: { label: string; value: UriMatchStrategySetting | null; disabled?: boolean }[]; + + constructor( + private formBuilder: FormBuilder, + private i18nService: I18nService, + ) { + super(); + + this.data = this.formBuilder.group({ + uriMatchDetection: new FormControl(UriMatchStrategy.Domain, { + validators: [Validators.required], + nonNullable: true, + }), + }); + + this.uriMatchOptions = [ + { label: i18nService.t("baseDomain"), value: UriMatchStrategy.Domain }, + { label: i18nService.t("host"), value: UriMatchStrategy.Host }, + { label: i18nService.t("exact"), value: UriMatchStrategy.Exact }, + { label: i18nService.t("never"), value: UriMatchStrategy.Never }, + ]; + } + + protected loadData() { + const uriMatchDetection = this.policyResponse?.data?.uriMatchDetection; + + this.data?.patchValue({ + uriMatchDetection: uriMatchDetection, + }); + } + + protected buildRequestData() { + return { + uriMatchDetection: this.data?.value?.uriMatchDetection, + }; + } + + async buildRequest(): Promise { + const request = await super.buildRequest(); + if (request.data?.uriMatchDetection == null) { + throw new Error(this.i18nService.t("invalidUriMatchDefaultPolicySetting")); + } + + return request; + } +} diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts b/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts index ca44818764c..a4bdece0a7b 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-register.ts @@ -13,6 +13,7 @@ import { SendOptionsPolicy, SingleOrgPolicy, TwoFactorAuthenticationPolicy, + UriMatchDefaultPolicy, vNextOrganizationDataOwnershipPolicy, } from "./policy-edit-definitions"; @@ -34,5 +35,6 @@ export const ossPolicyEditRegister: BasePolicyEditDefinition[] = [ new SendOptionsPolicy(), new RestrictedItemTypesPolicy(), new DesktopAutotypeDefaultSettingPolicy(), + new UriMatchDefaultPolicy(), new AutoConfirmPolicy(), ]; diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 60911173308..4571116312c 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -33,6 +33,8 @@ import { KeyService, BiometricStateService } from "@bitwarden/key-management"; const BroadcasterSubscriptionId = "AppComponent"; const IdleTimeout = 60000 * 10; // 10 minutes +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-root", templateUrl: "app.component.html", @@ -145,18 +147,6 @@ export class AppComponent implements OnDestroy, OnInit { } break; } - case "premiumRequired": { - const premiumConfirmed = await this.dialogService.openSimpleDialog({ - title: { key: "premiumRequired" }, - content: { key: "premiumRequiredDesc" }, - acceptButtonText: { key: "upgrade" }, - type: "success", - }); - if (premiumConfirmed) { - await this.router.navigate(["settings/subscription/premium"]); - } - break; - } case "emailVerificationRequired": { const emailVerificationConfirmed = await this.dialogService.openSimpleDialog({ title: { key: "emailVerificationRequired" }, diff --git a/apps/web/src/app/auth/constants/auth-web-route.constant.ts b/apps/web/src/app/auth/constants/auth-web-route.constant.ts new file mode 100644 index 00000000000..c1e714786e9 --- /dev/null +++ b/apps/web/src/app/auth/constants/auth-web-route.constant.ts @@ -0,0 +1,35 @@ +// Web route segments auth owns under shared infrastructure +export const AuthWebRouteSegment = Object.freeze({ + // settings routes + Account: "account", + EmergencyAccess: "emergency-access", + + // settings/security routes + Password: "password", + TwoFactor: "two-factor", + SecurityKeys: "security-keys", + DeviceManagement: "device-management", +} as const); + +export type AuthWebRouteSegment = (typeof AuthWebRouteSegment)[keyof typeof AuthWebRouteSegment]; + +// Full routes that auth owns in the web app +export const AuthWebRoute = Object.freeze({ + SignUpLinkExpired: "signup-link-expired", + RecoverTwoFactor: "recover-2fa", + AcceptEmergencyAccessInvite: "accept-emergency", + RecoverDeleteAccount: "recover-delete", + VerifyRecoverDeleteAccount: "verify-recover-delete", + AcceptOrganizationInvite: "accept-organization", + + // Composed routes from segments (allowing for router.navigate / routerLink usage) + AccountSettings: `settings/${AuthWebRouteSegment.Account}`, + EmergencyAccessSettings: `settings/${AuthWebRouteSegment.EmergencyAccess}`, + + PasswordSettings: `settings/security/${AuthWebRouteSegment.Password}`, + TwoFactorSettings: `settings/security/${AuthWebRouteSegment.TwoFactor}`, + SecurityKeysSettings: `settings/security/${AuthWebRouteSegment.SecurityKeys}`, + DeviceManagement: `settings/security/${AuthWebRouteSegment.DeviceManagement}`, +} as const); + +export type AuthWebRoute = (typeof AuthWebRoute)[keyof typeof AuthWebRoute]; diff --git a/apps/web/src/app/auth/constants/index.ts b/apps/web/src/app/auth/constants/index.ts new file mode 100644 index 00000000000..3d84e3729de --- /dev/null +++ b/apps/web/src/app/auth/constants/index.ts @@ -0,0 +1 @@ +export * from "./auth-web-route.constant"; diff --git a/apps/web/src/app/auth/recover-two-factor.component.ts b/apps/web/src/app/auth/recover-two-factor.component.ts index dc85668c8ec..9c033b88a75 100644 --- a/apps/web/src/app/auth/recover-two-factor.component.ts +++ b/apps/web/src/app/auth/recover-two-factor.component.ts @@ -113,14 +113,37 @@ export class RecoverTwoFactorComponent implements OnInit { await this.router.navigate(["/settings/security/two-factor"]); } catch (error: unknown) { if (error instanceof ErrorResponse) { - this.logService.error("Error logging in automatically: ", error.message); - - if (error.message.includes("Two-step token is invalid")) { - this.formGroup.get("recoveryCode")?.setErrors({ - invalidRecoveryCode: { message: this.i18nService.t("invalidRecoveryCode") }, + if ( + error.message.includes( + "Two-factor recovery has been performed. SSO authentication is required.", + ) + ) { + // [PM-21153]: Organization users with as SSO requirement need to be able to recover 2FA, + // but still be bound by the SSO requirement to log in. Therefore, we show a success toast for recovering 2FA, + // but then inform them that they need to log in via SSO and redirect them to the login page. + // The response tested here is a specific message for this scenario from request validation. + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("twoStepRecoverDisabled"), }); + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("ssoLoginIsRequired"), + }); + + await this.router.navigate(["/login"]); } else { - this.validationService.showError(error.message); + this.logService.error("Error logging in automatically: ", error.message); + + if (error.message.includes("Two-step token is invalid")) { + this.formGroup.get("recoveryCode")?.setErrors({ + invalidRecoveryCode: { message: this.i18nService.t("invalidRecoveryCode") }, + }); + } else { + this.validationService.showError(error.message); + } } } else { this.logService.error("Error logging in automatically: ", error); diff --git a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts index c2b8127ec34..7ef94706ef6 100644 --- a/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/emergency-access.component.ts @@ -96,15 +96,6 @@ export class EmergencyAccessComponent implements OnInit { this.loaded = true; } - async premiumRequired() { - const canAccessPremium = await firstValueFrom(this.canAccessPremium$); - - if (!canAccessPremium) { - this.messagingService.send("premiumRequired"); - return; - } - } - edit = async (details: GranteeEmergencyAccess) => { const canAccessPremium = await firstValueFrom(this.canAccessPremium$); const dialogRef = EmergencyAccessAddEditComponent.open(this.dialogService, { diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts index ef4d647a7d0..024455cc1bf 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts @@ -3,7 +3,6 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { first, - firstValueFrom, lastValueFrom, Observable, Subject, @@ -264,13 +263,6 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { } } - async premiumRequired() { - if (!(await firstValueFrom(this.canAccessPremium$))) { - this.messagingService.send("premiumRequired"); - return; - } - } - protected getTwoFactorProviders() { return this.twoFactorApiService.getTwoFactorProviders(); } diff --git a/apps/web/src/app/billing/guards/has-premium.guard.ts b/apps/web/src/app/billing/guards/has-premium.guard.ts index 61853b25cb8..f10e75d8268 100644 --- a/apps/web/src/app/billing/guards/has-premium.guard.ts +++ b/apps/web/src/app/billing/guards/has-premium.guard.ts @@ -1,21 +1,21 @@ import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, - RouterStateSnapshot, - Router, CanActivateFn, + Router, + RouterStateSnapshot, UrlTree, } from "@angular/router"; -import { Observable, of } from "rxjs"; +import { from, Observable, of } from "rxjs"; import { switchMap, tap } from "rxjs/operators"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; /** - * CanActivate guard that checks if the user has premium and otherwise triggers the "premiumRequired" - * message and blocks navigation. + * CanActivate guard that checks if the user has premium and otherwise triggers the premium upgrade + * flow and blocks navigation. */ export function hasPremiumGuard(): CanActivateFn { return ( @@ -23,7 +23,7 @@ export function hasPremiumGuard(): CanActivateFn { _state: RouterStateSnapshot, ): Observable => { const router = inject(Router); - const messagingService = inject(MessagingService); + const premiumUpgradePromptService = inject(PremiumUpgradePromptService); const billingAccountProfileStateService = inject(BillingAccountProfileStateService); const accountService = inject(AccountService); @@ -33,10 +33,14 @@ export function hasPremiumGuard(): CanActivateFn { ? billingAccountProfileStateService.hasPremiumFromAnySource$(account.id) : of(false), ), - tap((userHasPremium: boolean) => { + switchMap((userHasPremium: boolean) => { + // Can't call async method inside observables so instead, wait for service then switch back to the boolean if (!userHasPremium) { - messagingService.send("premiumRequired"); + return from(premiumUpgradePromptService.promptForPremium()).pipe( + switchMap(() => of(userHasPremium)), + ); } + return of(userHasPremium); }), // Prevent trapping the user on the login page, since that's an awful UX flow tap((userHasPremium: boolean) => { diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.html b/apps/web/src/app/billing/individual/premium/premium-vnext.component.html index bf5d0f60861..ee2bef9baa3 100644 --- a/apps/web/src/app/billing/individual/premium/premium-vnext.component.html +++ b/apps/web/src/app/billing/individual/premium/premium-vnext.component.html @@ -38,7 +38,7 @@ diff --git a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts index d25e035d1be..334e84d1451 100644 --- a/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium-vnext.component.ts @@ -16,6 +16,11 @@ import { 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 { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { @@ -28,12 +33,7 @@ import { import { PricingCardComponent } from "@bitwarden/pricing"; import { I18nPipe } from "@bitwarden/ui-common"; -import { SubscriptionPricingService } from "../../services/subscription-pricing.service"; import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types"; -import { - PersonalSubscriptionPricingTier, - PersonalSubscriptionPricingTierIds, -} from "../../types/subscription-pricing-tier"; import { UnifiedUpgradeDialogComponent, UnifiedUpgradeDialogParams, @@ -91,7 +91,7 @@ export class PremiumVNextComponent { private platformUtilsService: PlatformUtilsService, private syncService: SyncService, private billingAccountProfileStateService: BillingAccountProfileStateService, - private subscriptionPricingService: SubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, private router: Router, private activatedRoute: ActivatedRoute, ) { 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 6754f4c9f50..62d62331b94 100644 --- a/apps/web/src/app/billing/individual/premium/premium.component.ts +++ b/apps/web/src/app/billing/individual/premium/premium.component.ts @@ -5,6 +5,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { + catchError, combineLatest, concatMap, filter, @@ -12,10 +13,9 @@ import { map, Observable, of, + shareReplay, startWith, switchMap, - catchError, - shareReplay, } from "rxjs"; import { debounceTime } from "rxjs/operators"; @@ -23,6 +23,8 @@ 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 { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; 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"; @@ -35,12 +37,10 @@ import { getBillingAddressFromForm, } from "@bitwarden/web-vault/app/billing/payment/components"; import { - tokenizablePaymentMethodToLegacyEnum, NonTokenizablePaymentMethods, + tokenizablePaymentMethodToLegacyEnum, } from "@bitwarden/web-vault/app/billing/payment/types"; -import { SubscriptionPricingService } from "@bitwarden/web-vault/app/billing/services/subscription-pricing.service"; import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types"; -import { PersonalSubscriptionPricingTierIds } from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -137,7 +137,7 @@ export class PremiumComponent { private accountService: AccountService, private subscriberBillingClient: SubscriberBillingClient, private taxClient: TaxClient, - private subscriptionPricingService: SubscriptionPricingService, + private subscriptionPricingService: DefaultSubscriptionPricingService, ) { this.isSelfHost = this.platformUtilsService.isSelfHost(); diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts index d0960251724..32c67df1434 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -4,13 +4,13 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock } from "jest-mock-extended"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; -import { UserId } from "@bitwarden/common/types/guid"; -import { DIALOG_DATA, DialogRef } from "@bitwarden/components"; - import { PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, -} from "../../../types/subscription-pricing-tier"; +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DIALOG_DATA, DialogRef } from "@bitwarden/components"; + import { UpgradeAccountComponent, UpgradeAccountStatus, 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 index 077490cef43..07b21a9fb4b 100644 --- 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 @@ -4,6 +4,7 @@ import { Component, Inject, OnInit, signal } from "@angular/core"; import { Router } from "@angular/router"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { PersonalSubscriptionPricingTierId } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; import { ButtonModule, @@ -15,7 +16,6 @@ import { 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 { 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 index 27e69fcf0d4..add0eb0a011 100644 --- 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 @@ -4,15 +4,15 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; 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"; @@ -20,7 +20,7 @@ describe("UpgradeAccountComponent", () => { let sut: UpgradeAccountComponent; let fixture: ComponentFixture; const mockI18nService = mock(); - const mockSubscriptionPricingService = mock(); + const mockSubscriptionPricingService = mock(); // Mock pricing tiers data const mockPricingTiers: PersonalSubscriptionPricingTier[] = [ @@ -57,7 +57,10 @@ describe("UpgradeAccountComponent", () => { imports: [NoopAnimationsModule, UpgradeAccountComponent, PricingCardComponent, CdkTrapFocus], providers: [ { provide: I18nService, useValue: mockI18nService }, - { provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, ], }) .overrideComponent(UpgradeAccountComponent, { @@ -98,7 +101,7 @@ describe("UpgradeAccountComponent", () => { 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"].button.text).toBe("startFreeFamiliesTrial"); expect(sut["familiesCardDetails"].features).toEqual(["Feature A", "Feature B", "Feature C"]); }); @@ -170,7 +173,10 @@ describe("UpgradeAccountComponent", () => { ], providers: [ { provide: I18nService, useValue: mockI18nService }, - { provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, ], }) .overrideComponent(UpgradeAccountComponent, { 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 index be09505d190..a4089d7a47a 100644 --- 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 @@ -2,22 +2,23 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { catchError, of } from "rxjs"; -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 { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, SubscriptionCadence, SubscriptionCadenceIds, -} from "../../../types/subscription-pricing-tier"; +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { ButtonType, DialogModule, ToastService } from "@bitwarden/components"; +import { PricingCardComponent } from "@bitwarden/pricing"; + +import { SharedModule } from "../../../../shared"; +import { BillingServicesModule } from "../../../services"; export const UpgradeAccountStatus = { Closed: "closed", @@ -72,14 +73,26 @@ export class UpgradeAccountComponent implements OnInit { constructor( private i18nService: I18nService, - private subscriptionPricingService: SubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private toastService: ToastService, private destroyRef: DestroyRef, ) {} ngOnInit(): void { this.subscriptionPricingService .getPersonalSubscriptionPricingTiers$() - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("unexpectedError"), + }); + this.loading.set(false); + return of([]); + }), + takeUntilDestroyed(this.destroyRef), + ) .subscribe((plans) => { this.setupCardDetails(plans); this.loading.set(false); @@ -119,7 +132,7 @@ export class UpgradeAccountComponent implements OnInit { }, button: { text: this.i18nService.t( - this.isFamiliesPlan(tier.id) ? "upgradeToFamilies" : "upgradeToPremium", + this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium", ), type: buttonType, }, diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts index 11b1787e90e..787936c102e 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts @@ -119,14 +119,13 @@ describe("UpgradeNavButtonComponent", () => { ); }); - it("should refresh token and sync after upgrading to premium", async () => { + it("should full sync after upgrading to premium", async () => { const mockDialogRef = mock>(); mockDialogRef.closed = of({ status: UnifiedUpgradeDialogStatus.UpgradedToPremium }); mockDialogService.open.mockReturnValue(mockDialogRef); await component.upgrade(); - expect(mockApiService.refreshIdentityToken).toHaveBeenCalled(); expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); }); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts index 57d3b996e90..4dda16674ff 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts @@ -60,7 +60,6 @@ export class UpgradeNavButtonComponent { const result = await lastValueFrom(dialogRef.closed); if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium) { - await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); } else if (result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies) { const redirectUrl = `/organizations/${result.organizationId}/vault`; 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 index 614fc862577..daca452c174 100644 --- 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 @@ -11,6 +11,7 @@ import { OrganizationResponse } from "@bitwarden/common/admin-console/models/res import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; +import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; import { LogService } from "@bitwarden/logging"; @@ -27,7 +28,6 @@ import { NonTokenizedPaymentMethod, TokenizedPaymentMethod, } from "../../../../payment/types"; -import { PersonalSubscriptionPricingTierIds } from "../../../../types/subscription-pricing-tier"; import { UpgradePaymentService, PlanDetails } from "./upgrade-payment.service"; 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 index e175363af33..d14a1e40796 100644 --- 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 @@ -12,6 +12,11 @@ import { SubscriptionInformation, } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { LogService } from "@bitwarden/logging"; @@ -30,11 +35,6 @@ import { TokenizedPaymentMethod, } from "../../../../payment/types"; import { mapAccountToSubscriber } from "../../../../types"; -import { - PersonalSubscriptionPricingTier, - PersonalSubscriptionPricingTierId, - PersonalSubscriptionPricingTierIds, -} from "../../../../types/subscription-pricing-tier"; export type PlanDetails = { tier: PersonalSubscriptionPricingTierId; 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 index a0ba480fe1e..208d046caa7 100644 --- 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 @@ -24,6 +24,12 @@ import { } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierId, + PersonalSubscriptionPricingTierIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; 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"; @@ -43,13 +49,7 @@ import { TokenizedPaymentMethod, } from "../../../payment/types"; 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 { PaymentFormValues, @@ -128,7 +128,7 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { constructor( private i18nService: I18nService, - private subscriptionPricingService: SubscriptionPricingService, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, private toastService: ToastService, private logService: LogService, private destroyRef: DestroyRef, @@ -145,29 +145,42 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { } this.pricingTiers$ = this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$(); - this.pricingTiers$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((plans) => { - const planDetails = plans.find((plan) => plan.id === this.selectedPlanId()); + this.pricingTiers$ + .pipe( + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("error"), + message: this.i18nService.t("unexpectedError"), + }); + this.loading.set(false); + return of([]); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((plans) => { + const planDetails = plans.find((plan) => plan.id === this.selectedPlanId()); - if (planDetails) { - this.selectedPlan = { - tier: this.selectedPlanId(), - details: planDetails, - }; - this.passwordManager = { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", - cost: this.selectedPlan.details.passwordManager.annualPrice, - quantity: 1, - cadence: "year", - }; + if (planDetails) { + this.selectedPlan = { + tier: this.selectedPlanId(), + details: planDetails, + }; + 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", - ); - } else { - this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); - return; - } - }); + this.upgradeToMessage = this.i18nService.t( + this.isFamiliesPlan ? "startFreeFamiliesTrial" : "upgradeToPremium", + ); + } else { + this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); + return; + } + }); this.estimatedTax$ = this.formGroup.controls.billingAddress.valueChanges.pipe( startWith(this.formGroup.controls.billingAddress.value), 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 e2a30dd585c..9a6106bebd4 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 @@ -670,6 +670,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { if (this.selectedPlan.PasswordManager.hasPremiumAccessOption) { subTotal += this.selectedPlan.PasswordManager.premiumAccessOptionPrice; } + if (this.selectedPlan.PasswordManager.hasAdditionalStorageOption) { + subTotal += this.additionalStorageTotal(this.selectedPlan); + } return subTotal - this.discount; } @@ -707,18 +710,9 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { } if (this.organization.useSecretsManager) { - return ( - this.passwordManagerSubtotal + - this.additionalStorageTotal(this.selectedPlan) + - this.secretsManagerSubtotal() + - this.estimatedTax - ); + return this.passwordManagerSubtotal + this.secretsManagerSubtotal() + this.estimatedTax; } - return ( - this.passwordManagerSubtotal + - this.additionalStorageTotal(this.selectedPlan) + - this.estimatedTax - ); + return this.passwordManagerSubtotal + this.estimatedTax; } get teamsStarterPlanIsAvailable() { @@ -801,7 +795,6 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy { : this.i18nService.t("organizationUpgraded"), }); - await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); if (!this.acceptingSponsorship && !this.isInTrialFlow) { diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 7c081b38279..11c9b78aa21 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -49,6 +49,7 @@ import { ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; import { OrganizationSubscriptionPlan, + OrganizationSubscriptionPurchase, SubscriberBillingClient, TaxClient, } from "@bitwarden/web-vault/app/billing/clients"; @@ -478,7 +479,10 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } get passwordManagerSubtotal() { - let subTotal = this.selectedPlan.PasswordManager.basePrice; + const basePriceAfterDiscount = this.acceptingSponsorship + ? Math.max(this.selectedPlan.PasswordManager.basePrice - this.discount, 0) + : this.selectedPlan.PasswordManager.basePrice; + let subTotal = basePriceAfterDiscount; if ( this.selectedPlan.PasswordManager.hasAdditionalSeatsOption && this.formGroup.controls.additionalSeats.value @@ -488,19 +492,19 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { this.formGroup.value.additionalSeats, ); } - if ( - this.selectedPlan.PasswordManager.hasAdditionalStorageOption && - this.formGroup.controls.additionalStorage.value - ) { - subTotal += this.additionalStorageTotal(this.selectedPlan); - } if ( this.selectedPlan.PasswordManager.hasPremiumAccessOption && this.formGroup.controls.premiumAccessAddon.value ) { subTotal += this.selectedPlan.PasswordManager.premiumAccessOptionPrice; } - return subTotal - this.discount; + if ( + this.selectedPlan.PasswordManager.hasAdditionalStorageOption && + this.formGroup.controls.additionalStorage.value + ) { + subTotal += this.additionalStorageTotal(this.selectedPlan); + } + return subTotal; } get secretsManagerSubtotal() { @@ -671,7 +675,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { }); } - await this.apiService.refreshIdentityToken(); await this.syncService.fullSync(true); if (!this.acceptingSponsorship && !this.isInTrialFlow) { @@ -707,54 +710,90 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { } } + private getPlanFromLegacyEnum(): OrganizationSubscriptionPlan { + switch (this.formGroup.value.plan) { + case PlanType.FamiliesAnnually: + return { tier: "families", cadence: "annually" }; + case PlanType.TeamsMonthly: + return { tier: "teams", cadence: "monthly" }; + case PlanType.TeamsAnnually: + return { tier: "teams", cadence: "annually" }; + case PlanType.EnterpriseMonthly: + return { tier: "enterprise", cadence: "monthly" }; + case PlanType.EnterpriseAnnually: + return { tier: "enterprise", cadence: "annually" }; + } + } + + private buildTaxPreviewRequest( + additionalStorage: number, + sponsored: boolean, + ): OrganizationSubscriptionPurchase { + const passwordManagerSeats = this.selectedPlan.PasswordManager.hasAdditionalSeatsOption + ? this.formGroup.value.additionalSeats + : 1; + + return { + ...this.getPlanFromLegacyEnum(), + passwordManager: { + seats: passwordManagerSeats, + additionalStorage, + sponsored, + }, + secretsManager: this.formGroup.value.secretsManager.enabled + ? { + seats: this.secretsManagerForm.value.userSeats, + additionalServiceAccounts: this.secretsManagerForm.value.additionalServiceAccounts, + standalone: false, + } + : undefined, + }; + } + private async refreshSalesTax(): Promise { if (this.billingFormGroup.controls.billingAddress.invalid) { return; } - const getPlanFromLegacyEnum = (): OrganizationSubscriptionPlan => { - switch (this.formGroup.value.plan) { - case PlanType.FamiliesAnnually: - return { tier: "families", cadence: "annually" }; - case PlanType.TeamsMonthly: - return { tier: "teams", cadence: "monthly" }; - case PlanType.TeamsAnnually: - return { tier: "teams", cadence: "annually" }; - case PlanType.EnterpriseMonthly: - return { tier: "enterprise", cadence: "monthly" }; - case PlanType.EnterpriseAnnually: - return { tier: "enterprise", cadence: "annually" }; - } - }; - const billingAddress = getBillingAddressFromForm(this.billingFormGroup.controls.billingAddress); - const passwordManagerSeats = - this.formGroup.value.productTier === ProductTierType.Families - ? 1 - : this.formGroup.value.additionalSeats; + // should still be taxed. We mark the plan as NOT sponsored when there is additional storage + // so the server calculates tax, but we'll adjust the calculation to only tax the storage. + const hasPaidStorage = (this.formGroup.value.additionalStorage || 0) > 0; + const sponsoredForTaxPreview = this.acceptingSponsorship && !hasPaidStorage; - const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase( - { - ...getPlanFromLegacyEnum(), - passwordManager: { - seats: passwordManagerSeats, - additionalStorage: this.formGroup.value.additionalStorage, - sponsored: false, - }, - secretsManager: this.formGroup.value.secretsManager.enabled - ? { - seats: this.secretsManagerForm.value.userSeats, - additionalServiceAccounts: this.secretsManagerForm.value.additionalServiceAccounts, - standalone: false, - } - : undefined, - }, - billingAddress, - ); + if (this.acceptingSponsorship && hasPaidStorage) { + // For sponsored plans with paid storage, calculate tax only on storage + // by comparing tax on base+storage vs tax on base only + //TODO: Move this logic to PreviewOrganizationTaxCommand - https://bitwarden.atlassian.net/browse/PM-27585 + const [baseTaxAmounts, fullTaxAmounts] = await Promise.all([ + this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + this.buildTaxPreviewRequest(0, false), + billingAddress, + ), + this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, false), + billingAddress, + ), + ]); - this.estimatedTax = taxAmounts.tax; - this.total = taxAmounts.total; + // Tax on storage = Tax on (base + storage) - Tax on (base only) + this.estimatedTax = fullTaxAmounts.tax - baseTaxAmounts.tax; + } else { + const taxAmounts = await this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + this.buildTaxPreviewRequest(this.formGroup.value.additionalStorage, sponsoredForTaxPreview), + billingAddress, + ); + + this.estimatedTax = taxAmounts.tax; + } + + const subtotal = + this.passwordManagerSubtotal + + (this.planOffersSecretsManager && this.secretsManagerForm.value.enabled + ? this.secretsManagerSubtotal + : 0); + this.total = subtotal + this.estimatedTax; } private async updateOrganization() { diff --git a/apps/web/src/app/billing/services/free-families-policy.service.spec.ts b/apps/web/src/app/billing/services/free-families-policy.service.spec.ts index 10ccc448986..5b39a5a848a 100644 --- a/apps/web/src/app/billing/services/free-families-policy.service.spec.ts +++ b/apps/web/src/app/billing/services/free-families-policy.service.spec.ts @@ -38,7 +38,6 @@ describe("FreeFamiliesPolicyService", () => { describe("showSponsoredFamiliesDropdown$", () => { it("should return true when all conditions are met", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization that meets all criteria @@ -58,7 +57,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return false when organization is not Enterprise", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization that is not Enterprise tier @@ -74,27 +72,8 @@ describe("FreeFamiliesPolicyService", () => { expect(result).toBe(false); }); - it("should return false when feature flag is disabled", async () => { - // Configure mocks to disable feature flag - configService.getFeatureFlag$.mockReturnValue(of(false)); - policyService.policiesByType$.mockReturnValue(of([])); - - // Create a test organization that meets other criteria - const organization = { - id: "org-id", - productTierType: ProductTierType.Enterprise, - useAdminSponsoredFamilies: true, - isAdmin: true, - } as Organization; - - // Test the method - const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization))); - expect(result).toBe(false); - }); - it("should return false when families feature is disabled by policy", async () => { // Configure mocks with a policy that disables the feature - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue( of([{ organizationId: "org-id", enabled: true } as Policy]), ); @@ -114,7 +93,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return false when useAdminSponsoredFamilies is false", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization with useAdminSponsoredFamilies set to false @@ -132,7 +110,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return true when user is an owner but not admin", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization where user is owner but not admin @@ -152,7 +129,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return true when user can manage users but is not admin or owner", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization where user can manage users but is not admin or owner @@ -172,7 +148,6 @@ describe("FreeFamiliesPolicyService", () => { it("should return false when user has no admin permissions", async () => { // Configure mocks - configService.getFeatureFlag$.mockReturnValue(of(true)); policyService.policiesByType$.mockReturnValue(of([])); // Create a test organization where user has no admin permissions diff --git a/apps/web/src/app/billing/services/free-families-policy.service.ts b/apps/web/src/app/billing/services/free-families-policy.service.ts index 52041936e50..68e333d53ba 100644 --- a/apps/web/src/app/billing/services/free-families-policy.service.ts +++ b/apps/web/src/app/billing/services/free-families-policy.service.ts @@ -8,8 +8,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; interface EnterpriseOrgStatus { isFreeFamilyPolicyEnabled: boolean; @@ -23,7 +21,6 @@ export class FreeFamiliesPolicyService { private policyService: PolicyService, private organizationService: OrganizationService, private accountService: AccountService, - private configService: ConfigService, ) {} organizations$ = this.accountService.activeAccount$.pipe( @@ -58,20 +55,14 @@ export class FreeFamiliesPolicyService { userId, ); - return combineLatest([ - enterpriseOrganization$, - this.configService.getFeatureFlag$(FeatureFlag.PM17772_AdminInitiatedSponsorships), - organization, - policies$, - ]).pipe( - map(([isEnterprise, featureFlagEnabled, org, policies]) => { + return combineLatest([enterpriseOrganization$, organization, policies$]).pipe( + map(([isEnterprise, org, policies]) => { const familiesFeatureDisabled = policies.some( (policy) => policy.organizationId === org.id && policy.enabled, ); return ( isEnterprise && - featureFlagEnabled && !familiesFeatureDisabled && org.useAdminSponsoredFamilies && (org.isAdmin || org.isOwner || org.canManageUsers) diff --git a/apps/web/src/app/billing/services/pricing-summary.service.ts b/apps/web/src/app/billing/services/pricing-summary.service.ts index b06dc80a070..b3c071a8b88 100644 --- a/apps/web/src/app/billing/services/pricing-summary.service.ts +++ b/apps/web/src/app/billing/services/pricing-summary.service.ts @@ -50,6 +50,9 @@ export class PricingSummaryService { if (plan.PasswordManager?.hasPremiumAccessOption) { passwordManagerSubtotal += plan.PasswordManager.premiumAccessOptionPrice; } + if (plan.PasswordManager?.hasAdditionalStorageOption) { + passwordManagerSubtotal += additionalStorageTotal; + } const secretsManagerSubtotal = plan.SecretsManager ? (plan.SecretsManager.basePrice || 0) + @@ -66,8 +69,8 @@ export class PricingSummaryService { const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0; const total = organization?.useSecretsManager - ? passwordManagerSubtotal + additionalStorageTotal + secretsManagerSubtotal + estimatedTax - : passwordManagerSubtotal + additionalStorageTotal + estimatedTax; + ? passwordManagerSubtotal + secretsManagerSubtotal + estimatedTax + : passwordManagerSubtotal + estimatedTax; return { selectedPlanInterval: selectedInterval === PlanInterval.Annually ? "year" : "month", diff --git a/apps/web/src/app/components/dynamic-avatar.component.ts b/apps/web/src/app/components/dynamic-avatar.component.ts index 8cd73862151..ddaaa21758b 100644 --- a/apps/web/src/app/components/dynamic-avatar.component.ts +++ b/apps/web/src/app/components/dynamic-avatar.component.ts @@ -8,6 +8,8 @@ import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.servic import { SharedModule } from "../shared"; type SizeTypes = "xlarge" | "large" | "default" | "small" | "xsmall"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "dynamic-avatar", imports: [SharedModule], @@ -25,10 +27,20 @@ type SizeTypes = "xlarge" | "large" | "default" | "small" | "xsmall"; `, }) export class DynamicAvatarComponent implements OnDestroy { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() border = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() id: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() text: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() title: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() size: SizeTypes = "default"; private destroy$ = new Subject(); diff --git a/apps/web/src/app/components/environment-selector/environment-selector.component.ts b/apps/web/src/app/components/environment-selector/environment-selector.component.ts index 37e5ae0c3d8..4f77cc96bf7 100644 --- a/apps/web/src/app/components/environment-selector/environment-selector.component.ts +++ b/apps/web/src/app/components/environment-selector/environment-selector.component.ts @@ -12,6 +12,8 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SharedModule } from "../../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "environment-selector", templateUrl: "environment-selector.component.html", diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 7dca9c9e720..e8c43844a80 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -62,6 +62,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ClientType } from "@bitwarden/common/enums"; import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; @@ -101,6 +102,7 @@ import { NoopSdkLoadService } from "@bitwarden/common/platform/services/sdk/noop import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state"; import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service"; +import { SyncService } from "@bitwarden/common/platform/sync/sync.service"; import { DefaultThemeStateService, ThemeStateService, @@ -438,7 +440,16 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService, - deps: [DialogService, Router], + deps: [ + DialogService, + ConfigService, + AccountService, + ApiService, + SyncService, + BillingAccountProfileStateService, + PlatformUtilsService, + Router, + ], }), ]; diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts index acc34232571..80893737ffd 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.spec.ts @@ -121,4 +121,153 @@ describe("InactiveTwoFactorReportComponent", () => { it("should call fullSync method of syncService", () => { expect(syncServiceMock.fullSync).toHaveBeenCalledWith(false); }); + + describe("isInactive2faCipher", () => { + beforeEach(() => { + // Add both domain and host to services map + component.services.set("example.com", "https://example.com/2fa-doc"); + component.services.set("sub.example.com", "https://sub.example.com/2fa-doc"); + fixture.detectChanges(); + }); + it("should return true and documentation for cipher with matching domain", () => { + const cipher = createCipherView({ + login: { + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(true); + expect(doc).toBe("https://example.com/2fa-doc"); + }); + + it("should return true and documentation for cipher with matching host", () => { + const cipher = createCipherView({ + login: { + uris: [{ uri: "https://sub.example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(true); + expect(doc).toBe("https://sub.example.com/2fa-doc"); + }); + + it("should return false for cipher with non-matching domain or host", () => { + const cipher = createCipherView({ + login: { + uris: [{ uri: "https://otherdomain.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should return false if cipher type is not Login", () => { + const cipher = createCipherView({ + type: 2, + login: { + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should return false if cipher has TOTP", () => { + const cipher = createCipherView({ + login: { + totp: "some-totp", + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should return false if cipher is deleted", () => { + const cipher = createCipherView({ + isDeleted: true, + login: { + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should return false if cipher does not have edit access and no organization", () => { + component.organization = null; + const cipher = createCipherView({ + edit: false, + login: { + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should return false if cipher does not have viewPassword", () => { + const cipher = createCipherView({ + viewPassword: false, + login: { + uris: [{ uri: "https://example.com/login" }], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + it("should check all uris and return true if any matches domain or host", () => { + const cipher = createCipherView({ + login: { + uris: [ + { uri: "https://otherdomain.com/login" }, + { uri: "https://sub.example.com/dashboard" }, + ], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(true); + expect(doc).toBe("https://sub.example.com/2fa-doc"); + }); + + it("should return false if uris array is empty", () => { + const cipher = createCipherView({ + login: { + uris: [], + }, + }); + const [doc, isInactive] = (component as any).isInactive2faCipher(cipher); + expect(isInactive).toBe(false); + expect(doc).toBe(""); + }); + + function createCipherView({ + type = 1, + login = {}, + isDeleted = false, + edit = true, + viewPassword = true, + }: any): any { + return { + id: "test-id", + type, + login: { + totp: null, + hasUris: true, + uris: [], + ...login, + }, + isDeleted, + edit, + viewPassword, + }; + } + }); }); diff --git a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts index 8b0fdda70e3..2a8ec12ac6a 100644 --- a/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts +++ b/apps/web/src/app/dirt/reports/pages/inactive-two-factor-report.component.ts @@ -109,7 +109,18 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl const u = login.uris[i]; if (u.uri != null && u.uri !== "") { const uri = u.uri.replace("www.", ""); + const host = Utils.getHost(uri); const domain = Utils.getDomain(uri); + // check host first + if (host != null && this.services.has(host)) { + if (this.services.get(host) != null) { + docFor2fa = this.services.get(host) || ""; + } + isInactive2faCipher = true; + break; + } + + // then check domain if (domain != null && this.services.has(domain)) { if (this.services.get(domain) != null) { docFor2fa = this.services.get(domain) || ""; diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html index dab928e6ec3..a6ae7a246ac 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.html @@ -15,14 +15,12 @@

    {{ title }}

    {{ description }}

    - - {{ "premium" | i18n }} - {{ "upgrade" | i18n }} - + @if (requiresPremium) { + + } @else if (requiresUpgrade) { + + {{ "upgrade" | i18n }} + + } diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts index 565035c2c55..87c005ea46b 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.component.ts @@ -37,4 +37,8 @@ export class ReportCardComponent { protected get requiresPremium() { return this.variant == ReportVariant.RequiresPremium; } + + protected get requiresUpgrade() { + return this.variant == ReportVariant.RequiresUpgrade; + } } diff --git a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts index 50798fea6e1..93ea79c8418 100644 --- a/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-card/report-card.stories.ts @@ -1,14 +1,20 @@ import { importProvidersFrom } from "@angular/core"; import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { of } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { BadgeModule, BaseCardComponent, - IconModule, CardContentComponent, + I18nMockService, + IconModule, } from "@bitwarden/components"; import { PreloadedEnglishI18nModule } from "../../../../core/tests"; @@ -30,6 +36,37 @@ export default { PremiumBadgeComponent, BaseCardComponent, ], + providers: [ + { + provide: AccountService, + useValue: { + activeAccount$: of({ + id: "123", + }), + }, + }, + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + premium: "Premium", + upgrade: "Upgrade", + }); + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: () => of(false), + }, + }, + { + provide: PremiumUpgradePromptService, + useValue: { + promptForPremium: (orgId?: string) => {}, + }, + }, + ], }), applicationConfig({ providers: [importProvidersFrom(PreloadedEnglishI18nModule)], diff --git a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts index 5a89eeff803..5a95e332816 100644 --- a/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts +++ b/apps/web/src/app/dirt/reports/shared/report-list/report-list.stories.ts @@ -1,9 +1,13 @@ import { importProvidersFrom } from "@angular/core"; import { RouterTestingModule } from "@angular/router/testing"; -import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { applicationConfig, Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { of } from "rxjs"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { BadgeModule, BaseCardComponent, @@ -33,6 +37,28 @@ export default { BaseCardComponent, ], declarations: [ReportCardComponent], + providers: [ + { + provide: AccountService, + useValue: { + activeAccount$: of({ + id: "123", + }), + }, + }, + { + provide: BillingAccountProfileStateService, + useValue: { + hasPremiumFromAnySource$: () => of(false), + }, + }, + { + provide: PremiumUpgradePromptService, + useValue: { + promptForPremium: (orgId?: string) => {}, + }, + }, + ], }), applicationConfig({ providers: [importProvidersFrom(PreloadedEnglishI18nModule)], diff --git a/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts b/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts index 59e59a6a500..940a2d4e3a5 100644 --- a/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts +++ b/apps/web/src/app/dirt/reports/shared/reports-shared.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { BaseCardComponent, CardContentComponent } from "@bitwarden/components"; import { SharedModule } from "../../../shared/shared.module"; @@ -9,7 +10,13 @@ import { ReportCardComponent } from "./report-card/report-card.component"; import { ReportListComponent } from "./report-list/report-list.component"; @NgModule({ - imports: [CommonModule, SharedModule, BaseCardComponent, CardContentComponent], + imports: [ + CommonModule, + SharedModule, + BaseCardComponent, + CardContentComponent, + PremiumBadgeComponent, + ], declarations: [ReportCardComponent, ReportListComponent], exports: [ReportCardComponent, ReportListComponent], }) diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html index 530d4caca03..23f22d263cf 100644 --- a/apps/web/src/app/layouts/user-layout.component.html +++ b/apps/web/src/app/layouts/user-layout.component.html @@ -20,10 +20,12 @@ *ngIf="showSubscription$ | async" > - + @if (showEmergencyAccess()) { + + } diff --git a/apps/web/src/app/layouts/user-layout.component.ts b/apps/web/src/app/layouts/user-layout.component.ts index 9642803ef30..52e5b65a2e8 100644 --- a/apps/web/src/app/layouts/user-layout.component.ts +++ b/apps/web/src/app/layouts/user-layout.component.ts @@ -1,14 +1,20 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, Signal } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; import { RouterModule } from "@angular/router"; -import { Observable, switchMap } from "rxjs"; +import { combineLatest, map, Observable, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PasswordManagerLogo } from "@bitwarden/assets/svg"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { 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 { SyncService } from "@bitwarden/common/platform/sync"; import { IconModule } from "@bitwarden/components"; @@ -32,6 +38,7 @@ import { WebLayoutModule } from "./web-layout.module"; }) export class UserLayoutComponent implements OnInit { protected readonly logo = PasswordManagerLogo; + protected readonly showEmergencyAccess: Signal; protected hasFamilySponsorshipAvailable$: Observable; protected showSponsoredFamilies$: Observable; protected showSubscription$: Observable; @@ -40,12 +47,33 @@ export class UserLayoutComponent implements OnInit { private syncService: SyncService, private billingAccountProfileStateService: BillingAccountProfileStateService, private accountService: AccountService, + private policyService: PolicyService, + private configService: ConfigService, ) { this.showSubscription$ = this.accountService.activeAccount$.pipe( switchMap((account) => this.billingAccountProfileStateService.canViewSubscription$(account.id), ), ); + + this.showEmergencyAccess = toSignal( + combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm), + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId), + ), + ), + ]).pipe( + map(([enabled, policyAppliesToUser]) => { + if (!enabled || !policyAppliesToUser) { + return true; + } + return false; + }), + ), + ); } async ngOnInit() { diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 45ed6dc8eb9..8e2d770f1e4 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { Route, RouterModule, Routes } from "@angular/router"; import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component"; +import { AuthRoute } from "@bitwarden/angular/auth/constants"; import { authGuard, lockGuard, @@ -46,15 +47,18 @@ import { TwoFactorAuthGuard, NewDeviceVerificationComponent, } from "@bitwarden/auth/angular"; +import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; import { LockComponent } from "@bitwarden/key-management-ui"; import { flagEnabled, Flags } from "../utils/flags"; +import { organizationPolicyGuard } from "./admin-console/organizations/guards/org-policy.guard"; import { VerifyRecoverDeleteOrgComponent } from "./admin-console/organizations/manage/verify-recover-delete-org.component"; import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/sponsorships/accept-family-sponsorship.component"; import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component"; import { CreateOrganizationComponent } from "./admin-console/settings/create-organization.component"; +import { AuthWebRoute, AuthWebRouteSegment } from "./auth/constants/auth-web-route.constant"; import { deepLinkGuard } from "./auth/guards/deep-link/deep-link.guard"; import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component"; import { RecoverDeleteComponent } from "./auth/recover-delete.component"; @@ -93,12 +97,12 @@ const routes: Routes = [ // so that the redirectGuard does not interrupt the navigation. { path: "register", - redirectTo: "signup", + redirectTo: AuthRoute.SignUp, pathMatch: "full", }, { path: "trial", - redirectTo: "signup", + redirectTo: AuthRoute.SignUp, pathMatch: "full", }, { @@ -114,7 +118,7 @@ const routes: Routes = [ }, { path: "verify-email", component: VerifyEmailTokenComponent }, { - path: "accept-organization", + path: AuthWebRoute.AcceptOrganizationInvite, canActivate: [deepLinkGuard()], component: AcceptOrganizationComponent, data: { titleId: "joinOrganization", doNotSaveUrl: false } satisfies RouteDataProperties, @@ -128,7 +132,7 @@ const routes: Routes = [ doNotSaveUrl: false, } satisfies RouteDataProperties, }, - { path: "recover", pathMatch: "full", redirectTo: "recover-2fa" }, + { path: "recover", pathMatch: "full", redirectTo: AuthWebRoute.RecoverTwoFactor }, { path: "verify-recover-delete-org", component: VerifyRecoverDeleteOrgComponent, @@ -142,7 +146,7 @@ const routes: Routes = [ component: AnonLayoutWrapperComponent, children: [ { - path: "login-with-passkey", + path: AuthRoute.LoginWithPasskey, canActivate: [unauthGuardFn()], data: { pageIcon: TwoFactorAuthSecurityKeyIcon, @@ -164,7 +168,7 @@ const routes: Routes = [ ], }, { - path: "signup", + path: AuthRoute.SignUp, canActivate: [unauthGuardFn()], data: { pageIcon: RegistrationUserAddIcon, @@ -189,7 +193,7 @@ const routes: Routes = [ ], }, { - path: "finish-signup", + path: AuthRoute.FinishSignUp, canActivate: [unauthGuardFn()], data: { pageIcon: LockIcon, @@ -203,7 +207,7 @@ const routes: Routes = [ ], }, { - path: "login", + path: AuthRoute.Login, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -229,7 +233,7 @@ const routes: Routes = [ ], }, { - path: "login-with-device", + path: AuthRoute.LoginWithDevice, data: { pageIcon: DevicesIcon, pageTitle: { @@ -250,7 +254,7 @@ const routes: Routes = [ ], }, { - path: "admin-approval-requested", + path: AuthRoute.AdminApprovalRequested, data: { pageIcon: DevicesIcon, pageTitle: { @@ -264,7 +268,7 @@ const routes: Routes = [ children: [{ path: "", component: LoginViaAuthRequestComponent }], }, { - path: "hint", + path: AuthRoute.PasswordHint, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -286,7 +290,7 @@ const routes: Routes = [ ], }, { - path: "login-initiated", + path: AuthRoute.LoginInitiated, canActivate: [tdeDecryptionRequiredGuard()], data: { pageIcon: DevicesIcon, @@ -315,7 +319,7 @@ const routes: Routes = [ ], }, { - path: "set-initial-password", + path: AuthRoute.SetInitialPassword, canActivate: [authGuard], component: SetInitialPasswordComponent, data: { @@ -324,7 +328,7 @@ const routes: Routes = [ } satisfies AnonLayoutWrapperData, }, { - path: "signup-link-expired", + path: AuthWebRoute.SignUpLinkExpired, canActivate: [unauthGuardFn()], data: { pageIcon: TwoFactorTimeoutIcon, @@ -337,13 +341,13 @@ const routes: Routes = [ path: "", component: RegistrationLinkExpiredComponent, data: { - loginRoute: "/login", + loginRoute: `/${AuthRoute.Login}`, } satisfies RegistrationStartSecondaryComponentData, }, ], }, { - path: "sso", + path: AuthRoute.Sso, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -368,7 +372,7 @@ const routes: Routes = [ ], }, { - path: "2fa", + path: AuthRoute.TwoFactor, component: TwoFactorAuthComponent, canActivate: [unauthGuardFn(), TwoFactorAuthGuard], children: [ @@ -408,7 +412,7 @@ const routes: Routes = [ } satisfies AnonLayoutWrapperData, }, { - path: "authentication-timeout", + path: AuthRoute.AuthenticationTimeout, canActivate: [unauthGuardFn()], children: [ { @@ -430,7 +434,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "recover-2fa", + path: AuthWebRoute.RecoverTwoFactor, canActivate: [unauthGuardFn()], children: [ { @@ -452,7 +456,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "device-verification", + path: AuthRoute.NewDeviceVerification, canActivate: [unauthGuardFn(), activeAuthGuard()], children: [ { @@ -471,7 +475,7 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { - path: "accept-emergency", + path: AuthWebRoute.AcceptEmergencyAccessInvite, canActivate: [deepLinkGuard()], data: { pageTitle: { @@ -492,7 +496,7 @@ const routes: Routes = [ ], }, { - path: "recover-delete", + path: AuthWebRoute.RecoverDeleteAccount, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -514,7 +518,7 @@ const routes: Routes = [ ], }, { - path: "verify-recover-delete", + path: AuthWebRoute.VerifyRecoverDeleteAccount, canActivate: [unauthGuardFn()], data: { pageTitle: { @@ -596,7 +600,7 @@ const routes: Routes = [ ], }, { - path: "change-password", + path: AuthRoute.ChangePassword, component: ChangePasswordComponent, canActivate: [authGuard], data: { @@ -652,9 +656,9 @@ const routes: Routes = [ { path: "settings", children: [ - { path: "", pathMatch: "full", redirectTo: "account" }, + { path: "", pathMatch: "full", redirectTo: AuthWebRouteSegment.Account }, { - path: "account", + path: AuthWebRouteSegment.Account, component: AccountComponent, data: { titleId: "myAccount" } satisfies RouteDataProperties, }, @@ -680,16 +684,18 @@ const routes: Routes = [ ), }, { - path: "emergency-access", + path: AuthWebRouteSegment.EmergencyAccess, children: [ { path: "", component: EmergencyAccessComponent, + canActivate: [organizationPolicyGuard(canAccessEmergencyAccess)], data: { titleId: "emergencyAccess" } satisfies RouteDataProperties, }, { path: ":id", component: EmergencyAccessViewComponent, + canActivate: [organizationPolicyGuard(canAccessEmergencyAccess)], data: { titleId: "emergencyAccess" } satisfies RouteDataProperties, }, ], diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts index 0e32321a0b3..afac3b059a8 100644 --- a/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts +++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/request-sm-access.component.ts @@ -19,6 +19,8 @@ import { RequestSMAccessRequest } from "../models/requests/request-sm-access.req import { SmLandingApiService } from "./sm-landing-api.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-request-sm-access", templateUrl: "request-sm-access.component.html", diff --git a/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts index 301e6f7dfad..c1cc2b63e28 100644 --- a/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts +++ b/apps/web/src/app/secrets-manager/secrets-manager-landing/sm-landing.component.ts @@ -12,6 +12,8 @@ import { NoItemsModule, SearchModule } from "@bitwarden/components"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared/shared.module"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-sm-landing", imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule], diff --git a/apps/web/src/app/settings/domain-rules.component.ts b/apps/web/src/app/settings/domain-rules.component.ts index 6c4cb13d5fa..0e9d2f422d9 100644 --- a/apps/web/src/app/settings/domain-rules.component.ts +++ b/apps/web/src/app/settings/domain-rules.component.ts @@ -12,6 +12,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { HeaderModule } from "../layouts/header/header.module"; import { SharedModule } from "../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-domain-rules", templateUrl: "domain-rules.component.html", diff --git a/apps/web/src/app/settings/preferences.component.ts b/apps/web/src/app/settings/preferences.component.ts index 58a072ce76a..c1e8fce98ca 100644 --- a/apps/web/src/app/settings/preferences.component.ts +++ b/apps/web/src/app/settings/preferences.component.ts @@ -39,6 +39,8 @@ import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; import { HeaderModule } from "../layouts/header/header.module"; import { SharedModule } from "../shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-preferences", templateUrl: "preferences.component.html", diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts index e45a82d82ba..6716cde629a 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts @@ -6,11 +6,14 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogRef, DIALOG_DATA, DialogService, ToastService } from "@bitwarden/components"; @@ -73,6 +76,7 @@ describe("VaultItemDialogComponent", () => { { provide: LogService, useValue: {} }, { provide: CipherService, useValue: {} }, { provide: AccountService, useValue: { activeAccount$: { pipe: () => ({}) } } }, + { provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } }, { provide: Router, useValue: {} }, { provide: ActivatedRoute, useValue: {} }, { @@ -84,6 +88,8 @@ describe("VaultItemDialogComponent", () => { { provide: ApiService, useValue: {} }, { provide: EventCollectionService, useValue: {} }, { provide: RoutedVaultFilterService, useValue: {} }, + { provide: SyncService, useValue: {} }, + { provide: PlatformUtilsService, useValue: {} }, ], }).compileComponents(); diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 43ce8530d55..c09553dab9c 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -169,10 +169,12 @@ - + @if (!viewingOrgVault) { + + } diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts b/bitwarden_license/bit-web/src/app/auth/sso/sso-manage.component.ts similarity index 85% rename from bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts rename to bitwarden_license/bit-web/src/app/auth/sso/sso-manage.component.ts index 1c25283ea4f..c7479df7784 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso-manage.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, OnDestroy, OnInit } from "@angular/core"; import { AbstractControl, @@ -55,11 +53,11 @@ const defaultSigningAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha2 // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ - selector: "app-org-manage-sso", - templateUrl: "sso.component.html", + selector: "auth-sso-manage", + templateUrl: "sso-manage.component.html", standalone: false, }) -export class SsoComponent implements OnInit, OnDestroy { +export class SsoManageComponent implements OnInit, OnDestroy { readonly ssoType = SsoType; readonly memberDecryptionType = MemberDecryptionType; @@ -117,31 +115,31 @@ export class SsoComponent implements OnInit, OnDestroy { isInitializing = true; // concerned with UI/UX (i.e. when to show loading spinner vs form) isFormValidatingOrPopulating = true; // tracks when form fields are being validated/populated during load() or submit() - configuredKeyConnectorUrlFromServer: string | null; + configuredKeyConnectorUrlFromServer: string | null = null; memberDecryptionTypeValueChangesSubscription: Subscription | null = null; haveTestedKeyConnector = false; - organizationId: string; - organization: Organization; + organizationId: string | undefined = undefined; + organization: Organization | undefined = undefined; - callbackPath: string; - signedOutCallbackPath: string; - spEntityId: string; - spEntityIdStatic: string; - spMetadataUrl: string; - spAcsUrl: string; + callbackPath: string | undefined = undefined; + signedOutCallbackPath: string | undefined = undefined; + spEntityId: string | undefined = undefined; + spEntityIdStatic: string | undefined = undefined; + spMetadataUrl: string | undefined = undefined; + spAcsUrl: string | undefined = undefined; showClientSecret = false; protected openIdForm = this.formBuilder.group>( { - authority: new FormControl("", Validators.required), - clientId: new FormControl("", Validators.required), - clientSecret: new FormControl("", Validators.required), + authority: new FormControl("", { nonNullable: true, validators: Validators.required }), + clientId: new FormControl("", { nonNullable: true, validators: Validators.required }), + clientSecret: new FormControl("", { nonNullable: true, validators: Validators.required }), metadataAddress: new FormControl(), - redirectBehavior: new FormControl( - OpenIdConnectRedirectBehavior.RedirectGet, - Validators.required, - ), + redirectBehavior: new FormControl(OpenIdConnectRedirectBehavior.RedirectGet, { + nonNullable: true, + validators: Validators.required, + }), getClaimsFromUserInfoEndpoint: new FormControl(), additionalScopes: new FormControl(), additionalUserIdClaimTypes: new FormControl(), @@ -157,22 +155,32 @@ export class SsoComponent implements OnInit, OnDestroy { protected samlForm = this.formBuilder.group>( { - spUniqueEntityId: new FormControl(true, { updateOn: "change" }), - spNameIdFormat: new FormControl(Saml2NameIdFormat.NotConfigured), - spOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm), - spSigningBehavior: new FormControl(Saml2SigningBehavior.IfIdpWantAuthnRequestsSigned), - spMinIncomingSigningAlgorithm: new FormControl(defaultSigningAlgorithm), + spUniqueEntityId: new FormControl(true, { nonNullable: true, updateOn: "change" }), + spNameIdFormat: new FormControl(Saml2NameIdFormat.NotConfigured, { nonNullable: true }), + spOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm, { nonNullable: true }), + spSigningBehavior: new FormControl(Saml2SigningBehavior.IfIdpWantAuthnRequestsSigned, { + nonNullable: true, + }), + spMinIncomingSigningAlgorithm: new FormControl(defaultSigningAlgorithm, { + nonNullable: true, + }), spWantAssertionsSigned: new FormControl(), spValidateCertificates: new FormControl(), - idpEntityId: new FormControl("", Validators.required), - idpBindingType: new FormControl(Saml2BindingType.HttpRedirect), - idpSingleSignOnServiceUrl: new FormControl("", Validators.required), + idpEntityId: new FormControl("", { nonNullable: true, validators: Validators.required }), + idpBindingType: new FormControl(Saml2BindingType.HttpRedirect, { nonNullable: true }), + idpSingleSignOnServiceUrl: new FormControl("", { + nonNullable: true, + validators: Validators.required, + }), idpSingleLogoutServiceUrl: new FormControl(), - idpX509PublicCert: new FormControl("", Validators.required), - idpOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm), + idpX509PublicCert: new FormControl("", { + nonNullable: true, + validators: Validators.required, + }), + idpOutboundSigningAlgorithm: new FormControl(defaultSigningAlgorithm, { nonNullable: true }), idpAllowUnsolicitedAuthnResponse: new FormControl(), - idpAllowOutboundLogoutRequests: new FormControl(true), + idpAllowOutboundLogoutRequests: new FormControl(true, { nonNullable: true }), idpWantAuthnRequestsSigned: new FormControl(), }, { @@ -181,13 +189,16 @@ export class SsoComponent implements OnInit, OnDestroy { ); protected ssoConfigForm = this.formBuilder.group>({ - configType: new FormControl(SsoType.None), - memberDecryptionType: new FormControl(MemberDecryptionType.MasterPassword), - keyConnectorUrl: new FormControl(""), + configType: new FormControl(SsoType.None, { nonNullable: true }), + memberDecryptionType: new FormControl(MemberDecryptionType.MasterPassword, { + nonNullable: true, + }), + keyConnectorUrl: new FormControl("", { nonNullable: true }), openId: this.openIdForm, saml: this.samlForm, - enabled: new FormControl(false), + enabled: new FormControl(false, { nonNullable: true }), ssoIdentifier: new FormControl("", { + nonNullable: true, validators: [Validators.maxLength(50), Validators.required], }), }); @@ -235,7 +246,7 @@ export class SsoComponent implements OnInit, OnDestroy { this.ssoConfigForm .get("configType") - .valueChanges.pipe(takeUntil(this.destroy$)) + ?.valueChanges.pipe(takeUntil(this.destroy$)) .subscribe((newType: SsoType) => { if (newType === SsoType.OpenIdConnect) { this.openIdForm.enable(); @@ -251,8 +262,8 @@ export class SsoComponent implements OnInit, OnDestroy { this.samlForm .get("spSigningBehavior") - .valueChanges.pipe(takeUntil(this.destroy$)) - .subscribe(() => this.samlForm.get("idpX509PublicCert").updateValueAndValidity()); + ?.valueChanges.pipe(takeUntil(this.destroy$)) + .subscribe(() => this.samlForm.get("idpX509PublicCert")?.updateValueAndValidity()); this.route.params .pipe( @@ -286,6 +297,10 @@ export class SsoComponent implements OnInit, OnDestroy { this.memberDecryptionTypeValueChangesSubscription = null; try { + if (!this.organizationId) { + throw new Error("Load: Organization ID is not set"); + } + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.organization = await firstValueFrom( this.organizationService @@ -334,6 +349,11 @@ export class SsoComponent implements OnInit, OnDestroy { this.readOutErrors(); return; } + + if (!this.organizationId) { + throw new Error("Submit: Organization ID is not set"); + } + const request = new OrganizationSsoRequest(); request.enabled = this.enabledCtrl.value; // Return null instead of empty string to avoid duplicate id errors in database @@ -349,7 +369,6 @@ export class SsoComponent implements OnInit, OnDestroy { this.toastService.showToast({ variant: "success", - title: null, message: this.i18nService.t("ssoSettingsSaved"), }); } finally { @@ -407,16 +426,16 @@ export class SsoComponent implements OnInit, OnDestroy { return; } - this.keyConnectorUrl.markAsPending(); + this.keyConnectorUrlFormCtrl.markAsPending(); try { - await this.apiService.getKeyConnectorAlive(this.keyConnectorUrl.value); - this.keyConnectorUrl.updateValueAndValidity(); + await this.apiService.getKeyConnectorAlive(this.keyConnectorUrlFormCtrl.value); + this.keyConnectorUrlFormCtrl.updateValueAndValidity(); } catch { - this.keyConnectorUrl.setErrors({ + this.keyConnectorUrlFormCtrl.setErrors({ invalidUrl: { message: this.i18nService.t("keyConnectorTestFail") }, }); - this.keyConnectorUrl.markAllAsTouched(); + this.keyConnectorUrlFormCtrl.markAllAsTouched(); } this.haveTestedKeyConnector = true; @@ -442,12 +461,12 @@ export class SsoComponent implements OnInit, OnDestroy { get enableTestKeyConnector() { return ( this.ssoConfigForm.value?.memberDecryptionType === MemberDecryptionType.KeyConnector && - !Utils.isNullOrWhitespace(this.keyConnectorUrl?.value) + !Utils.isNullOrWhitespace(this.keyConnectorUrlFormCtrl?.value) ); } - get keyConnectorUrl() { - return this.ssoConfigForm.get("keyConnectorUrl"); + get keyConnectorUrlFormCtrl() { + return this.ssoConfigForm.controls?.keyConnectorUrl as FormControl; } /** @@ -502,6 +521,11 @@ export class SsoComponent implements OnInit, OnDestroy { organizationSsoRequest: OrganizationSsoRequest, ): Promise { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + + if (!this.organizationId) { + throw new Error("upsertOrganizationWithSsoChanges: Organization ID is not set"); + } + const currentOrganization = await firstValueFrom( this.organizationService .organizations$(userId) 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 2cb9140f174..c1d2cdda3e2 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 @@ -30,12 +30,14 @@ import { LogService } from "@bitwarden/logging"; import { DefaultAdminTaskService } from "../../vault/services/default-admin-task.service"; import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module"; +import { NewApplicationsDialogComponent } from "./activity/application-review-dialog/new-applications-dialog.component"; import { RiskInsightsComponent } from "./risk-insights.component"; import { AccessIntelligenceSecurityTasksService } from "./shared/security-tasks.service"; @NgModule({ - imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule], + imports: [RiskInsightsComponent, AccessIntelligenceRoutingModule, NewApplicationsDialogComponent], providers: [ + safeProvider(DefaultAdminTaskService), safeProvider({ provide: MemberCipherDetailsApiService, useClass: MemberCipherDetailsApiService, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html index 756907d24e6..73f98034f0a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.html @@ -2,7 +2,7 @@ {{ title }}
    @if (iconClass) { - + } {{ cardMetrics }}
    diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts index 84c763841b5..24d931165a7 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-card.component.ts @@ -39,11 +39,15 @@ export class ActivityCardComponent { /** * The text to display for the action link */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() actionText: string = ""; /** * Show action link */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showActionLink: boolean = false; /** @@ -54,6 +58,14 @@ export class ActivityCardComponent { // eslint-disable-next-line @angular-eslint/prefer-signals @Input() iconClass: string | null = null; + /** + * CSS class for icon color (e.g., "tw-text-success", "tw-text-muted"). + * Defaults to "tw-text-muted" if not provided. + */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() iconColorClass: string = "tw-text-muted"; + /** * Button text. If provided, a button will be displayed instead of a navigation link. */ @@ -78,6 +90,8 @@ export class ActivityCardComponent { /** * Event emitted when action link is clicked */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() actionClick = new EventEmitter(); constructor(private router: Router) {} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html index 9fffded215e..8cdb927ab65 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.html @@ -1,4 +1,4 @@ -@if (dataService.isLoading$ | async) { +@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) { } @else {
      diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts index 9689110866a..8a2b2825208 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/all-activity.component.ts @@ -1,10 +1,12 @@ import { Component, DestroyRef, inject, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, lastValueFrom } from "rxjs"; import { AllActivitiesService, + ApplicationHealthReportDetail, + ReportStatus, RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -12,6 +14,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getById } from "@bitwarden/common/platform/misc"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @@ -19,7 +22,7 @@ import { ApplicationsLoadingComponent } from "../shared/risk-insights-loading.co import { ActivityCardComponent } from "./activity-card.component"; import { PasswordChangeMetricComponent } from "./activity-cards/password-change-metric.component"; -import { NewApplicationsDialogComponent } from "./new-applications-dialog.component"; +import { NewApplicationsDialogComponent } from "./application-review-dialog/new-applications-dialog.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -39,11 +42,16 @@ export class AllActivityComponent implements OnInit { totalCriticalAppsCount = 0; totalCriticalAppsAtRiskCount = 0; newApplicationsCount = 0; - newApplications: string[] = []; + newApplications: ApplicationHealthReportDetail[] = []; passwordChangeMetricHasProgressBar = false; + allAppsHaveReviewDate = false; + isAllCaughtUp = false; + hasLoadedApplicationData = false; destroyRef = inject(DestroyRef); + protected ReportStatusEnum = ReportStatus; + constructor( private accountService: AccountService, protected activatedRoute: ActivatedRoute, @@ -69,8 +77,14 @@ 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.dataService.newApplications$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((newApps) => { + this.newApplications = newApps; + this.newApplicationsCount = newApps.length; + this.updateIsAllCaughtUp(); }); this.allActivitiesService.passwordChangeProgressMetricHasProgressBar$ @@ -78,34 +92,75 @@ export class AllActivityComponent implements OnInit { .subscribe((hasProgressBar) => { this.passwordChangeMetricHasProgressBar = hasProgressBar; }); + + this.dataService.enrichedReportData$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((enrichedData) => { + if (enrichedData?.applicationData && enrichedData.applicationData.length > 0) { + this.hasLoadedApplicationData = true; + // Check if all apps have a review date (not null and not undefined) + this.allAppsHaveReviewDate = enrichedData.applicationData.every( + (app) => app.reviewedDate !== null && app.reviewedDate !== undefined, + ); + } else { + this.hasLoadedApplicationData = enrichedData !== null; + this.allAppsHaveReviewDate = false; + } + this.updateIsAllCaughtUp(); + }); } } + /** + * Updates the isAllCaughtUp flag based on current state. + * Only shows "All caught up!" when: + * - Data has been loaded (hasLoadedApplicationData is true) + * - No new applications need review + * - All apps have a review date + */ + private updateIsAllCaughtUp(): void { + this.isAllCaughtUp = + this.hasLoadedApplicationData && + this.newApplicationsCount === 0 && + this.allAppsHaveReviewDate; + } + /** * Handles the review new applications button click. * Opens a dialog showing the list of new applications that can be marked as critical. */ - onReviewNewApplications = async () => { + async onReviewNewApplications() { + const organizationId = this.activatedRoute.snapshot.paramMap.get("organizationId"); + + if (!organizationId) { + return; + } + + // Pass organizationId via dialog data instead of having the dialog retrieve it from route. + // This ensures organizationId is immediately available when dialog opens, preventing + // timing issues where the dialog's checkForTasksToAssign() method runs before + // organizationId is populated via async route subscription. const dialogRef = NewApplicationsDialogComponent.open(this.dialogService, { newApplications: this.newApplications, + organizationId: organizationId as OrganizationId, }); - await firstValueFrom(dialogRef.closed); - }; + await lastValueFrom(dialogRef.closed); + } /** * Handles the "View at-risk members" link click. * Opens the at-risk members drawer for critical applications only. */ - onViewAtRiskMembers = async () => { + async onViewAtRiskMembers() { await this.dataService.setDrawerForCriticalAtRiskMembers("activityTabAtRiskMembers"); - }; + } /** * Handles the "View at-risk applications" link click. * Opens the at-risk applications drawer for critical applications only. */ - onViewAtRiskApplications = async () => { + async onViewAtRiskApplications() { await this.dataService.setDrawerForCriticalAtRiskApps("activityTabAtRiskApplications"); - }; + } } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html new file mode 100644 index 00000000000..875e86ed40b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.html @@ -0,0 +1,78 @@ +
      + +
      + +
      + + + {{ atRiskCriticalMembersCount() }} + {{ "membersWithAtRiskPasswords" | i18n }} + for + {{ criticalApplicationsCount() }} + {{ "criticalApplications" | i18n }} + + + +
      + +
      + + {{ atRiskCriticalMembersCount() }} + + + {{ "membersWithAtRiskPasswords" | i18n }} + +
      +
      + + +
      + +
      +
      + + {{ criticalApplicationsCount() }} + + + of {{ totalApplicationsCount() }} total + +
      + + {{ "criticalApplications" | i18n }} at-risk + +
      +
      +
      + + +
      + + + +
      + {{ "membersWillReceiveNotification" | i18n }} +
      +
      +
      +
      diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts new file mode 100644 index 00000000000..ac1b241a54b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/assign-tasks-view.component.ts @@ -0,0 +1,45 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, input } from "@angular/core"; + +import { + ButtonModule, + CalloutComponent, + IconTileComponent, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { DarkImageSourceDirective } from "@bitwarden/vault"; + +import { DefaultAdminTaskService } from "../../../../vault/services/default-admin-task.service"; +import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; + +/** + * Embedded component for displaying task assignment UI. + * Not a dialog - intended to be embedded within a parent dialog. + * + * Important: This component provides its own instances of AccessIntelligenceSecurityTasksService + * and DefaultAdminTaskService. These services are scoped to this component to ensure proper + * dependency injection when the component is dynamically rendered within the structure. + * Without these providers, Angular would throw NullInjectorError when trying to inject + * DefaultAdminTaskService, which is required by AccessIntelligenceSecurityTasksService. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "dirt-assign-tasks-view", + templateUrl: "./assign-tasks-view.component.html", + imports: [ + CommonModule, + ButtonModule, + TypographyModule, + I18nPipe, + IconTileComponent, + DarkImageSourceDirective, + CalloutComponent, + ], + providers: [AccessIntelligenceSecurityTasksService, DefaultAdminTaskService], +}) +export class AssignTasksViewComponent { + readonly criticalApplicationsCount = input.required(); + readonly totalApplicationsCount = input.required(); + readonly atRiskCriticalMembersCount = input.required(); +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html new file mode 100644 index 00000000000..6ac6ea768b5 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html @@ -0,0 +1,97 @@ + + + {{ + currentView() === DialogView.SelectApplications + ? ("prioritizeCriticalApplications" | i18n) + : ("assignTasksToMembers" | i18n) + }} + + +
      + @if (currentView() === DialogView.SelectApplications) { +
      +

      + {{ "selectCriticalApplicationsDescription" | i18n }} +

      + +
      + +

      + {{ "clickIconToMarkAppAsCritical" | i18n }} +

      +
      + + +
      + } + + @if (currentView() === DialogView.AssignTasks) { + + + } +
      + + @if (currentView() === DialogView.SelectApplications) { + + + + + } + @if (currentView() == DialogView.AssignTasks) { + + + + + + } +
      diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts new file mode 100644 index 00000000000..ff238e2636a --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts @@ -0,0 +1,276 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + Inject, + inject, + signal, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { from, switchMap } from "rxjs"; + +import { + ApplicationHealthReportDetail, + ApplicationHealthReportDetailEnriched, + OrganizationReportApplication, + RiskInsightsDataService, +} from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { + ButtonModule, + DIALOG_DATA, + DialogModule, + DialogRef, + DialogService, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { AccessIntelligenceSecurityTasksService } from "../../shared/security-tasks.service"; + +import { AssignTasksViewComponent } from "./assign-tasks-view.component"; +import { ReviewApplicationsViewComponent } from "./review-applications-view.component"; + +export interface NewApplicationsDialogData { + newApplications: ApplicationHealthReportDetail[]; + /** + * Organization ID is passed via dialog data instead of being retrieved from route params. + * This ensures organizationId is available immediately when the dialog opens, + * preventing async timing issues where user clicks "Mark as critical" before + * the route subscription has fired. + */ + organizationId: OrganizationId; +} + +/** + * View states for dialog navigation + * Using const object pattern per ADR-0025 (Deprecate TypeScript Enums) + */ +export const DialogView = Object.freeze({ + SelectApplications: "select", + AssignTasks: "assign", +} as const); + +export type DialogView = (typeof DialogView)[keyof typeof DialogView]; + +// Possible results for closing the dialog +export const NewApplicationsDialogResultType = Object.freeze({ + Close: "close", + Complete: "complete", +} as const); +export type NewApplicationsDialogResultType = + (typeof NewApplicationsDialogResultType)[keyof typeof NewApplicationsDialogResultType]; + +@Component({ + selector: "dirt-new-applications-dialog", + templateUrl: "./new-applications-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ButtonModule, + DialogModule, + TypographyModule, + I18nPipe, + AssignTasksViewComponent, + ReviewApplicationsViewComponent, + ], +}) +export class NewApplicationsDialogComponent { + destroyRef = inject(DestroyRef); + + // View state management + protected readonly currentView = signal(DialogView.SelectApplications); + // Expose DialogView constants to template + protected readonly DialogView = DialogView; + + // Review new applications view + // Applications selected to save as critical applications + protected readonly selectedApplications = signal>(new Set()); + + // Assign tasks variables + readonly criticalApplicationsCount = signal(0); + readonly totalApplicationsCount = signal(0); + readonly atRiskCriticalMembersCount = signal(0); + readonly saving = signal(false); + + // Loading states + protected readonly markingAsCritical = signal(false); + + constructor( + @Inject(DIALOG_DATA) protected dialogParams: NewApplicationsDialogData, + private dialogRef: DialogRef, + private dataService: RiskInsightsDataService, + private toastService: ToastService, + private i18nService: I18nService, + private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, + private logService: LogService, + ) {} + + /** + * Opens the new applications dialog + * @param dialogService The dialog service instance + * @param data Dialog data containing the list of new applications and organizationId + * @returns Dialog reference + */ + static open(dialogService: DialogService, data: NewApplicationsDialogData) { + return dialogService.open( + NewApplicationsDialogComponent, + { + data, + }, + ); + } + + getApplications() { + return this.dialogParams.newApplications; + } + + /** + * Toggles the selection state of an application. + * @param applicationName The application to toggle + */ + toggleSelection(applicationName: string) { + this.selectedApplications.update((current) => { + const temp = new Set(current); + if (temp.has(applicationName)) { + temp.delete(applicationName); + } else { + temp.add(applicationName); + } + return temp; + }); + } + + /** + * Toggles the selection state of all applications. + * If all are selected, unselect all. Otherwise, select all. + */ + toggleAll() { + const allApplicationNames = this.dialogParams.newApplications.map((app) => app.applicationName); + const allSelected = this.selectedApplications().size === allApplicationNames.length; + + this.selectedApplications.update(() => { + return allSelected ? new Set() : new Set(allApplicationNames); + }); + } + + handleMarkAsCritical() { + if (this.markingAsCritical() || this.saving()) { + return; // Prevent action if already processing + } + this.markingAsCritical.set(true); + + const onlyNewCriticalApplications = this.dialogParams.newApplications.filter((newApp) => + this.selectedApplications().has(newApp.applicationName), + ); + + const atRiskCriticalMembersCount = getUniqueMembers( + onlyNewCriticalApplications.flatMap((x) => x.atRiskMemberDetails), + ).length; + this.atRiskCriticalMembersCount.set(atRiskCriticalMembersCount); + + this.currentView.set(DialogView.AssignTasks); + this.markingAsCritical.set(false); + } + + /** + * Handles the assign tasks button click + */ + protected handleAssignTasks() { + if (this.saving()) { + return; // Prevent double-click + } + this.saving.set(true); + + // Create updated organization report application types with new review date + // and critical marking based on selected applications + const newReviewDate = new Date(); + const updatedApplications: OrganizationReportApplication[] = + this.dialogParams.newApplications.map((app) => ({ + applicationName: app.applicationName, + isCritical: this.selectedApplications().has(app.applicationName), + reviewedDate: newReviewDate, + })); + + // Save the application review dates and critical markings + this.dataService + .saveApplicationReviewStatus(updatedApplications) + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((updatedState) => { + // After initial save is complete, created the assigned tasks + // for at risk passwords + const updatedStateApplicationData = updatedState?.data?.applicationData || []; + // Manual enrich for type matching + // TODO Consolidate in model updates + const manualEnrichedApplications = + updatedState?.data?.reportData.map( + (application): ApplicationHealthReportDetailEnriched => ({ + ...application, + isMarkedAsCritical: updatedStateApplicationData.some( + (a) => a.applicationName == application.applicationName && a.isCritical, + ), + }), + ) || []; + return from( + this.accessIntelligenceSecurityTasksService.assignTasks( + this.dialogParams.organizationId, + manualEnrichedApplications, + ), + ); + }), + ) + .subscribe({ + next: () => { + this.toastService.showToast({ + variant: "success", + title: this.i18nService.t("applicationReviewSaved"), + message: this.i18nService.t("newApplicationsReviewed"), + }); + this.saving.set(false); + this.handleAssigningCompleted(); + }, + error: (error: unknown) => { + this.logService.error( + "[NewApplicationsDialog] Failed to save application review or assign tasks", + error, + ); + this.saving.set(false); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorSavingReviewStatus"), + message: this.i18nService.t("pleaseTryAgain"), + }); + }, + }); + } + + /** + * Closes the dialog when the "Cancel" button is selected + */ + handleCancel() { + this.dialogRef.close(NewApplicationsDialogResultType.Close); + } + + /** + * Handles the tasksAssigned event from the embedded component. + * Closes the dialog with success indicator. + */ + protected handleAssigningCompleted = () => { + // Tasks were successfully assigned - close dialog + this.dialogRef.close(NewApplicationsDialogResultType.Complete); + }; + + /** + * Handles the back event from the embedded component. + * Returns to the select applications view. + */ + protected onBack = () => { + this.currentView.set(DialogView.SelectApplications); + }; +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html new file mode 100644 index 00000000000..15d8160a55d --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.html @@ -0,0 +1,83 @@ +
      + + +
      + + + + + + + + + + + + @for (app of filteredApplications(); track app.applicationName) { + + + + + + + + } + +
      + + + {{ "application" | i18n }} + + {{ "atRiskPasswords" | i18n }} + + {{ "totalPasswords" | i18n }} + + {{ "atRiskMembers" | i18n }} +
      + + +
      + + {{ app.applicationName }} +
      +
      + {{ app.atRiskPasswordCount }} + + {{ app.passwordCount }} + + {{ app.atRiskMemberCount }} +
      +
      +
      diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.ts new file mode 100644 index 00000000000..7a269d3aa15 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/review-applications-view.component.ts @@ -0,0 +1,61 @@ +import { CommonModule } from "@angular/common"; +import { Component, input, output, ChangeDetectionStrategy, signal, computed } from "@angular/core"; +import { FormsModule } from "@angular/forms"; + +import { ApplicationHealthReportDetail } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { ButtonModule, DialogModule, SearchModule, TypographyModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: "dirt-review-applications-view", + templateUrl: "./review-applications-view.component.html", + imports: [ + CommonModule, + ButtonModule, + DialogModule, + FormsModule, + SearchModule, + TypographyModule, + I18nPipe, + ], +}) +export class ReviewApplicationsViewComponent { + readonly applications = input.required(); + readonly selectedApplications = input.required>(); + + protected readonly searchText = signal(""); + + // Filter applications based on search text + protected readonly filteredApplications = computed(() => { + const search = this.searchText().toLowerCase(); + if (!search) { + return this.applications(); + } + return this.applications().filter((app) => app.applicationName.toLowerCase().includes(search)); + }); + + // Return the selected applications from the view + onToggleSelection = output(); + onToggleAll = output(); + + toggleSelection(applicationName: string): void { + this.onToggleSelection.emit(applicationName); + } + + toggleAll(): void { + this.onToggleAll.emit(); + } + + isAllSelected(): boolean { + const filtered = this.filteredApplications(); + return ( + filtered.length > 0 && + filtered.every((app) => this.selectedApplications().has(app.applicationName)) + ); + } + + onSearchTextChanged(searchText: string): void { + this.searchText.set(searchText); + } +} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html deleted file mode 100644 index f7a5441030e..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.html +++ /dev/null @@ -1,71 +0,0 @@ - - {{ "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/activity/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts deleted file mode 100644 index 05b47da40ed..00000000000 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/new-applications-dialog.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -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[]; -} - -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@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/all-applications/all-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html index 1971b61d516..26beaf349a9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.html @@ -1,102 +1,74 @@ -@if (dataService.isLoading$ | async) { +@if ((dataService.reportStatus$ | async) == ReportStatusEnum.Loading) { } @else { @let drawerDetails = dataService.drawerDetails$ | async; - @if (!dataSource.data.length) { -
      - - -

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

      -
      - -
      - - {{ "noAppsInOrgDescription" | i18n }} - - {{ "learnMore" | i18n }} -
      -
      - - - -
      +
      +

      {{ "allApplications" | i18n }}

      +
      + + +
      +
      + +
      - } @else { -
      -

      {{ "allApplications" | i18n }}

      -
      - - -
      -
      - - -
      - -
      - } + +
      } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index 5fbc841778a..b4e2bf466b9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -1,7 +1,7 @@ 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 { ActivatedRoute, Router } from "@angular/router"; import { debounceTime } from "rxjs"; import { Security } from "@bitwarden/assets/svg"; @@ -10,7 +10,10 @@ import { RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; 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 { + OrganizationReportSummary, + ReportStatus, +} from "@bitwarden/bit-common/dirt/reports/risk-insights/models/report-models"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { @@ -53,6 +56,7 @@ export class AllApplicationsComponent implements OnInit { noItemsIcon = Security; protected markingAsCritical = false; protected applicationSummary: OrganizationReportSummary = createNewSummaryData(); + protected ReportStatusEnum = ReportStatus; destroyRef = inject(DestroyRef); @@ -61,6 +65,7 @@ export class AllApplicationsComponent implements OnInit { protected activatedRoute: ActivatedRoute, protected toastService: ToastService, protected dataService: RiskInsightsDataService, + private router: Router, // protected allActivitiesService: AllActivitiesService, ) { this.searchControl.valueChanges @@ -78,27 +83,15 @@ export class AllApplicationsComponent implements OnInit { this.dataSource.data = []; }, }); - - // TODO - // this.applicationSummary = this.reportService.generateApplicationsSummary(data); - // this.allActivitiesService.setAllAppsReportSummary(this.applicationSummary); } - goToCreateNewLoginItem = async () => { - // TODO: implement - this.toastService.showToast({ - variant: "warning", - title: "", - message: "Not yet implemented", - }); - }; - isMarkedAsCriticalItem(applicationName: string) { return this.selectedUrls.has(applicationName); } markAppsAsCritical = async () => { this.markingAsCritical = true; + const count = this.selectedUrls.size; this.dataService .saveCriticalApplications(Array.from(this.selectedUrls)) @@ -108,7 +101,7 @@ export class AllApplicationsComponent implements OnInit { this.toastService.showToast({ variant: "success", title: "", - message: this.i18nService.t("applicationsMarkedAsCriticalSuccess"), + message: this.i18nService.t("criticalApplicationsMarkedSuccess", count.toString()), }); this.selectedUrls.clear(); this.markingAsCritical = false; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html index cfcdf3a1841..0e757582855 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html @@ -1,31 +1,4 @@ -
      - - {{ "loading" | i18n }} -
      -
      - - -

      - {{ "noCriticalApplicationsTitle" | i18n }} -

      -
      - -

      - {{ "noCriticalApplicationsDescription" | i18n }} -

      -
      - - - -
      -
      -
      +

      {{ "criticalApplications" | i18n }}

      +
      +
      + +
      +
      + @if (videoSrc()) { + + } @else if (icon()) { +
      + +
      + } +
      +
      + +
      +
      + @if (videoSrc()) { + + } @else if (icon()) { +
      + +
      + } +
      +
      +
      diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts new file mode 100644 index 00000000000..54d97e984ec --- /dev/null +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/empty-state-card.component.ts @@ -0,0 +1,29 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, input, isDevMode, OnInit } from "@angular/core"; + +import { Icon } from "@bitwarden/assets/svg"; +import { ButtonModule, IconModule } from "@bitwarden/components"; + +@Component({ + selector: "empty-state-card", + templateUrl: "./empty-state-card.component.html", + imports: [CommonModule, IconModule, ButtonModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EmptyStateCardComponent implements OnInit { + readonly icon = input(null); + readonly videoSrc = input(null); + readonly title = input(""); + readonly description = input(""); + readonly benefits = input<[string, string][]>([]); + readonly buttonText = input(""); + readonly buttonAction = input<(() => void) | null>(null); + readonly buttonIcon = input(undefined); + + ngOnInit(): void { + if (!this.title() && isDevMode()) { + // eslint-disable-next-line no-console + console.warn("EmptyStateCardComponent: title is required for proper display"); + } + } +} 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 18df046b82c..b7d05c73768 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 @@ -1,64 +1,106 @@ -

      {{ "riskInsights" | i18n }}

      -
      - {{ "reviewAtRiskPasswords" | i18n }} -
      -
      - - @if (dataLastUpdated) { - {{ - "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a") - }} + @let status = dataService.reportStatus$ | async; + @let hasCiphers = dataService.hasCiphers$ | async; + @if (status == ReportStatusEnum.Initializing || hasCiphers === null) { + + + } @else { + + @if (!(dataService.hasReportData$ | async)) { +
      + @if (!hasCiphers) { + + + } @else { + + + } +
      } @else { - {{ "noReportRan" | i18n }} + +
      +
      +

      {{ "riskInsights" | i18n }}

      +
      + {{ "reviewAtRiskPasswords" | i18n }} +
      + @if (dataLastUpdated) { +
      + + {{ + "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a") + }} + @let isRunningReport = dataService.isGeneratingReport$ | async; + + + + + + +
      + } +
      + +
      + + @if (isRiskInsightsActivityTabFeatureEnabled) { + + + + } + + + + + + + {{ + "criticalApplicationsWithCount" + | i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 + }} + + + + +
      +
      } - @let isRunningReport = dataService.isGeneratingReport$ | async; - - - - - - -
      - - @if (isRiskInsightsActivityTabFeatureEnabled) { - - - - } - - - - - - - {{ - "criticalApplicationsWithCount" - | i18n: (dataService.criticalReportResults$ | async)?.reportData?.length ?? 0 - }} - - - - + } @if (dataService.drawerDetails$ | async; as drawerDetails) { 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 8e58ba22454..cde5d5c8c66 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,16 +2,18 @@ import { CommonModule } from "@angular/common"; import { Component, DestroyRef, OnDestroy, OnInit, inject } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; -import { EMPTY } from "rxjs"; +import { combineLatest, EMPTY } from "rxjs"; import { map, tap } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { DrawerType, + ReportStatus, RiskInsightsDataService, } from "@bitwarden/bit-common/dirt/reports/risk-insights"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { AsyncActionsModule, @@ -26,7 +28,9 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod import { AllActivityComponent } from "./activity/all-activity.component"; import { AllApplicationsComponent } from "./all-applications/all-applications.component"; import { CriticalApplicationsComponent } from "./critical-applications/critical-applications.component"; +import { EmptyStateCardComponent } from "./empty-state-card.component"; import { RiskInsightsTabType } from "./models/risk-insights.models"; +import { ApplicationsLoadingComponent } from "./shared/risk-insights-loading.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -38,6 +42,7 @@ import { RiskInsightsTabType } from "./models/risk-insights.models"; ButtonModule, CommonModule, CriticalApplicationsComponent, + EmptyStateCardComponent, JslibModule, HeaderModule, TabsModule, @@ -45,30 +50,46 @@ import { RiskInsightsTabType } from "./models/risk-insights.models"; DrawerBodyComponent, DrawerHeaderComponent, AllActivityComponent, + ApplicationsLoadingComponent, ], }) export class RiskInsightsComponent implements OnInit, OnDestroy { private destroyRef = inject(DestroyRef); private _isDrawerOpen: boolean = false; + protected ReportStatusEnum = ReportStatus; tabIndex: RiskInsightsTabType = RiskInsightsTabType.AllApps; isRiskInsightsActivityTabFeatureEnabled: boolean = false; appsCount: number = 0; - // Leaving this commented because it's not used but seems important - // notifiedMembersCount: number = 0; private organizationId: OrganizationId = "" as OrganizationId; dataLastUpdated: Date | null = null; + // Empty state properties + protected organizationName = ""; + + // Empty state computed properties + protected emptyStateBenefits: [string, string][] = [ + [this.i18nService.t("benefit1Title"), this.i18nService.t("benefit1Description")], + [this.i18nService.t("benefit2Title"), this.i18nService.t("benefit2Description")], + [this.i18nService.t("benefit3Title"), this.i18nService.t("benefit3Description")], + ]; + protected emptyStateVideoSrc: string | null = "/videos/risk-insights-mark-as-critical.mp4"; + + protected IMPORT_ICON = "bwi bwi-download"; + + // TODO: See https://github.com/bitwarden/clients/pull/16832#discussion_r2474523235 + constructor( private route: ActivatedRoute, private router: Router, private configService: ConfigService, protected dataService: RiskInsightsDataService, + protected i18nService: I18nService, ) { - this.route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { + this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(({ tabIndex }) => { this.tabIndex = !isNaN(Number(tabIndex)) ? Number(tabIndex) : RiskInsightsTabType.AllApps; }); @@ -89,7 +110,7 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { tap((orgId) => { if (orgId) { // Initialize Data Service - this.dataService.initializeForOrganization(orgId as OrganizationId); + void this.dataService.initializeForOrganization(orgId as OrganizationId); this.organizationId = orgId as OrganizationId; } else { return EMPTY; @@ -98,12 +119,17 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { ) .subscribe(); - // Subscribe to report result details - this.dataService.enrichedReportData$ + // Combine report data, vault items check, organization details, and generation state + // This declarative pattern ensures proper cleanup and prevents memory leaks + combineLatest([this.dataService.enrichedReportData$, this.dataService.organizationDetails$]) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((report) => { + .subscribe(([report, orgDetails]) => { + // Update report state this.appsCount = report?.reportData.length ?? 0; this.dataLastUpdated = report?.creationDate ?? null; + + // Update organization name + this.organizationName = orgDetails?.organizationName ?? ""; }); // Subscribe to drawer state changes @@ -166,4 +192,19 @@ export class RiskInsightsComponent implements OnInit, OnDestroy { } } } + + // Empty state methods + + // TODO: import data button (we have this) OR button for adding new login items + // we want to add this new button as a second option on the empty state card + + goToImportPage = () => { + void this.router.navigate([ + "/organizations", + this.organizationId, + "settings", + "tools", + "import", + ]); + }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html index ea41dd0aff3..79af3869d99 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html @@ -45,7 +45,11 @@ tabindex="0" [attr.aria-label]="'viewItem' | i18n" > - + + { const mockOrganizationId = "mockOrgId" as OrganizationId; const reportApiService = mock(); + const mockEncryptService = mock(); + const userId = newGuid() as UserId; + const mockAccountService = mockAccountServiceWith(userId); + const mockKeyService = mock(); let memberAccessReportService: MemberAccessReportService; const i18nMock = mock({ t(key) { @@ -20,10 +31,19 @@ describe("ImportService", () => { }); beforeEach(() => { + mockKeyService.orgKeys$.mockReturnValue( + of({ mockOrgId: new SymmetricCryptoKey(new Uint8Array(64)) }), + ); reportApiService.getMemberAccessData.mockImplementation(() => Promise.resolve(memberAccessReportsMock), ); - memberAccessReportService = new MemberAccessReportService(reportApiService, i18nMock); + memberAccessReportService = new MemberAccessReportService( + reportApiService, + i18nMock, + mockEncryptService, + mockKeyService, + mockAccountService, + ); }); describe("generateMemberAccessReportView", () => { diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts index caa27a75b82..f6d1139f619 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/services/member-access-report.service.ts @@ -1,11 +1,16 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable } from "@angular/core"; +import { firstValueFrom, map } from "rxjs"; import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Guid, OrganizationId } from "@bitwarden/common/types/guid"; +import { KeyService } from "@bitwarden/key-management"; import { getPermissionList, convertToPermission, @@ -22,6 +27,9 @@ export class MemberAccessReportService { constructor( private reportApiService: MemberAccessReportApiService, private i18nService: I18nService, + private encryptService: EncryptService, + private keyService: KeyService, + private accountService: AccountService, ) {} /** * Transforms user data into a MemberAccessReportView. @@ -78,14 +86,22 @@ export class MemberAccessReportService { async generateUserReportExportItems( organizationId: OrganizationId, ): Promise { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const organizationSymmetricKey = await firstValueFrom( + this.keyService.orgKeys$(activeUserId).pipe(map((keys) => keys[organizationId])), + ); + const memberAccessReports = await this.reportApiService.getMemberAccessData(organizationId); const collectionNames = memberAccessReports.map((item) => item.collectionName.encryptedString); const collectionNameMap = new Map(collectionNames.map((col) => [col, ""])); for await (const key of collectionNameMap.keys()) { - const decrypted = new EncString(key); - await decrypted.decrypt(organizationId); - collectionNameMap.set(key, decrypted.decryptedValue); + const encryptedCollectionName = new EncString(key); + const collectionName = await this.encryptService.decryptString( + encryptedCollectionName, + organizationSymmetricKey, + ); + collectionNameMap.set(key, collectionName); } const exportItems = memberAccessReports.map((report) => { diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts index a909baf1c77..884cbd10cac 100644 --- a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout-confirmation-never.component.ts @@ -3,6 +3,8 @@ import { Component } from "@angular/core"; import { DialogRef, DialogService } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ imports: [SharedModule], templateUrl: "./session-timeout-confirmation-never.component.html", diff --git a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts index 3e40b9f0d80..9c6129f64df 100644 --- a/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts +++ b/bitwarden_license/bit-web/src/app/key-management/policies/session-timeout.component.ts @@ -40,6 +40,8 @@ export class SessionTimeoutPolicy extends BasePolicyEditDefinition { const DEFAULT_HOURS = 8; const DEFAULT_MINUTES = 0; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "session-timeout.component.html", imports: [SharedModule], diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/guards/project-access.guard.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/guards/project-access.guard.spec.ts index f442c85f46d..79c022e8fd2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/guards/project-access.guard.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/guards/project-access.guard.spec.ts @@ -20,12 +20,16 @@ import { ProjectService } from "../projects/project.service"; import { projectAccessGuard } from "./project-access.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, }) export class GuardedRouteTestComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, 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 978cfeb1aa4..0e8c46c8864 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 @@ -21,6 +21,8 @@ import { IntegrationGridComponent } from "../../dirt/organization-integrations/i import { IntegrationsComponent } from "./integrations.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-header", template: "
      ", @@ -28,6 +30,8 @@ import { IntegrationsComponent } from "./integrations.component"; }) class MockHeaderComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-new-menu", template: "
      ", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts index b2279775191..37c7a93d27f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/integrations/integrations.component.ts @@ -3,6 +3,8 @@ import { Component } from "@angular/core"; import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration"; import { IntegrationType } from "@bitwarden/common/enums"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-integrations", templateUrl: "./integrations.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.ts index b50e586c337..00a4c6cc4d4 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.ts @@ -1,5 +1,7 @@ import { Component, OnInit } from "@angular/core"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-layout", templateUrl: "./layout.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts index a714bc0d543..be9124ee3e1 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts @@ -31,6 +31,8 @@ import { ServiceAccountService } from "../service-accounts/service-account.servi import { SecretsManagerPortingApiService } from "../settings/services/sm-porting-api.service"; import { CountService } from "../shared/counts/count.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-navigation", templateUrl: "./navigation.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index e301c0462c3..12a5432c4b8 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -75,6 +75,8 @@ type OrganizationTasks = { createServiceAccount: boolean; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-overview", templateUrl: "./overview.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.ts index 6b71c81f09e..0691ed9dd73 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/section.component.ts @@ -1,11 +1,15 @@ import { Component, Input } from "@angular/core"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-section", templateUrl: "./section.component.html", standalone: false, }) export class SectionComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() open = true; /** diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts index 8cdb1bb4d69..3ddf3233b38 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts @@ -25,6 +25,8 @@ export interface ProjectDeleteOperation { projects: ProjectListView[]; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./project-delete-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts index 819f2107fcf..2f6b2229d75 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts @@ -25,6 +25,8 @@ export interface ProjectOperation { projectId?: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./project-dialog.component.html", standalone: false, 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 ec7397a22a8..49b016e921c 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 @@ -24,6 +24,8 @@ import { import { ApItemEnum } from "../../shared/access-policies/access-policy-selector/models/enums/ap-item.enum"; import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-project-people", templateUrl: "./project-people.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts index 5c83f784431..7112a28010f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts @@ -41,6 +41,8 @@ import { import { SecretService } from "../../secrets/secret.service"; import { SecretsListComponent } from "../../shared/secrets-list.component"; import { ProjectService } from "../project.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-project-secrets", templateUrl: "./project-secrets.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts index fc3a489bce9..e2fd8556621 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts @@ -22,6 +22,8 @@ import { } from "../../shared/access-policies/access-policy-selector/models/ap-item-view.type"; import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-project-service-accounts", templateUrl: "./project-service-accounts.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts index c79ebd733c0..7c1812e3f26 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts @@ -34,6 +34,8 @@ import { } from "../dialog/project-dialog.component"; import { ProjectService } from "../project.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-project", templateUrl: "./project.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts index 81a568f0c65..10e75cfb75a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts @@ -40,6 +40,8 @@ import { } from "../dialog/project-dialog.component"; import { ProjectService } from "../project.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-projects", templateUrl: "./projects.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts index 6340cc42f3b..344a20f02c2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts @@ -18,6 +18,8 @@ export interface SecretDeleteOperation { secrets: SecretListView[]; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./secret-delete.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts index 9172d44965d..6376b58423d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts @@ -67,6 +67,8 @@ export interface SecretOperation { organizationEnabled: boolean; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./secret-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-view-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-view-dialog.component.ts index b719014a382..ace8db4e6ba 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-view-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-view-dialog.component.ts @@ -10,6 +10,8 @@ export interface SecretViewDialogParams { secretId: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./secret-view-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts index ca093f449c9..46cccb1d95d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts @@ -34,6 +34,8 @@ import { } from "./dialog/secret-view-dialog.component"; import { SecretService } from "./secret.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-secrets", templateUrl: "./secrets.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts index a714729d96f..7a8c0b37408 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts @@ -5,12 +5,16 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { AccessTokenView } from "../models/view/access-token.view"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-access-list", templateUrl: "./access-list.component.html", standalone: false, }) export class AccessListComponent { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get tokens(): AccessTokenView[] { return this._tokens; @@ -21,7 +25,11 @@ export class AccessListComponent { } private _tokens: AccessTokenView[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() newAccessTokenEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() revokeAccessTokensEvent = new EventEmitter(); protected selection = new SelectionModel(true, []); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts index b9643ce8fd8..4e9069cd6cb 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts @@ -24,6 +24,8 @@ import { ServiceAccountService } from "../service-account.service"; import { AccessService } from "./access.service"; import { AccessTokenCreateDialogComponent } from "./dialogs/access-token-create-dialog.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-access-tokens", templateUrl: "./access-tokens.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts index dfbe0a1511d..3aca93572ef 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.ts @@ -15,6 +15,8 @@ export interface AccessTokenOperation { serviceAccountView: ServiceAccountView; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./access-token-create-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts index 0259b8d6e90..cf5118c5062 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts @@ -12,6 +12,8 @@ export interface AccessTokenDetails { accessToken: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./access-token-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts index 891501874ff..a0db42d03b0 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts @@ -18,6 +18,8 @@ import { Subject, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-expiration-options", templateUrl: "./expiration-options.component.html", @@ -40,8 +42,12 @@ export class ExpirationOptionsComponent { private destroy$ = new Subject(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() expirationDayOptions: number[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set touched(val: boolean) { if (val) { this.form.markAllAsTouched(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.ts index f85cde90306..18ef397c6ae 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/config/config.component.ts @@ -24,6 +24,8 @@ class ServiceAccountConfig { projects: ProjectListView[]; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-account-config", templateUrl: "./config.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts index 5edc57d8c74..638ee6862a3 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts @@ -25,6 +25,8 @@ export interface ServiceAccountDeleteOperation { serviceAccounts: ServiceAccountView[]; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./service-account-delete-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts index 250e0870ecf..5c6072807a6 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts @@ -24,6 +24,8 @@ export interface ServiceAccountOperation { organizationEnabled: boolean; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./service-account-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts index 2e364df1423..5968933064d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/event-logs/service-accounts-events.component.ts @@ -17,6 +17,8 @@ import { EventExportService } from "@bitwarden/web-vault/app/tools/event-export" import { ServiceAccountEventLogApiService } from "./service-account-event-log-api.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-accounts-events", templateUrl: "./service-accounts-events.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts index e0bcad8d6e9..e7b258ed1c2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/guards/service-account-access.guard.spec.ts @@ -20,12 +20,16 @@ import { ServiceAccountService } from "../service-account.service"; import { serviceAccountAccessGuard } from "./service-account-access.guard"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, }) export class GuardedRouteTestComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: "", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts index 4449757167d..42ab2ec613b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts @@ -25,6 +25,8 @@ import { ApItemEnum } from "../../shared/access-policies/access-policy-selector/ import { ApPermissionEnum } from "../../shared/access-policies/access-policy-selector/models/enums/ap-permission.enum"; import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-account-people", templateUrl: "./service-account-people.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts index af334b22c63..6d4490bad3c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts @@ -22,6 +22,8 @@ import { } from "../../shared/access-policies/access-policy-selector/models/ap-item-view.type"; import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-account-projects", templateUrl: "./service-account-projects.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts index 5eb074e3e99..285f03acb01 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts @@ -15,6 +15,8 @@ import { AccessService } from "./access/access.service"; import { AccessTokenCreateDialogComponent } from "./access/dialogs/access-token-create-dialog.component"; import { ServiceAccountService } from "./service-account.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-account", templateUrl: "./service-account.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts index 21f11d6bfed..4febda9ea28 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts @@ -21,6 +21,8 @@ import { ServiceAccountView, } from "../models/view/service-account.view"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-accounts-list", templateUrl: "./service-accounts-list.component.html", @@ -29,6 +31,8 @@ import { export class ServiceAccountsListComponent implements OnDestroy, OnInit { protected dataSource = new TableDataSource(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get serviceAccounts(): ServiceAccountSecretsDetailsView[] { return this._serviceAccounts; @@ -40,15 +44,25 @@ export class ServiceAccountsListComponent implements OnDestroy, OnInit { } private _serviceAccounts: ServiceAccountSecretsDetailsView[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set search(search: string) { this.selection.clear(); this.dataSource.filter = search; } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() newServiceAccountEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() deleteServiceAccountsEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onServiceAccountCheckedEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() editServiceAccountEvent = new EventEmitter(); private destroy$: Subject = new Subject(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts index 345fff03876..5d6b4fd49de 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts @@ -30,6 +30,8 @@ import { } from "./dialog/service-account-dialog.component"; import { ServiceAccountService } from "./service-account.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-service-accounts", templateUrl: "./service-accounts.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts index 0bed0355a8c..85e054d998b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts @@ -10,6 +10,8 @@ export interface SecretsManagerImportErrorDialogOperation { error: SecretsManagerImportError; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./sm-import-error-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts index c2b726803c5..e2b66d9ffa6 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts @@ -26,6 +26,8 @@ type ExportFormat = { fileExtension: string; }; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-export", templateUrl: "./sm-export.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts index 65075d12bf6..c2ffe5536b8 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts @@ -18,6 +18,8 @@ import { import { SecretsManagerImportError } from "../models/error/sm-import-error"; import { SecretsManagerPortingApiService } from "../services/sm-porting-api.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-import", templateUrl: "./sm-import.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.ts index fba3ff03ee0..2bb4d6cb37f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy-selector/access-policy-selector.component.ts @@ -20,6 +20,8 @@ import { ApItemViewType } from "./models/ap-item-view.type"; import { ApItemEnumUtil, ApItemEnum } from "./models/enums/ap-item.enum"; import { ApPermissionEnum } from "./models/enums/ap-permission.enum"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-access-policy-selector", templateUrl: "access-policy-selector.component.html", @@ -108,23 +110,43 @@ export class AccessPolicySelectorComponent implements ControlValueAccessor, OnIn disabled: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() loading: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() addButtonMode: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() label: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() hint: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() columnTitle: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() emptyMessage: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() permissionList = [ { perm: ApPermissionEnum.CanRead, labelId: "canRead" }, { perm: ApPermissionEnum.CanReadWrite, labelId: "canReadWrite" }, ]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() initialPermission = ApPermissionEnum.CanRead; // Pass in a static permission that wil be the only option for a given selector instance. // Will ignore permissionList and initialPermission. + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() staticPermission: ApPermissionEnum; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get items(): ApItemViewType[] { return this.selectionList.allItems; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.ts index 9d2a3715e16..0f0991d52a9 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-confirmation-dialog.component.ts @@ -22,6 +22,8 @@ export enum BulkConfirmationResult { Cancel, } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-bulk-confirmation-dialog", templateUrl: "./bulk-confirmation-dialog.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-status-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-status-dialog.component.ts index fc7890f1654..8e27b551e55 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-status-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/dialogs/bulk-status-dialog.component.ts @@ -18,6 +18,8 @@ export class BulkOperationStatus { errorMessage?: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./bulk-status-dialog.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts index 18823130d22..6c3d4228c06 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts @@ -26,6 +26,8 @@ import { ServiceAccountOperation, } from "../service-accounts/dialog/service-account-dialog.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-new-menu", templateUrl: "./new-menu.component.html", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts index 6777df7ef7a..f2e0d48fe1d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/org-suspended.component.ts @@ -10,6 +10,8 @@ import { import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./org-suspended.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.ts index 31114bcd1c4..5d3c806f386 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.ts @@ -20,12 +20,16 @@ import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/o import { ProjectListView } from "../models/view/project-list.view"; import { ProjectView } from "../models/view/project.view"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-projects-list", templateUrl: "./projects-list.component.html", standalone: false, }) export class ProjectsListComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get projects(): ProjectListView[] { return this._projects; @@ -40,17 +44,29 @@ export class ProjectsListComponent implements OnInit { protected isAdmin$: Observable; private destroy$: Subject = new Subject(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() showMenus?: boolean = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set search(search: string) { this.selection.clear(); this.dataSource.filter = search; } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() editProjectEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() deleteProjectEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() newProjectEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() copiedProjectUUIdEvent = new EventEmitter(); selection = new SelectionModel(true, []); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts index 4ef7dbf22e7..05e38baff69 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts @@ -21,6 +21,8 @@ import { SecretListView } from "../models/view/secret-list.view"; import { SecretView } from "../models/view/secret.view"; import { SecretService } from "../secrets/secret.service"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-secrets-list", templateUrl: "./secrets-list.component.html", @@ -29,6 +31,8 @@ import { SecretService } from "../secrets/secret.service"; export class SecretsListComponent implements OnDestroy, OnInit { protected dataSource = new TableDataSource(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() get secrets(): SecretListView[] { return this._secrets; @@ -40,22 +44,44 @@ export class SecretsListComponent implements OnDestroy, OnInit { } private _secrets: SecretListView[]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set search(search: string) { this.selection.clear(); this.dataSource.filter = search; } + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() trash: boolean; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() editSecretEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() viewSecretEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() copySecretNameEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() copySecretValueEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() copySecretUuidEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() onSecretCheckedEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() deleteSecretsEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() newSecretEvent = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref @Output() restoreSecretsEvent = new EventEmitter(); private destroy$: Subject = new Subject(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts index 29f9a85250c..521550185f1 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts @@ -13,6 +13,8 @@ export interface SecretHardDeleteOperation { organizationId: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./secret-hard-delete.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts index 712757445be..034b6f8de00 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts @@ -13,6 +13,8 @@ export interface SecretRestoreOperation { organizationId: string; } +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ templateUrl: "./secret-restore.component.html", standalone: false, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts index 4392ae8b1bb..b4da7769127 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts @@ -21,6 +21,8 @@ import { SecretRestoreOperation, } from "./dialog/secret-restore.component"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "sm-trash", templateUrl: "./trash.component.html", diff --git a/bitwarden_license/bit-web/tsconfig.build.json b/bitwarden_license/bit-web/tsconfig.build.json index 66c475051ed..58acbf09392 100644 --- a/bitwarden_license/bit-web/tsconfig.build.json +++ b/bitwarden_license/bit-web/tsconfig.build.json @@ -1,6 +1,8 @@ { "extends": "./tsconfig", "files": [ + "../../bitwarden_license/bit-common/src/platform/sdk/sdk-alias.d.ts", + "../../apps/web/src/polyfills.ts", "../../apps/web/src/main.ts", "../../apps/web/src/theme.ts", diff --git a/bitwarden_license/bit-web/tsconfig.json b/bitwarden_license/bit-web/tsconfig.json index 0836d3d54ad..8c19f771a26 100644 --- a/bitwarden_license/bit-web/tsconfig.json +++ b/bitwarden_license/bit-web/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../apps/web/tsconfig", "files": [ + "../../bitwarden_license/bit-common/src/platform/sdk/sdk-alias.d.ts", + "../../apps/web/src/polyfills.ts", "../../apps/web/src/main.ts", "../../apps/web/src/theme.ts", diff --git a/eslint.config.mjs b/eslint.config.mjs index d8b2094c37c..6c362a4dc43 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -72,9 +72,9 @@ export default tseslint.config( "@angular-eslint/no-output-on-prefix": 0, "@angular-eslint/no-output-rename": 0, "@angular-eslint/no-outputs-metadata-property": 0, - "@angular-eslint/prefer-on-push-component-change-detection": "warn", - "@angular-eslint/prefer-output-emitter-ref": "warn", - "@angular-eslint/prefer-signals": "warn", + "@angular-eslint/prefer-on-push-component-change-detection": "error", + "@angular-eslint/prefer-output-emitter-ref": "error", + "@angular-eslint/prefer-signals": "error", "@angular-eslint/prefer-standalone": 0, "@angular-eslint/use-lifecycle-interface": "error", "@angular-eslint/use-pipe-transform-interface": 0, @@ -160,6 +160,11 @@ export default tseslint.config( // allow module index import except: ["**/state/index.ts"], }, + { + target: ["libs/**/*"], + from: ["apps/**/*"], + message: "Libs should not import app-specific code.", + }, ], }, ], @@ -688,6 +693,12 @@ function buildNoRestrictedImports(additionalForbiddenPatterns = [], skipPlatform return [ "error", { + paths: [ + { + name: "@bitwarden/commercial-sdk-internal", + message: "Use @bitwarden/sdk-internal instead.", + }, + ], patterns: [ ...(skipPlatform ? [] : ["**/platform/**/internal", "**/platform/messaging/**"]), "**/src/**/*", // Prevent relative imports across libs. diff --git a/libs/angular/src/auth/constants/auth-route.constant.ts b/libs/angular/src/auth/constants/auth-route.constant.ts new file mode 100644 index 00000000000..caacfbbc4a8 --- /dev/null +++ b/libs/angular/src/auth/constants/auth-route.constant.ts @@ -0,0 +1,21 @@ +/** + * Constants for auth team owned full routes which are shared across clients. + */ +export const AuthRoute = Object.freeze({ + SignUp: "signup", + FinishSignUp: "finish-signup", + Login: "login", + LoginWithDevice: "login-with-device", + AdminApprovalRequested: "admin-approval-requested", + PasswordHint: "hint", + LoginInitiated: "login-initiated", + SetInitialPassword: "set-initial-password", + ChangePassword: "change-password", + Sso: "sso", + TwoFactor: "2fa", + AuthenticationTimeout: "authentication-timeout", + NewDeviceVerification: "device-verification", + LoginWithPasskey: "login-with-passkey", +} as const); + +export type AuthRoute = (typeof AuthRoute)[keyof typeof AuthRoute]; diff --git a/libs/angular/src/auth/constants/index.ts b/libs/angular/src/auth/constants/index.ts new file mode 100644 index 00000000000..d8e362734c1 --- /dev/null +++ b/libs/angular/src/auth/constants/index.ts @@ -0,0 +1 @@ +export * from "./auth-route.constant"; diff --git a/libs/angular/src/billing/components/index.ts b/libs/angular/src/billing/components/index.ts index 34e1d27c1ed..574ed01e073 100644 --- a/libs/angular/src/billing/components/index.ts +++ b/libs/angular/src/billing/components/index.ts @@ -1 +1,2 @@ export * from "./premium.component"; +export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component"; diff --git a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts index e8a829d458d..8890584186d 100644 --- a/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts +++ b/libs/angular/src/billing/components/premium-badge/premium-badge.component.ts @@ -10,7 +10,13 @@ import { BadgeModule } from "@bitwarden/components"; selector: "app-premium-badge", standalone: true, template: ` - `, @@ -21,7 +27,9 @@ export class PremiumBadgeComponent { constructor(private premiumUpgradePromptService: PremiumUpgradePromptService) {} - async promptForPremium() { + async promptForPremium(event: Event) { + event.stopPropagation(); + event.preventDefault(); await this.premiumUpgradePromptService.promptForPremium(this.organizationId()); } } diff --git a/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts b/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts index 08259358f30..bf50d16d3c4 100644 --- a/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts +++ b/libs/angular/src/billing/components/premium-badge/premium-badge.stories.ts @@ -5,18 +5,11 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { MessageSender } from "@bitwarden/common/platform/messaging"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { BadgeModule, I18nMockService } from "@bitwarden/components"; import { PremiumBadgeComponent } from "./premium-badge.component"; -class MockMessagingService implements MessageSender { - send = () => { - alert("Clicked on badge"); - }; -} - export default { title: "Billing/Premium Badge", component: PremiumBadgeComponent, @@ -40,12 +33,6 @@ export default { }); }, }, - { - provide: MessageSender, - useFactory: () => { - return new MockMessagingService(); - }, - }, { provide: BillingAccountProfileStateService, useValue: { diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html new file mode 100644 index 00000000000..99e1c173c2a --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html @@ -0,0 +1,98 @@ +@if (cardDetails$ | async; as cardDetails) { +
      +
      + +
      +
      +
      +
      +

      + {{ "upgradeToPremium" | i18n }} +

      +
      + + +
      +

      + {{ cardDetails.tagline }} +

      +
      + + +
      +
      + {{ + cardDetails.price.amount | currency: "$" + }} + + / {{ cardDetails.price.cadence }} + +
      +
      + + +
      + +
      + + +
      + @if (cardDetails.features.length > 0) { +
        + @for (feature of cardDetails.features; track feature) { +
      • + + {{ + feature + }} +
      • + } +
      + } +
      +
      +
      +
      +} @else { + + {{ "loading" | i18n }} +} diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts new file mode 100644 index 00000000000..f2991cc41b4 --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.spec.ts @@ -0,0 +1,240 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { firstValueFrom, of, throwError } from "rxjs"; + +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { + EnvironmentService, + Region, +} 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 { DialogRef, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component"; + +describe("PremiumUpgradeDialogComponent", () => { + let component: PremiumUpgradeDialogComponent; + let fixture: ComponentFixture; + let mockDialogRef: jest.Mocked; + let mockSubscriptionPricingService: jest.Mocked; + let mockI18nService: jest.Mocked; + let mockToastService: jest.Mocked; + let mockEnvironmentService: jest.Mocked; + let mockPlatformUtilsService: jest.Mocked; + let mockLogService: jest.Mocked; + + const mockPremiumTier: PersonalSubscriptionPricingTier = { + id: PersonalSubscriptionPricingTierIds.Premium, + name: "Premium", + description: "Advanced features for power users", + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "standalone", + annualPrice: 10, + annualPricePerAdditionalStorageGB: 4, + features: [ + { key: "feature1", value: "Feature 1" }, + { key: "feature2", value: "Feature 2" }, + { key: "feature3", value: "Feature 3" }, + ], + }, + }; + + const mockFamiliesTier: PersonalSubscriptionPricingTier = { + id: PersonalSubscriptionPricingTierIds.Families, + name: "Families", + description: "Family plan", + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "packaged", + users: 6, + annualPrice: 40, + annualPricePerAdditionalStorageGB: 4, + features: [{ key: "featureA", value: "Feature A" }], + }, + }; + + beforeEach(async () => { + mockDialogRef = { + close: jest.fn(), + } as any; + + mockSubscriptionPricingService = { + getPersonalSubscriptionPricingTiers$: jest.fn(), + } as any; + + mockI18nService = { + t: jest.fn((key: string) => key), + } as any; + + mockToastService = { + showToast: jest.fn(), + } as any; + + mockEnvironmentService = { + environment$: of({ + getWebVaultUrl: () => "https://vault.bitwarden.com", + getRegion: () => Region.US, + }), + } as any; + + mockPlatformUtilsService = { + launchUri: jest.fn(), + } as any; + + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of([mockPremiumTier, mockFamiliesTier]), + ); + + mockLogService = { + error: jest.fn(), + } as any; + + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, PremiumUpgradeDialogComponent, CdkTrapFocus], + providers: [ + { provide: DialogRef, useValue: mockDialogRef }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: mockSubscriptionPricingService, + }, + { provide: I18nService, useValue: mockI18nService }, + { provide: ToastService, useValue: mockToastService }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: LogService, useValue: mockLogService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(PremiumUpgradeDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should emit cardDetails$ observable with Premium tier data", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$).toHaveBeenCalled(); + expect(cardDetails).toBeDefined(); + expect(cardDetails?.title).toBe("Premium"); + }); + + it("should filter to Premium tier only", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(cardDetails?.title).toBe("Premium"); + expect(cardDetails?.title).not.toBe("Families"); + }); + + it("should map Premium tier to card details correctly", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(cardDetails?.title).toBe("Premium"); + expect(cardDetails?.tagline).toBe("Advanced features for power users"); + expect(cardDetails?.price.amount).toBe(10 / 12); + expect(cardDetails?.price.cadence).toBe("monthly"); + expect(cardDetails?.button.text).toBe("upgradeNow"); + expect(cardDetails?.button.type).toBe("primary"); + expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2", "Feature 3"]); + }); + + it("should use i18nService for button text", async () => { + const cardDetails = await firstValueFrom(component["cardDetails$"]); + + expect(mockI18nService.t).toHaveBeenCalledWith("upgradeNow"); + expect(cardDetails?.button.text).toBe("upgradeNow"); + }); + + describe("upgrade()", () => { + it("should launch URI with query parameter for cloud-hosted environments", async () => { + mockEnvironmentService.environment$ = of({ + getWebVaultUrl: () => "https://vault.bitwarden.com", + getRegion: () => Region.US, + } as any); + + await component["upgrade"](); + + expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( + "https://vault.bitwarden.com/#/settings/subscription/premium?callToAction=upgradeToPremium", + ); + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it("should launch URI without query parameter for self-hosted environments", async () => { + mockEnvironmentService.environment$ = of({ + getWebVaultUrl: () => "https://self-hosted.example.com", + getRegion: () => Region.SelfHosted, + } as any); + + await component["upgrade"](); + + expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( + "https://self-hosted.example.com/#/settings/subscription/premium", + ); + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it("should launch URI with query parameter for EU cloud region", async () => { + mockEnvironmentService.environment$ = of({ + getWebVaultUrl: () => "https://vault.bitwarden.eu", + getRegion: () => Region.EU, + } as any); + + await component["upgrade"](); + + expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith( + "https://vault.bitwarden.eu/#/settings/subscription/premium?callToAction=upgradeToPremium", + ); + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + }); + + it("should close dialog when close button clicked", () => { + component["close"](); + + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + describe("error handling", () => { + it("should show error toast and return EMPTY and close dialog when getPersonalSubscriptionPricingTiers$ throws an error", (done) => { + const error = new Error("Service error"); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + throwError(() => error), + ); + + const errorFixture = TestBed.createComponent(PremiumUpgradeDialogComponent); + const errorComponent = errorFixture.componentInstance; + errorFixture.detectChanges(); + + const cardDetails$ = errorComponent["cardDetails$"]; + + cardDetails$.subscribe({ + next: () => { + done.fail("Observable should not emit any values"); + }, + complete: () => { + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: "error", + message: "unexpectedError", + }); + expect(mockDialogRef.close).toHaveBeenCalled(); + done(); + }, + error: (err: unknown) => done.fail(`Observable should not error: ${err}`), + }); + }); + }); +}); diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts new file mode 100644 index 00000000000..7ba09192d3c --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.stories.ts @@ -0,0 +1,117 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { of } from "rxjs"; + +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + ButtonModule, + DialogModule, + DialogRef, + ToastOptions, + ToastService, + TypographyModule, +} from "@bitwarden/components"; + +import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component"; + +const mockPremiumTier: PersonalSubscriptionPricingTier = { + id: PersonalSubscriptionPricingTierIds.Premium, + name: "Premium", + description: "Complete online security", + availableCadences: [SubscriptionCadenceIds.Annually], + passwordManager: { + type: "standalone", + annualPrice: 10, + annualPricePerAdditionalStorageGB: 4, + features: [ + { key: "builtInAuthenticator", value: "Built-in authenticator" }, + { key: "secureFileStorage", value: "Secure file storage" }, + { key: "emergencyAccess", value: "Emergency access" }, + { key: "breachMonitoring", value: "Breach monitoring" }, + { key: "andMoreFeatures", value: "And more!" }, + ], + }, +}; + +export default { + title: "Billing/Premium Upgrade Dialog", + component: PremiumUpgradeDialogComponent, + description: "A dialog for upgrading to Premium subscription", + decorators: [ + moduleMetadata({ + imports: [DialogModule, ButtonModule, TypographyModule], + providers: [ + { + provide: DialogRef, + useValue: { + close: () => {}, + }, + }, + { + provide: SubscriptionPricingServiceAbstraction, + useValue: { + getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTier]), + }, + }, + { + provide: ToastService, + useValue: { + showToast: (options: ToastOptions) => {}, + }, + }, + { + provide: EnvironmentService, + useValue: { + cloudWebVaultUrl$: of("https://vault.bitwarden.com"), + }, + }, + { + provide: PlatformUtilsService, + useValue: { + launchUri: (uri: string) => {}, + }, + }, + { + provide: I18nService, + useValue: { + t: (key: string) => { + switch (key) { + case "upgradeNow": + return "Upgrade Now"; + case "month": + return "month"; + case "upgradeToPremium": + return "Upgrade To Premium"; + default: + return key; + } + }, + }, + }, + { + provide: LogService, + useValue: { + error: {}, + }, + }, + ], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=931-17785&t=xOhvwjYLpjoMPgND-1", + }, + }, +} as Meta; + +type Story = StoryObj; +export const Default: Story = {}; diff --git a/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts new file mode 100644 index 00000000000..d20c0d668c4 --- /dev/null +++ b/libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts @@ -0,0 +1,123 @@ +import { CdkTrapFocus } from "@angular/cdk/a11y"; +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; +import { + PersonalSubscriptionPricingTier, + PersonalSubscriptionPricingTierIds, + SubscriptionCadence, + SubscriptionCadenceIds, +} from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { + EnvironmentService, + Region, +} 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 { + ButtonModule, + ButtonType, + DialogModule, + DialogRef, + DialogService, + IconButtonModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; + +type CardDetails = { + title: string; + tagline: string; + price: { amount: number; cadence: SubscriptionCadence }; + button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } }; + features: string[]; +}; + +@Component({ + selector: "billing-premium-upgrade-dialog", + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + DialogModule, + ButtonModule, + IconButtonModule, + TypographyModule, + CdkTrapFocus, + JslibModule, + ], + templateUrl: "./premium-upgrade-dialog.component.html", +}) +export class PremiumUpgradeDialogComponent { + protected cardDetails$: Observable = this.subscriptionPricingService + .getPersonalSubscriptionPricingTiers$() + .pipe( + map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)), + map((tier) => this.mapPremiumTierToCardDetails(tier!)), + catchError((error: unknown) => { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("error"), + message: this.i18nService.t("unexpectedError"), + }); + this.logService.error("Error fetching and mapping pricing tiers", error); + this.dialogRef.close(); + return EMPTY; + }), + ); + + constructor( + private dialogRef: DialogRef, + private subscriptionPricingService: SubscriptionPricingServiceAbstraction, + private i18nService: I18nService, + private toastService: ToastService, + private environmentService: EnvironmentService, + private platformUtilsService: PlatformUtilsService, + private logService: LogService, + ) {} + + protected async upgrade(): Promise { + const environment = await firstValueFrom(this.environmentService.environment$); + let vaultUrl = environment.getWebVaultUrl() + "/#/settings/subscription/premium"; + if (environment.getRegion() !== Region.SelfHosted) { + vaultUrl += "?callToAction=upgradeToPremium"; + } + this.platformUtilsService.launchUri(vaultUrl); + this.dialogRef.close(); + } + + protected close(): void { + this.dialogRef.close(); + } + + private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails { + return { + title: tier.name, + tagline: tier.description, + price: { + amount: tier.passwordManager.annualPrice / 12, + cadence: SubscriptionCadenceIds.Monthly, + }, + button: { + text: this.i18nService.t("upgradeNow"), + type: "primary", + icon: { type: "bwi-external-link", position: "after" }, + }, + features: tier.passwordManager.features.map((f) => f.value), + }; + } + + /** + * Opens the premium upgrade dialog. + * + * @param dialogService - The dialog service used to open the component + * @returns A dialog reference object + */ + static open(dialogService: DialogService): DialogRef { + return dialogService.open(PremiumUpgradeDialogComponent); + } +} diff --git a/libs/angular/src/billing/directives/not-premium.directive.ts b/libs/angular/src/billing/directives/not-premium.directive.ts index 41d62bb773e..8582a9f4396 100644 --- a/libs/angular/src/billing/directives/not-premium.directive.ts +++ b/libs/angular/src/billing/directives/not-premium.directive.ts @@ -1,4 +1,5 @@ -import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; +import { DestroyRef, Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -16,6 +17,7 @@ export class NotPremiumDirective implements OnInit { private templateRef: TemplateRef, private viewContainer: ViewContainerRef, private billingAccountProfileStateService: BillingAccountProfileStateService, + private destroyRef: DestroyRef, private accountService: AccountService, ) {} @@ -27,14 +29,15 @@ export class NotPremiumDirective implements OnInit { return; } - const premium = await firstValueFrom( - this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), - ); - - if (premium) { - this.viewContainer.clear(); - } else { - this.viewContainer.createEmbeddedView(this.templateRef); - } + this.billingAccountProfileStateService + .hasPremiumFromAnySource$(account.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((premium) => { + if (premium) { + this.viewContainer.clear(); + } else { + this.viewContainer.createEmbeddedView(this.templateRef); + } + }); } } diff --git a/libs/angular/src/components/callout.component.ts b/libs/angular/src/components/callout.component.ts index 215de49f676..9630b761076 100644 --- a/libs/angular/src/components/callout.component.ts +++ b/libs/angular/src/components/callout.component.ts @@ -9,17 +9,31 @@ import { CalloutTypes } from "@bitwarden/components"; /** * @deprecated use the CL's `CalloutComponent` instead */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-callout", templateUrl: "callout.component.html", standalone: false, }) export class DeprecatedCalloutComponent implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() type: CalloutTypes = "info"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() icon: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() title: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() enforcedPolicyOptions: MasterPasswordPolicyOptions; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() enforcedPolicyMessage: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() useAlertRole = false; calloutStyle: string; diff --git a/libs/angular/src/components/modal/dynamic-modal.component.ts b/libs/angular/src/components/modal/dynamic-modal.component.ts index 77491193916..ea40dd1a877 100644 --- a/libs/angular/src/components/modal/dynamic-modal.component.ts +++ b/libs/angular/src/components/modal/dynamic-modal.component.ts @@ -15,6 +15,8 @@ import { import { ModalRef } from "./modal.ref"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-modal", template: "", @@ -23,6 +25,8 @@ import { ModalRef } from "./modal.ref"; export class DynamicModalComponent implements AfterViewInit, OnDestroy { componentRef: ComponentRef; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild("modalContent", { read: ViewContainerRef, static: true }) modalContentRef: ViewContainerRef; diff --git a/libs/angular/src/directives/api-action.directive.ts b/libs/angular/src/directives/api-action.directive.ts index 85ba8a7489c..6873e448589 100644 --- a/libs/angular/src/directives/api-action.directive.ts +++ b/libs/angular/src/directives/api-action.directive.ts @@ -18,6 +18,8 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid standalone: false, }) export class ApiActionDirective implements OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() appApiAction: Promise; constructor( diff --git a/libs/angular/src/directives/copy-text.directive.ts b/libs/angular/src/directives/copy-text.directive.ts index 0f9018e19ad..aefb26ef07e 100644 --- a/libs/angular/src/directives/copy-text.directive.ts +++ b/libs/angular/src/directives/copy-text.directive.ts @@ -15,6 +15,8 @@ export class CopyTextDirective { private platformUtilsService: PlatformUtilsService, ) {} + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input("appCopyText") copyText: string; @HostListener("copy") onCopy() { diff --git a/libs/angular/src/directives/fallback-src.directive.ts b/libs/angular/src/directives/fallback-src.directive.ts index f1225245912..b63dc8671cf 100644 --- a/libs/angular/src/directives/fallback-src.directive.ts +++ b/libs/angular/src/directives/fallback-src.directive.ts @@ -7,6 +7,8 @@ import { Directive, ElementRef, HostListener, Input } from "@angular/core"; standalone: false, }) export class FallbackSrcDirective { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input("appFallbackSrc") appFallbackSrc: string; /** Only try setting the fallback once. This prevents an infinite loop if the fallback itself is missing. */ diff --git a/libs/angular/src/directives/if-feature.directive.spec.ts b/libs/angular/src/directives/if-feature.directive.spec.ts index d7c49994045..357209b0e64 100644 --- a/libs/angular/src/directives/if-feature.directive.spec.ts +++ b/libs/angular/src/directives/if-feature.directive.spec.ts @@ -13,6 +13,8 @@ const testBooleanFeature: FeatureFlag = "boolean-feature" as FeatureFlag; const testStringFeature: FeatureFlag = "string-feature" as FeatureFlag; const testStringFeatureValue = "test-value"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ template: `
      diff --git a/libs/angular/src/directives/if-feature.directive.ts b/libs/angular/src/directives/if-feature.directive.ts index aa10c9e8081..28cf1d5c35f 100644 --- a/libs/angular/src/directives/if-feature.directive.ts +++ b/libs/angular/src/directives/if-feature.directive.ts @@ -20,12 +20,16 @@ export class IfFeatureDirective implements OnInit { /** * The feature flag to check. */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() appIfFeature: FeatureFlag; /** * Optional value to compare against the value of the feature flag in the config service. * @default true */ + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() appIfFeatureValue: AllowedFeatureFlagTypes = true; private hasView = false; diff --git a/libs/angular/src/directives/input-verbatim.directive.ts b/libs/angular/src/directives/input-verbatim.directive.ts index 7bd18b12659..1240523d2bf 100644 --- a/libs/angular/src/directives/input-verbatim.directive.ts +++ b/libs/angular/src/directives/input-verbatim.directive.ts @@ -7,6 +7,8 @@ import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core"; standalone: false, }) export class InputVerbatimDirective implements OnInit { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() set appInputVerbatim(condition: boolean | string) { this.disableComplete = condition === "" || condition === true; } diff --git a/libs/angular/src/directives/launch-click.directive.ts b/libs/angular/src/directives/launch-click.directive.ts index b270dbba5e3..ce44648dc37 100644 --- a/libs/angular/src/directives/launch-click.directive.ts +++ b/libs/angular/src/directives/launch-click.directive.ts @@ -10,6 +10,8 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; export class LaunchClickDirective { constructor(private platformUtilsService: PlatformUtilsService) {} + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input("appLaunchClick") uriToLaunch = ""; @HostListener("click") onClick() { diff --git a/libs/angular/src/directives/text-drag.directive.ts b/libs/angular/src/directives/text-drag.directive.ts index 6202c552a87..aade2798dc7 100644 --- a/libs/angular/src/directives/text-drag.directive.ts +++ b/libs/angular/src/directives/text-drag.directive.ts @@ -8,6 +8,8 @@ import { Directive, HostListener, Input } from "@angular/core"; }, }) export class TextDragDirective { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input({ alias: "appTextDrag", required: true, diff --git a/libs/angular/src/directives/true-false-value.directive.ts b/libs/angular/src/directives/true-false-value.directive.ts index 5d25ac2a385..78c1b4647c6 100644 --- a/libs/angular/src/directives/true-false-value.directive.ts +++ b/libs/angular/src/directives/true-false-value.directive.ts @@ -14,7 +14,11 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; standalone: false, }) export class TrueFalseValueDirective implements ControlValueAccessor { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() trueValue: boolean | string = true; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals @Input() falseValue: boolean | string = false; constructor( diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 89ac6902137..d9038af39a5 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -153,6 +153,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; +import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; @@ -160,6 +161,7 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service"; import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; +import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service"; import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service"; import { DefaultKeyGenerationService, @@ -970,7 +972,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: SignalRConnectionService, useClass: SignalRConnectionService, - deps: [ApiServiceAbstraction, LogService], + deps: [ApiServiceAbstraction, LogService, PlatformUtilsServiceAbstraction], }), safeProvider({ provide: WebPushConnectionService, @@ -1237,7 +1239,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: AnonymousHubServiceAbstraction, useClass: AnonymousHubService, - deps: [EnvironmentService, AuthRequestServiceAbstraction], + deps: [EnvironmentService, AuthRequestServiceAbstraction, PlatformUtilsServiceAbstraction], }), safeProvider({ provide: ValidationServiceAbstraction, @@ -1469,6 +1471,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultBillingAccountProfileStateService, deps: [StateProvider, PlatformUtilsServiceAbstraction, ApiServiceAbstraction], }), + safeProvider({ + provide: SubscriptionPricingServiceAbstraction, + useClass: DefaultSubscriptionPricingService, + deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService], + }), safeProvider({ provide: OrganizationManagementPreferencesService, useClass: DefaultOrganizationManagementPreferencesService, diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index 1680182f9de..e03162c2d91 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -11,6 +11,7 @@ import { BehaviorSubject, concatMap, switchMap, + tap, } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -33,6 +34,7 @@ import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { DialogService, ToastService } from "@bitwarden/components"; // Value = hours @@ -144,6 +146,7 @@ export class AddEditComponent implements OnInit, OnDestroy { protected billingAccountProfileStateService: BillingAccountProfileStateService, protected accountService: AccountService, protected toastService: ToastService, + protected premiumUpgradePromptService: PremiumUpgradePromptService, ) { this.typeOptions = [ { name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true }, @@ -192,10 +195,15 @@ export class AddEditComponent implements OnInit, OnDestroy { } }); - this.formGroup.controls.type.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((val) => { - this.type = val; - this.typeChanged(); - }); + this.formGroup.controls.type.valueChanges + .pipe( + tap((val) => { + this.type = val; + }), + switchMap(() => this.typeChanged()), + takeUntil(this.destroy$), + ) + .subscribe(); this.formGroup.controls.selectedDeletionDatePreset.valueChanges .pipe(takeUntil(this.destroy$)) @@ -426,11 +434,11 @@ export class AddEditComponent implements OnInit, OnDestroy { return false; } - typeChanged() { + async typeChanged() { if (this.type === SendType.File && !this.alertShown) { if (!this.canAccessPremium) { this.alertShown = true; - this.messagingService.send("premiumRequired"); + await this.premiumUpgradePromptService.promptForPremium(); } else if (!this.emailVerified) { this.alertShown = true; this.messagingService.send("emailVerificationRequired"); diff --git a/libs/auth/src/angular/login/login.component.html b/libs/auth/src/angular/login/login.component.html index 26e19f11147..4e1689b1054 100644 --- a/libs/auth/src/angular/login/login.component.html +++ b/libs/auth/src/angular/login/login.component.html @@ -44,6 +44,8 @@ block buttonType="primary" (click)="continuePressed()" + [bitTooltip]="ssoRequired ? ('yourOrganizationRequiresSingleSignOn' | i18n) : ''" + [addTooltipToDescribedby]="ssoRequired" [disabled]="ssoRequired" > {{ "continue" | i18n }} @@ -59,6 +61,8 @@ block buttonType="secondary" (click)="handleLoginWithPasskeyClick()" + [bitTooltip]="ssoRequired ? ('yourOrganizationRequiresSingleSignOn' | i18n) : ''" + [addTooltipToDescribedby]="ssoRequired" [disabled]="ssoRequired" > @@ -67,7 +71,13 @@ - diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 537a42700c8..54a2a3b732b 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -54,6 +54,7 @@ import { IconButtonModule, LinkModule, ToastService, + TooltipDirective, } from "@bitwarden/components"; import { LoginComponentService, PasswordPolicies } from "./login-component.service"; @@ -82,6 +83,7 @@ export enum LoginUiState { JslibModule, ReactiveFormsModule, RouterModule, + TooltipDirective, ], }) export class LoginComponent implements OnInit, OnDestroy { diff --git a/libs/auth/src/angular/self-hosted-env-config-dialog/self-hosted-env-config-dialog.component.ts b/libs/auth/src/angular/self-hosted-env-config-dialog/self-hosted-env-config-dialog.component.ts index 40fdfb8c17c..6fb40179afa 100644 --- a/libs/auth/src/angular/self-hosted-env-config-dialog/self-hosted-env-config-dialog.component.ts +++ b/libs/auth/src/angular/self-hosted-env-config-dialog/self-hosted-env-config-dialog.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from "@angular/common"; -import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Component, inject, OnDestroy, OnInit } from "@angular/core"; import { AbstractControl, FormBuilder, @@ -16,6 +16,8 @@ import { EnvironmentService, Region, } 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"; // 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 { @@ -51,6 +53,25 @@ function selfHostedEnvSettingsFormValidator(): ValidatorFn { }; } +function onlyHttpsValidator(): ValidatorFn { + const i18nService = inject(I18nService); + const platformUtilsService = inject(PlatformUtilsService); + + return (control: AbstractControl): ValidationErrors | null => { + const url = control.value as string; + + if (url && !url.startsWith("https://") && !platformUtilsService.isDev()) { + return { + onlyHttpsAllowed: { + message: i18nService.t("selfHostedEnvMustUseHttps"), + }, + }; // invalid + } + + return null; // valid + }; +} + /** * Dialog for configuring self-hosted environment settings. */ @@ -89,12 +110,12 @@ export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy { formGroup = this.formBuilder.group( { - baseUrl: [""], - webVaultUrl: [""], - apiUrl: [""], - identityUrl: [""], - iconsUrl: [""], - notificationsUrl: [""], + baseUrl: ["", [onlyHttpsValidator()]], + webVaultUrl: ["", [onlyHttpsValidator()]], + apiUrl: ["", [onlyHttpsValidator()]], + identityUrl: ["", [onlyHttpsValidator()]], + iconsUrl: ["", [onlyHttpsValidator()]], + notificationsUrl: ["", [onlyHttpsValidator()]], }, { validators: selfHostedEnvSettingsFormValidator() }, ); @@ -162,10 +183,11 @@ export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy { }); } submit = async () => { + this.formGroup.markAllAsTouched(); this.showErrorSummary = false; if (this.formGroup.invalid) { - this.showErrorSummary = true; + this.showErrorSummary = Boolean(this.formGroup.errors?.["atLeastOneUrlIsRequired"]); return; } diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 761038c2e46..f7ca1964b76 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -91,7 +91,7 @@ import { CipherShareRequest } from "../vault/models/request/cipher-share.request import { CipherRequest } from "../vault/models/request/cipher.request"; import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response"; import { AttachmentResponse } from "../vault/models/response/attachment.response"; -import { CipherResponse } from "../vault/models/response/cipher.response"; +import { CipherMiniResponse, CipherResponse } from "../vault/models/response/cipher.response"; import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response"; /** @@ -215,7 +215,10 @@ export abstract class ApiService { id: string, request: CipherCollectionsRequest, ): Promise; - abstract putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise; + abstract putCipherCollectionsAdmin( + id: string, + request: CipherCollectionsRequest, + ): Promise; abstract postPurgeCiphers( request: SecretVerificationRequest, organizationId?: string, diff --git a/libs/common/src/abstractions/audit.service.ts b/libs/common/src/abstractions/audit.service.ts index a00b2bf038a..71edc3f1740 100644 --- a/libs/common/src/abstractions/audit.service.ts +++ b/libs/common/src/abstractions/audit.service.ts @@ -14,10 +14,4 @@ export abstract class AuditService { * @returns A promise that resolves to an array of BreachAccountResponse objects. */ abstract breachedAccounts: (username: string) => Promise; - /** - * Checks if a domain is known for phishing. - * @param domain The domain to check. - * @returns A promise that resolves to a boolean indicating if the domain is known for phishing. - */ - abstract getKnownPhishingDomains: () => Promise; } diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 58d6d9efef9..363b82c507d 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -1,8 +1,13 @@ -import { map, Observable } from "rxjs"; +import { combineLatest, map, Observable } from "rxjs"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { UserId } from "../../../types/guid"; +import { PolicyType } from "../../enums"; import { OrganizationData } from "../../models/data/organization.data"; import { Organization } from "../../models/domain/organization"; +import { PolicyService } from "../policy/policy.service.abstraction"; export function canAccessVaultTab(org: Organization): boolean { return org.canViewAllCollections; @@ -51,6 +56,17 @@ export function canAccessOrgAdmin(org: Organization): boolean { ); } +export function canAccessEmergencyAccess( + userId: UserId, + configService: ConfigService, + policyService: PolicyService, +) { + return combineLatest([ + configService.getFeatureFlag$(FeatureFlag.AutoConfirm), + policyService.policiesByType$(PolicyType.AutoConfirm, userId), + ]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled))); +} + /** * @deprecated Please use the general `getById` custom rxjs operator instead. */ diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts index 6b50f9befec..8ce1a785516 100644 --- a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts @@ -554,6 +554,77 @@ describe("PolicyService", () => { expect(result).toBe(false); }); + + describe("SingleOrg policy exemptions", () => { + it("returns true for SingleOrg policy when AutoConfirm is enabled, even for users who can manage policies", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org6", PolicyType.SingleOrg, true), + policyData("policy2", "org6", PolicyType.AutoConfirm, true), + ]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ); + + expect(result).toBe(true); + }); + + it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is not enabled", async () => { + singleUserState.nextState( + arrayToRecord([policyData("policy1", "org6", PolicyType.SingleOrg, true)]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ); + + expect(result).toBe(false); + }); + + it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is disabled", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org6", PolicyType.SingleOrg, true), + policyData("policy2", "org6", PolicyType.AutoConfirm, false), + ]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ); + + expect(result).toBe(false); + }); + + it("returns true for SingleOrg policy for regular users when AutoConfirm is not enabled", async () => { + singleUserState.nextState( + arrayToRecord([policyData("policy1", "org1", PolicyType.SingleOrg, true)]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ); + + expect(result).toBe(true); + }); + + it("returns true for SingleOrg policy when AutoConfirm is enabled in a different organization", async () => { + singleUserState.nextState( + arrayToRecord([ + policyData("policy1", "org6", PolicyType.SingleOrg, true), + policyData("policy2", "org1", PolicyType.AutoConfirm, true), + ]), + ); + + const result = await firstValueFrom( + policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId), + ); + + expect(result).toBe(false); + }); + }); }); describe("combinePoliciesIntoMasterPasswordPolicyOptions", () => { diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.ts b/libs/common/src/admin-console/services/policy/default-policy.service.ts index 5781dd938f3..1107e88e796 100644 --- a/libs/common/src/admin-console/services/policy/default-policy.service.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.ts @@ -40,18 +40,16 @@ export class DefaultPolicyService implements PolicyService { } policiesByType$(policyType: PolicyType, userId: UserId) { - const filteredPolicies$ = this.policies$(userId).pipe( - map((policies) => policies.filter((p) => p.type === policyType)), - ); - if (!userId) { throw new Error("No userId provided"); } + const allPolicies$ = this.policies$(userId); const organizations$ = this.organizationService.organizations$(userId); - return combineLatest([filteredPolicies$, organizations$]).pipe( + return combineLatest([allPolicies$, organizations$]).pipe( map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)), + map((policies) => policies.filter((p) => p.type === policyType)), ); } @@ -77,7 +75,7 @@ export class DefaultPolicyService implements PolicyService { policy.enabled && organization.status >= OrganizationUserStatusType.Accepted && organization.usePolicies && - !this.isExemptFromPolicy(policy.type, organization) + !this.isExemptFromPolicy(policy.type, organization, policies) ); }); } @@ -265,7 +263,11 @@ export class DefaultPolicyService implements PolicyService { * Determines whether an orgUser is exempt from a specific policy because of their role * Generally orgUsers who can manage policies are exempt from them, but some policies are stricter */ - private isExemptFromPolicy(policyType: PolicyType, organization: Organization) { + private isExemptFromPolicy( + policyType: PolicyType, + organization: Organization, + allPolicies: Policy[], + ) { switch (policyType) { case PolicyType.MaximumVaultTimeout: // Max Vault Timeout applies to everyone except owners @@ -286,6 +288,14 @@ export class DefaultPolicyService implements PolicyService { case PolicyType.OrganizationDataOwnership: // organization data ownership policy applies to everyone except admins and owners return organization.isAdmin; + case PolicyType.SingleOrg: + // Check if AutoConfirm policy is enabled for this organization + return allPolicies.find( + (p) => + p.organizationId === organization.id && p.type === PolicyType.AutoConfirm && p.enabled, + ) + ? false + : organization.canManagePolicies; default: return organization.canManagePolicies; } diff --git a/libs/common/src/auth/services/anonymous-hub.service.ts b/libs/common/src/auth/services/anonymous-hub.service.ts index 3900dd53ee0..561cddb5372 100644 --- a/libs/common/src/auth/services/anonymous-hub.service.ts +++ b/libs/common/src/auth/services/anonymous-hub.service.ts @@ -18,6 +18,8 @@ import { NotificationResponse, } from "../../models/response/notification.response"; import { EnvironmentService } from "../../platform/abstractions/environment.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { InsecureUrlNotAllowedError } from "../../services/api-errors"; import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymous-hub.service"; export class AnonymousHubService implements AnonymousHubServiceAbstraction { @@ -27,10 +29,14 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction { constructor( private environmentService: EnvironmentService, private authRequestService: AuthRequestServiceAbstraction, + private platformUtilsService: PlatformUtilsService, ) {} async createHubConnection(token: string) { this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl(); + if (!this.url.startsWith("https://") && !this.platformUtilsService.isDev()) { + throw new InsecureUrlNotAllowedError(); + } this.anonHubConnection = new HubConnectionBuilder() .withUrl(this.url + "/anonymous-hub?Token=" + token, { diff --git a/libs/common/src/autofill/services/domain-settings.service.ts b/libs/common/src/autofill/services/domain-settings.service.ts index d6ab8851ad7..fa7a37ba732 100644 --- a/libs/common/src/autofill/services/domain-settings.service.ts +++ b/libs/common/src/autofill/services/domain-settings.service.ts @@ -166,7 +166,7 @@ export class DefaultDomainSettingsService implements DomainSettingsService { if (!policy?.enabled || policy?.data == null) { return null; } - const data = policy.data?.defaultUriMatchStrategy; + const data = policy.data?.uriMatchDetection; // Validate that data is a valid UriMatchStrategy value return Object.values(UriMatchStrategy).includes(data) ? data : null; }), diff --git a/libs/common/src/billing/abstractions/subscription-pricing.service.abstraction.ts b/libs/common/src/billing/abstractions/subscription-pricing.service.abstraction.ts new file mode 100644 index 00000000000..f3928c0e2e7 --- /dev/null +++ b/libs/common/src/billing/abstractions/subscription-pricing.service.abstraction.ts @@ -0,0 +1,32 @@ +import { Observable } from "rxjs"; + +import { + BusinessSubscriptionPricingTier, + PersonalSubscriptionPricingTier, +} from "../types/subscription-pricing-tier"; + +export abstract class SubscriptionPricingServiceAbstraction { + /** + * Gets personal subscription pricing tiers (Premium and Families). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of personal subscription pricing tiers. + * @throws Error if any errors occur during api request. + */ + abstract getPersonalSubscriptionPricingTiers$(): Observable; + + /** + * Gets business subscription pricing tiers (Teams, Enterprise, and Custom). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of business subscription pricing tiers. + * @throws Error if any errors occur during api request. + */ + abstract getBusinessSubscriptionPricingTiers$(): Observable; + + /** + * Gets developer subscription pricing tiers (Free, Teams, and Enterprise). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of business subscription pricing tiers for developers. + * @throws Error if any errors occur during api request. + */ + abstract getDeveloperSubscriptionPricingTiers$(): Observable; +} diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts b/libs/common/src/billing/services/subscription-pricing.service.spec.ts similarity index 87% rename from apps/web/src/app/billing/services/subscription-pricing.service.spec.ts rename to libs/common/src/billing/services/subscription-pricing.service.spec.ts index de80cdcbdbf..07ad292c568 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.spec.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.spec.ts @@ -1,4 +1,3 @@ -import { TestBed } from "@angular/core/testing"; import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; @@ -8,7 +7,6 @@ import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.res import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; import { @@ -17,15 +15,14 @@ import { SubscriptionCadenceIds, } from "../types/subscription-pricing-tier"; -import { SubscriptionPricingService } from "./subscription-pricing.service"; +import { DefaultSubscriptionPricingService } from "./subscription-pricing.service"; -describe("SubscriptionPricingService", () => { - let service: SubscriptionPricingService; +describe("DefaultSubscriptionPricingService", () => { + let service: DefaultSubscriptionPricingService; let billingApiService: MockProxy; let configService: MockProxy; let i18nService: MockProxy; let logService: MockProxy; - let toastService: MockProxy; const mockFamiliesPlan = { type: PlanType.FamiliesAnnually, @@ -233,7 +230,6 @@ describe("SubscriptionPricingService", () => { beforeAll(() => { i18nService = mock(); logService = mock(); - toastService = mock(); i18nService.t.mockImplementation((key: string, ...args: any[]) => { switch (key) { @@ -324,8 +320,6 @@ describe("SubscriptionPricingService", () => { return "Boost productivity"; case "seamlessIntegration": return "Seamless integration"; - case "unexpectedError": - return "An unexpected error has occurred."; default: return key; } @@ -340,18 +334,12 @@ describe("SubscriptionPricingService", () => { billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value) - TestBed.configureTestingModule({ - providers: [ - SubscriptionPricingService, - { provide: BillingApiServiceAbstraction, useValue: billingApiService }, - { provide: ConfigService, useValue: configService }, - { provide: I18nService, useValue: i18nService }, - { provide: LogService, useValue: logService }, - { provide: ToastService, useValue: toastService }, - ], - }); - - service = TestBed.inject(SubscriptionPricingService); + service = new DefaultSubscriptionPricingService( + billingApiService, + configService, + i18nService, + logService, + ); }); describe("getPersonalSubscriptionPricingTiers$", () => { @@ -422,46 +410,37 @@ describe("SubscriptionPricingService", () => { }); }); - it("should handle API errors by logging and showing toast", (done) => { + it("should handle API errors by logging and throwing error", (done) => { const errorBillingApiService = mock(); const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); const testError = new Error("API error"); errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); - errorI18nService.t.mockImplementation((key: string) => { - if (key === "unexpectedError") { - return "An unexpected error has occurred."; - } - return key; - }); + errorI18nService.t.mockImplementation((key: string) => key); - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, errorI18nService, errorLogService, - errorToastService, ); errorService.getPersonalSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - expect(tiers).toEqual([]); - expect(errorLogService.error).toHaveBeenCalledWith(testError); - expect(errorToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(errorLogService.error).toHaveBeenCalledWith( + "Failed to load personal subscription pricing tiers", + testError, + ); + expect(error).toBe(testError); + done(); }, }); }); @@ -611,46 +590,37 @@ describe("SubscriptionPricingService", () => { }); }); - it("should handle API errors by logging and showing toast", (done) => { + it("should handle API errors by logging and throwing error", (done) => { const errorBillingApiService = mock(); const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); const testError = new Error("API error"); errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); - errorI18nService.t.mockImplementation((key: string) => { - if (key === "unexpectedError") { - return "An unexpected error has occurred."; - } - return key; - }); + errorI18nService.t.mockImplementation((key: string) => key); - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, errorI18nService, errorLogService, - errorToastService, ); errorService.getBusinessSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - expect(tiers).toEqual([]); - expect(errorLogService.error).toHaveBeenCalledWith(testError); - expect(errorToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(errorLogService.error).toHaveBeenCalledWith( + "Failed to load business subscription pricing tiers", + testError, + ); + expect(error).toBe(testError); + done(); }, }); }); @@ -855,46 +825,37 @@ describe("SubscriptionPricingService", () => { }); }); - it("should handle API errors by logging and showing toast", (done) => { + it("should handle API errors by logging and throwing error", (done) => { const errorBillingApiService = mock(); const errorConfigService = mock(); const errorI18nService = mock(); const errorLogService = mock(); - const errorToastService = mock(); const testError = new Error("API error"); errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); - errorI18nService.t.mockImplementation((key: string) => { - if (key === "unexpectedError") { - return "An unexpected error has occurred."; - } - return key; - }); + errorI18nService.t.mockImplementation((key: string) => key); - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, errorI18nService, errorLogService, - errorToastService, ); errorService.getDeveloperSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - expect(tiers).toEqual([]); - expect(errorLogService.error).toHaveBeenCalledWith(testError); - expect(errorToastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(errorLogService.error).toHaveBeenCalledWith( + "Failed to load developer subscription pricing tiers", + testError, + ); + expect(error).toBe(testError); + done(); }, }); }); @@ -910,38 +871,36 @@ describe("SubscriptionPricingService", () => { errorBillingApiService.getPremiumPlan.mockRejectedValue(testError); errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, i18nService, logService, - toastService, ); errorService.getPersonalSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - // Should return empty array due to error in premium plan fetch - expect(tiers).toEqual([]); + next: () => { + fail("Observable should error, not return a value"); + }, + error: (error: unknown) => { expect(logService.error).toHaveBeenCalledWith( "Failed to fetch premium plan from API", testError, ); - expect(toastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); + expect(logService.error).toHaveBeenCalledWith( + "Failed to load personal subscription pricing tiers", + testError, + ); + expect(error).toBe(testError); done(); }, - error: () => { - fail("Observable should not error, it should return empty array"); - }, }); }); it("should handle malformed premium plan API response", (done) => { const errorBillingApiService = mock(); const errorConfigService = mock(); + const testError = new TypeError("Cannot read properties of undefined (reading 'price')"); // Malformed response missing the Seat property const malformedResponse = { @@ -955,28 +914,24 @@ describe("SubscriptionPricingService", () => { errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any); errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, i18nService, logService, - toastService, ); errorService.getPersonalSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - // Should return empty array due to validation error - expect(tiers).toEqual([]); - expect(logService.error).toHaveBeenCalled(); - expect(toastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(logService.error).toHaveBeenCalledWith( + "Failed to load personal subscription pricing tiers", + testError, + ); + expect(error).toEqual(testError); + done(); }, }); }); @@ -984,6 +939,7 @@ describe("SubscriptionPricingService", () => { it("should handle malformed premium plan with invalid price types", (done) => { const errorBillingApiService = mock(); const errorConfigService = mock(); + const testError = new TypeError("Cannot read properties of undefined (reading 'price')"); // Malformed response with price as string instead of number const malformedResponse = { @@ -1001,28 +957,24 @@ describe("SubscriptionPricingService", () => { errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any); errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag - const errorService = new SubscriptionPricingService( + const errorService = new DefaultSubscriptionPricingService( errorBillingApiService, errorConfigService, i18nService, logService, - toastService, ); errorService.getPersonalSubscriptionPricingTiers$().subscribe({ - next: (tiers) => { - // Should return empty array due to validation error - expect(tiers).toEqual([]); - expect(logService.error).toHaveBeenCalled(); - expect(toastService.showToast).toHaveBeenCalledWith({ - variant: "error", - title: "", - message: "An unexpected error has occurred.", - }); - done(); + next: () => { + fail("Observable should error, not return a value"); }, - error: () => { - fail("Observable should not error, it should return empty array"); + error: (error: unknown) => { + expect(logService.error).toHaveBeenCalledWith( + "Failed to load personal subscription pricing tiers", + testError, + ); + expect(error).toEqual(testError); + done(); }, }); }); @@ -1053,12 +1005,11 @@ describe("SubscriptionPricingService", () => { const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan"); // Create a new service instance with the feature flag enabled - const newService = new SubscriptionPricingService( + const newService = new DefaultSubscriptionPricingService( newBillingApiService, newConfigService, i18nService, logService, - toastService, ); // Subscribe to the premium pricing tier multiple times @@ -1082,12 +1033,11 @@ describe("SubscriptionPricingService", () => { newConfigService.getFeatureFlag$.mockReturnValue(of(false)); // Create a new service instance with the feature flag disabled - const newService = new SubscriptionPricingService( + const newService = new DefaultSubscriptionPricingService( newBillingApiService, newConfigService, i18nService, logService, - toastService, ); // Subscribe with feature flag disabled diff --git a/apps/web/src/app/billing/services/subscription-pricing.service.ts b/libs/common/src/billing/services/subscription-pricing.service.ts similarity index 87% rename from apps/web/src/app/billing/services/subscription-pricing.service.ts rename to libs/common/src/billing/services/subscription-pricing.service.ts index 71729a42d23..a4223579c12 100644 --- a/apps/web/src/app/billing/services/subscription-pricing.service.ts +++ b/libs/common/src/billing/services/subscription-pricing.service.ts @@ -1,5 +1,14 @@ -import { Injectable } from "@angular/core"; -import { combineLatest, from, map, Observable, of, shareReplay, switchMap, take } from "rxjs"; +import { + combineLatest, + from, + map, + Observable, + of, + shareReplay, + switchMap, + take, + throwError, +} from "rxjs"; import { catchError } from "rxjs/operators"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; @@ -10,19 +19,18 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; -import { BillingServicesModule } from "@bitwarden/web-vault/app/billing/services/billing-services.module"; + +import { SubscriptionPricingServiceAbstraction } from "../abstractions/subscription-pricing.service.abstraction"; import { BusinessSubscriptionPricingTier, BusinessSubscriptionPricingTierIds, PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTierIds, SubscriptionCadenceIds, -} from "@bitwarden/web-vault/app/billing/types/subscription-pricing-tier"; +} from "../types/subscription-pricing-tier"; -@Injectable({ providedIn: BillingServicesModule }) -export class SubscriptionPricingService { +export class DefaultSubscriptionPricingService implements SubscriptionPricingServiceAbstraction { /** * Fallback premium pricing used when the feature flag is disabled. * These values represent the legacy pricing model and will not reflect @@ -37,33 +45,47 @@ export class SubscriptionPricingService { private configService: ConfigService, private i18nService: I18nService, private logService: LogService, - private toastService: ToastService, ) {} + /** + * Gets personal subscription pricing tiers (Premium and Families). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of personal subscription pricing tiers. + * @throws Error if any errors occur during api request. + */ getPersonalSubscriptionPricingTiers$ = (): Observable => combineLatest([this.premium$, this.families$]).pipe( catchError((error: unknown) => { - this.logService.error(error); - this.showUnexpectedErrorToast(); - return of([]); + this.logService.error("Failed to load personal subscription pricing tiers", error); + return throwError(() => error); }), ); + /** + * Gets business subscription pricing tiers (Teams, Enterprise, and Custom). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of business subscription pricing tiers. + * @throws Error if any errors occur during api request. + */ getBusinessSubscriptionPricingTiers$ = (): Observable => combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe( catchError((error: unknown) => { - this.logService.error(error); - this.showUnexpectedErrorToast(); - return of([]); + this.logService.error("Failed to load business subscription pricing tiers", error); + return throwError(() => error); }), ); + /** + * Gets developer subscription pricing tiers (Free, Teams, and Enterprise). + * Throws any errors that occur during api request so callers must handle errors. + * @returns An observable of an array of business subscription pricing tiers for developers. + * @throws Error if any errors occur during api request. + */ getDeveloperSubscriptionPricingTiers$ = (): Observable => combineLatest([this.free$, this.teams$, this.enterprise$]).pipe( catchError((error: unknown) => { - this.logService.error(error); - this.showUnexpectedErrorToast(); - return of([]); + this.logService.error("Failed to load developer subscription pricing tiers", error); + return throwError(() => error); }), ); @@ -76,7 +98,7 @@ export class SubscriptionPricingService { ).pipe( catchError((error: unknown) => { this.logService.error("Failed to fetch premium plan from API", error); - throw error; // Re-throw to propagate to higher-level error handler + return throwError(() => error); // Re-throw to propagate to higher-level error handler }), shareReplay({ bufferSize: 1, refCount: false }), ); @@ -94,8 +116,8 @@ export class SubscriptionPricingService { })), ) : of({ - seat: SubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE, - storage: SubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE, + seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE, + storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE, }), ), map((premiumPrices) => ({ @@ -268,14 +290,6 @@ export class SubscriptionPricingService { ), ); - private showUnexpectedErrorToast() { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("unexpectedError"), - }); - } - private featureTranslations = { builtInAuthenticator: () => ({ key: "builtInAuthenticator", diff --git a/apps/web/src/app/billing/types/subscription-pricing-tier.ts b/libs/common/src/billing/types/subscription-pricing-tier.ts similarity index 100% rename from apps/web/src/app/billing/types/subscription-pricing-tier.ts rename to libs/common/src/billing/types/subscription-pricing-tier.ts diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index d9cd1dbfab3..d9effd21b30 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -23,7 +23,6 @@ export enum FeatureFlag { /* Billing */ TrialPaymentOptional = "PM-8163-trial-payment", - PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button", @@ -31,6 +30,7 @@ export enum FeatureFlag { PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page", PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", + PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -38,6 +38,7 @@ export enum FeatureFlag { ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption", WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2", + LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2", UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", @@ -56,6 +57,7 @@ export enum FeatureFlag { PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view", PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption", CipherKeyEncryption = "cipher-key-encryption", + AutofillConfirmation = "pm-25083-autofill-confirm-from-search", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -103,13 +105,13 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE, [FeatureFlag.PM22134SdkCipherListView]: FALSE, [FeatureFlag.PM22136_SdkCipherEncryption]: FALSE, + [FeatureFlag.AutofillConfirmation]: FALSE, /* Auth */ [FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE, /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, - [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, @@ -117,6 +119,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, [FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE, [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, + [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, @@ -124,6 +127,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.ForceUpdateKDFSettings]: FALSE, [FeatureFlag.PM25174_DisableType0Decryption]: FALSE, [FeatureFlag.WindowsBiometricsV2]: FALSE, + [FeatureFlag.LinuxBiometricsV2]: FALSE, [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, [FeatureFlag.NoLogoutOnKdfChange]: FALSE, diff --git a/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts index 5998668f138..2a9e7fc7141 100644 --- a/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts +++ b/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts @@ -10,8 +10,10 @@ import { Observable, Subscription } from "rxjs"; import { ApiService } from "../../../abstractions/api.service"; import { NotificationResponse } from "../../../models/response/notification.response"; +import { InsecureUrlNotAllowedError } from "../../../services/api-errors"; import { UserId } from "../../../types/guid"; import { LogService } from "../../abstractions/log.service"; +import { PlatformUtilsService } from "../../abstractions/platform-utils.service"; // 2 Minutes const MIN_RECONNECT_TIME = 2 * 60 * 1000; @@ -69,12 +71,17 @@ export class SignalRConnectionService { constructor( private readonly apiService: ApiService, private readonly logService: LogService, + private readonly platformUtilsService: PlatformUtilsService, private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () => new HubConnectionBuilder(), private readonly timeoutManager: TimeoutManager = globalThis, ) {} connect$(userId: UserId, notificationsUrl: string) { + if (!notificationsUrl.startsWith("https://") && !this.platformUtilsService.isDev()) { + throw new InsecureUrlNotAllowedError(); + } + return new Observable((subsciber) => { const connection = this.hubConnectionBuilderFactory() .withUrl(notificationsUrl + "/hub", { diff --git a/libs/common/src/services/api-errors.ts b/libs/common/src/services/api-errors.ts new file mode 100644 index 00000000000..6dc9c8fce6d --- /dev/null +++ b/libs/common/src/services/api-errors.ts @@ -0,0 +1,9 @@ +export class InsecureUrlNotAllowedError extends Error { + constructor(url?: string) { + if (url === undefined) { + super("Insecure URL not allowed. All URLs must use HTTPS."); + } else { + super(`Insecure URL not allowed: ${url}. All URLs must use HTTPS.`); + } + } +} diff --git a/libs/common/src/services/api.service.spec.ts b/libs/common/src/services/api.service.spec.ts index 6d6e96de9e3..1fb8f86697f 100644 --- a/libs/common/src/services/api.service.spec.ts +++ b/libs/common/src/services/api.service.spec.ts @@ -20,6 +20,7 @@ import { Environment, EnvironmentService } from "../platform/abstractions/enviro import { LogService } from "../platform/abstractions/log.service"; import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { InsecureUrlNotAllowedError } from "./api-errors"; import { ApiService, HttpOperations } from "./api.service"; describe("ApiService", () => { @@ -411,4 +412,39 @@ describe("ApiService", () => { ).rejects.toMatchObject(error); }, ); + + it("throws error when trying to fetch an insecure URL", async () => { + environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue( + of({ + getApiUrl: () => "http://example.com", + } satisfies Partial as Environment), + ); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal ?? undefined, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + const nativeFetch = jest.fn, [request: Request]>(); + nativeFetch.mockImplementation((request) => { + return Promise.resolve({ + ok: true, + status: 204, + headers: new Headers(), + } satisfies Partial as Response); + }); + sut.nativeFetch = nativeFetch; + + await expect( + async () => await sut.send("GET", "/something", null, true, true, null), + ).rejects.toThrow(InsecureUrlNotAllowedError); + expect(nativeFetch).not.toHaveBeenCalled(); + }); }); diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index b7f5f0ed001..8314e44e75f 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -117,6 +117,8 @@ import { AttachmentResponse } from "../vault/models/response/attachment.response import { CipherResponse } from "../vault/models/response/cipher.response"; import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response"; +import { InsecureUrlNotAllowedError } from "./api-errors"; + export type HttpOperations = { createRequest: (url: string, request: RequestInit) => Request; }; @@ -1310,6 +1312,10 @@ export class ApiService implements ApiServiceAbstraction { } async fetch(request: Request): Promise { + if (!request.url.startsWith("https://") && !this.platformUtilsService.isDev()) { + throw new InsecureUrlNotAllowedError(); + } + if (request.method === "GET") { request.headers.set("Cache-Control", "no-store"); request.headers.set("Pragma", "no-cache"); diff --git a/libs/common/src/services/audit.service.ts b/libs/common/src/services/audit.service.ts index 8fc9f13476d..0bdf45917de 100644 --- a/libs/common/src/services/audit.service.ts +++ b/libs/common/src/services/audit.service.ts @@ -80,9 +80,4 @@ export class AuditService implements AuditServiceAbstraction { throw new Error(); } } - - async getKnownPhishingDomains(): Promise { - const response = await this.apiService.send("GET", "/phishing-domains", null, true, true); - return response as string[]; - } } diff --git a/libs/common/src/vault/abstractions/cipher-risk.service.ts b/libs/common/src/vault/abstractions/cipher-risk.service.ts new file mode 100644 index 00000000000..6bbd9d7791e --- /dev/null +++ b/libs/common/src/vault/abstractions/cipher-risk.service.ts @@ -0,0 +1,55 @@ +import type { + CipherRiskResult, + CipherRiskOptions, + ExposedPasswordResult, + PasswordReuseMap, + CipherId, +} from "@bitwarden/sdk-internal"; + +import { UserId } from "../../types/guid"; +import { CipherView } from "../models/view/cipher.view"; + +export abstract class CipherRiskService { + /** + * Compute password risks for multiple ciphers. + * Only processes Login ciphers with passwords. + * + * @param ciphers - The ciphers to evaluate for password risks + * @param userId - The user ID for SDK client context + * @param options - Optional configuration for risk computation (passwordMap, checkExposed) + * @returns Array of CipherRisk results from SDK containing password_strength, exposed_result, and reuse_count + */ + abstract computeRiskForCiphers( + ciphers: CipherView[], + userId: UserId, + options?: CipherRiskOptions, + ): Promise; + + /** + * Compute password risk for a single cipher by its ID. Will automatically build a password reuse map + * from all the user's ciphers via the CipherService. + * @param cipherId + * @param userId + * @param checkExposed - Whether to check if the password has been exposed in data breaches via HIBP + * @returns CipherRisk result from SDK containing password_strength, exposed_result, and reuse_count + */ + abstract computeCipherRiskForUser( + cipherId: CipherId, + userId: UserId, + checkExposed?: boolean, + ): Promise; + + /** + * Build a password reuse map for the given ciphers. + * Maps each password to the number of times it appears across ciphers. + * Only processes Login ciphers with passwords. + * + * @param ciphers - The ciphers to analyze for password reuse + * @param userId - The user ID for SDK client context + * @returns A map of password to count of occurrences + */ + abstract buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise; +} + +// Re-export SDK types for convenience +export type { CipherRiskResult, CipherRiskOptions, ExposedPasswordResult, PasswordReuseMap }; diff --git a/libs/common/src/vault/models/response/cipher.response.ts b/libs/common/src/vault/models/response/cipher.response.ts index bf01e0f08de..28979302eb4 100644 --- a/libs/common/src/vault/models/response/cipher.response.ts +++ b/libs/common/src/vault/models/response/cipher.response.ts @@ -14,6 +14,11 @@ import { SshKeyApi } from "../api/ssh-key.api"; import { AttachmentResponse } from "./attachment.response"; import { PasswordHistoryResponse } from "./password-history.response"; +export type CipherMiniResponse = Omit< + CipherResponse, + "edit" | "viewPassword" | "folderId" | "favorite" | "permissions" +>; + export class CipherResponse extends BaseResponse { id: string; organizationId: string; diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index efe7bc2b89b..1e7e5302d41 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1117,7 +1117,13 @@ export class CipherService implements CipherServiceAbstraction { async saveCollectionsWithServerAdmin(cipher: Cipher): Promise { const request = new CipherCollectionsRequest(cipher.collectionIds); const response = await this.apiService.putCipherCollectionsAdmin(cipher.id, request); - const data = new CipherData(response); + // The response will be incomplete with several properties missing values + // We will assign those properties values so the SDK decryption can complete + const completedResponse = new CipherResponse(response); + completedResponse.edit = true; + completedResponse.viewPassword = true; + completedResponse.favorite = false; + const data = new CipherData(completedResponse); return new Cipher(data); } diff --git a/libs/common/src/vault/services/default-cipher-risk.service.spec.ts b/libs/common/src/vault/services/default-cipher-risk.service.spec.ts new file mode 100644 index 00000000000..afd52bde6cf --- /dev/null +++ b/libs/common/src/vault/services/default-cipher-risk.service.spec.ts @@ -0,0 +1,538 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import type { CipherRiskOptions, CipherId, CipherRiskResult } from "@bitwarden/sdk-internal"; + +import { asUuid } from "../../platform/abstractions/sdk/sdk.service"; +import { MockSdkService } from "../../platform/spec/mock-sdk.service"; +import { UserId } from "../../types/guid"; +import { CipherService } from "../abstractions/cipher.service"; +import { CipherType } from "../enums/cipher-type"; +import { CipherView } from "../models/view/cipher.view"; +import { LoginView } from "../models/view/login.view"; + +import { DefaultCipherRiskService } from "./default-cipher-risk.service"; + +describe("DefaultCipherRiskService", () => { + let cipherRiskService: DefaultCipherRiskService; + let sdkService: MockSdkService; + let mockCipherService: jest.Mocked; + + const mockUserId = "test-user-id" as UserId; + const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2"; + const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3"; + const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4"; + + beforeEach(() => { + sdkService = new MockSdkService(); + mockCipherService = mock(); + cipherRiskService = new DefaultCipherRiskService(sdkService, mockCipherService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("computeRiskForCiphers", () => { + it("should call SDK cipher_risk().compute_risk() with correct parameters", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + const mockRiskResults: CipherRiskResult[] = [ + { + id: mockCipherId1 as any, + password_strength: 3, + exposed_result: { type: "NotChecked" }, + reuse_count: undefined, + }, + ]; + + mockCipherRiskClient.compute_risk.mockResolvedValue(mockRiskResults); + + const cipher = new CipherView(); + cipher.id = mockCipherId1; + cipher.type = CipherType.Login; + cipher.login = new LoginView(); + cipher.login.password = "test-password"; + cipher.login.username = "test@example.com"; + + const options: CipherRiskOptions = { + checkExposed: true, + passwordMap: undefined, + hibpBaseUrl: undefined, + }; + + const results = await cipherRiskService.computeRiskForCiphers([cipher], mockUserId, options); + + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith( + [ + { + id: expect.anything(), + password: "test-password", + username: "test@example.com", + }, + ], + options, + ); + expect(results).toEqual(mockRiskResults); + }); + + it("should filter out non-Login ciphers", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + mockCipherRiskClient.compute_risk.mockResolvedValue([]); + + const loginCipher = new CipherView(); + loginCipher.id = mockCipherId1; + loginCipher.type = CipherType.Login; + loginCipher.login = new LoginView(); + loginCipher.login.password = "password1"; + + const cardCipher = new CipherView(); + cardCipher.id = mockCipherId2; + cardCipher.type = CipherType.Card; + + const identityCipher = new CipherView(); + identityCipher.id = mockCipherId3; + identityCipher.type = CipherType.Identity; + + await cipherRiskService.computeRiskForCiphers( + [loginCipher, cardCipher, identityCipher], + mockUserId, + ); + + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith( + [ + expect.objectContaining({ + id: expect.anything(), + password: "password1", + }), + ], + expect.any(Object), + ); + }); + + it("should filter out Login ciphers without passwords", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + mockCipherRiskClient.compute_risk.mockResolvedValue([]); + + const cipherWithPassword = new CipherView(); + cipherWithPassword.id = mockCipherId1; + cipherWithPassword.type = CipherType.Login; + cipherWithPassword.login = new LoginView(); + cipherWithPassword.login.password = "password1"; + + const cipherWithoutPassword = new CipherView(); + cipherWithoutPassword.id = mockCipherId2; + cipherWithoutPassword.type = CipherType.Login; + cipherWithoutPassword.login = new LoginView(); + cipherWithoutPassword.login.password = undefined; + + const cipherWithEmptyPassword = new CipherView(); + cipherWithEmptyPassword.id = mockCipherId3; + cipherWithEmptyPassword.type = CipherType.Login; + cipherWithEmptyPassword.login = new LoginView(); + cipherWithEmptyPassword.login.password = ""; + + await cipherRiskService.computeRiskForCiphers( + [cipherWithPassword, cipherWithoutPassword, cipherWithEmptyPassword], + mockUserId, + ); + + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith( + [ + expect.objectContaining({ + password: "password1", + }), + ], + expect.any(Object), + ); + }); + + it("should return empty array when no valid Login ciphers provided", async () => { + const cardCipher = new CipherView(); + cardCipher.type = CipherType.Card; + + const results = await cipherRiskService.computeRiskForCiphers([cardCipher], mockUserId); + + expect(results).toEqual([]); + }); + + it("should handle multiple Login ciphers", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + const mockRiskResults: CipherRiskResult[] = [ + { + id: mockCipherId1 as any, + password_strength: 3, + exposed_result: { type: "Found", value: 5 }, + reuse_count: 2, + }, + { + id: mockCipherId2 as any, + password_strength: 4, + exposed_result: { type: "NotChecked" }, + reuse_count: 1, + }, + ]; + + mockCipherRiskClient.compute_risk.mockResolvedValue(mockRiskResults); + + const cipher1 = new CipherView(); + cipher1.id = mockCipherId1; + cipher1.type = CipherType.Login; + cipher1.login = new LoginView(); + cipher1.login.password = "password1"; + cipher1.login.username = "user1@example.com"; + + const cipher2 = new CipherView(); + cipher2.id = mockCipherId2; + cipher2.type = CipherType.Login; + cipher2.login = new LoginView(); + cipher2.login.password = "password2"; + cipher2.login.username = "user2@example.com"; + + const results = await cipherRiskService.computeRiskForCiphers([cipher1, cipher2], mockUserId); + + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith( + [ + expect.objectContaining({ password: "password1", username: "user1@example.com" }), + expect.objectContaining({ password: "password2", username: "user2@example.com" }), + ], + expect.any(Object), + ); + expect(results).toEqual(mockRiskResults); + }); + + it("should use default options when options not provided", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + mockCipherRiskClient.compute_risk.mockResolvedValue([]); + + const cipher = new CipherView(); + cipher.id = mockCipherId1; + cipher.type = CipherType.Login; + cipher.login = new LoginView(); + cipher.login.password = "test-password"; + + await cipherRiskService.computeRiskForCiphers([cipher], mockUserId); + + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), { + checkExposed: false, + passwordMap: undefined, + hibpBaseUrl: undefined, + }); + }); + + it("should handle ciphers without username", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + mockCipherRiskClient.compute_risk.mockResolvedValue([]); + + const cipher = new CipherView(); + cipher.id = mockCipherId1; + cipher.type = CipherType.Login; + cipher.login = new LoginView(); + cipher.login.password = "test-password"; + cipher.login.username = undefined; + + await cipherRiskService.computeRiskForCiphers([cipher], mockUserId); + + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith( + [ + expect.objectContaining({ + password: "test-password", + username: undefined, + }), + ], + expect.any(Object), + ); + }); + }); + + describe("buildPasswordReuseMap", () => { + it("should call SDK cipher_risk().password_reuse_map() with correct parameters", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + const mockReuseMap = { + password1: 2, + password2: 1, + }; + + mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap); + + const cipher1 = new CipherView(); + cipher1.id = mockCipherId1; + cipher1.type = CipherType.Login; + cipher1.login = new LoginView(); + cipher1.login.password = "password1"; + + const cipher2 = new CipherView(); + cipher2.id = mockCipherId2; + cipher2.type = CipherType.Login; + cipher2.login = new LoginView(); + cipher2.login.password = "password2"; + + const result = await cipherRiskService.buildPasswordReuseMap([cipher1, cipher2], mockUserId); + + expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([ + expect.objectContaining({ password: "password1" }), + expect.objectContaining({ password: "password2" }), + ]); + expect(result).toEqual(mockReuseMap); + }); + }); + + describe("computeCipherRiskForUser", () => { + it("should compute risk for a single cipher with password reuse map", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + // Setup cipher data + const cipher1 = new CipherView(); + cipher1.id = mockCipherId1; + cipher1.type = CipherType.Login; + cipher1.login = new LoginView(); + cipher1.login.password = "password1"; + cipher1.login.username = "user1@example.com"; + + const cipher2 = new CipherView(); + cipher2.id = mockCipherId2; + cipher2.type = CipherType.Login; + cipher2.login = new LoginView(); + cipher2.login.password = "password1"; // Same password as cipher1 + cipher2.login.username = "user2@example.com"; + + const allCiphers = [cipher1, cipher2]; + + // Mock cipherViews$ observable + mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject(allCiphers)); + + // Mock password reuse map + const mockReuseMap = { password1: 2 }; + mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap); + + // Mock compute_risk result + const mockRiskResult: CipherRiskResult = { + id: mockCipherId1 as any, + password_strength: 3, + exposed_result: { type: "NotChecked" }, + reuse_count: 2, + }; + mockCipherRiskClient.compute_risk.mockResolvedValue([mockRiskResult]); + + const result = await cipherRiskService.computeCipherRiskForUser( + asUuid(mockCipherId1), + mockUserId, + true, + ); + + // Verify cipherViews$ was called + expect(mockCipherService.cipherViews$).toHaveBeenCalledWith(mockUserId); + + // Verify password_reuse_map was called with all ciphers + expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([ + expect.objectContaining({ password: "password1", username: "user1@example.com" }), + expect.objectContaining({ password: "password1", username: "user2@example.com" }), + ]); + + // Verify compute_risk was called with target cipher and password map + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith( + [expect.objectContaining({ password: "password1", username: "user1@example.com" })], + { + passwordMap: mockReuseMap, + checkExposed: true, + }, + ); + + expect(result).toEqual(mockRiskResult); + }); + + it("should throw error when cipher is not found", async () => { + const cipher1 = new CipherView(); + cipher1.id = mockCipherId1; + cipher1.type = CipherType.Login; + cipher1.login = new LoginView(); + cipher1.login.password = "password1"; + + mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher1])); + + const nonExistentId = "00000000-0000-0000-0000-000000000000"; + await expect( + cipherRiskService.computeCipherRiskForUser(asUuid(nonExistentId), mockUserId), + ).rejects.toThrow(`Cipher with id ${asUuid(nonExistentId)} not found`); + }); + + it("should use checkExposed parameter correctly", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + const cipher = new CipherView(); + cipher.id = mockCipherId1; + cipher.type = CipherType.Login; + cipher.login = new LoginView(); + cipher.login.password = "password1"; + + mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher])); + mockCipherRiskClient.password_reuse_map.mockReturnValue({}); + mockCipherRiskClient.compute_risk.mockResolvedValue([ + { + id: mockCipherId1 as any, + password_strength: 4, + exposed_result: { type: "NotChecked" }, + reuse_count: 1, + }, + ]); + + await cipherRiskService.computeCipherRiskForUser( + asUuid(mockCipherId1), + mockUserId, + false, + ); + + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), { + passwordMap: expect.any(Object), + checkExposed: false, + }); + }); + + it("should default checkExposed to true when not provided", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + const cipher = new CipherView(); + cipher.id = mockCipherId1; + cipher.type = CipherType.Login; + cipher.login = new LoginView(); + cipher.login.password = "password1"; + + mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher])); + mockCipherRiskClient.password_reuse_map.mockReturnValue({}); + mockCipherRiskClient.compute_risk.mockResolvedValue([ + { + id: mockCipherId1 as any, + password_strength: 4, + exposed_result: { type: "Found", value: 10 }, + reuse_count: 1, + }, + ]); + + await cipherRiskService.computeCipherRiskForUser(asUuid(mockCipherId1), mockUserId); + + expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(expect.any(Array), { + passwordMap: expect.any(Object), + checkExposed: true, + }); + }); + + it("should handle ciphers without passwords when building password map", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + const cipherWithPassword = new CipherView(); + cipherWithPassword.id = mockCipherId1; + cipherWithPassword.type = CipherType.Login; + cipherWithPassword.login = new LoginView(); + cipherWithPassword.login.password = "password1"; + + const cipherWithoutPassword = new CipherView(); + cipherWithoutPassword.id = mockCipherId2; + cipherWithoutPassword.type = CipherType.Login; + cipherWithoutPassword.login = new LoginView(); + cipherWithoutPassword.login.password = ""; + + mockCipherService.cipherViews$.mockReturnValue( + new BehaviorSubject([cipherWithPassword, cipherWithoutPassword]), + ); + mockCipherRiskClient.password_reuse_map.mockReturnValue({}); + mockCipherRiskClient.compute_risk.mockResolvedValue([ + { + id: mockCipherId1 as any, + password_strength: 4, + exposed_result: { type: "NotChecked" }, + reuse_count: 1, + }, + ]); + + await cipherRiskService.computeCipherRiskForUser(asUuid(mockCipherId1), mockUserId); + + // Verify password_reuse_map only received cipher with password + expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([ + expect.objectContaining({ password: "password1" }), + ]); + }); + + it("should handle non-Login ciphers in vault when building password map", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + const loginCipher = new CipherView(); + loginCipher.id = mockCipherId1; + loginCipher.type = CipherType.Login; + loginCipher.login = new LoginView(); + loginCipher.login.password = "password1"; + + const cardCipher = new CipherView(); + cardCipher.id = mockCipherId2; + cardCipher.type = CipherType.Card; + + const noteCipher = new CipherView(); + noteCipher.id = mockCipherId3; + noteCipher.type = CipherType.SecureNote; + + mockCipherService.cipherViews$.mockReturnValue( + new BehaviorSubject([loginCipher, cardCipher, noteCipher]), + ); + mockCipherRiskClient.password_reuse_map.mockReturnValue({}); + mockCipherRiskClient.compute_risk.mockResolvedValue([ + { + id: mockCipherId1 as any, + password_strength: 4, + exposed_result: { type: "NotChecked" }, + reuse_count: 1, + }, + ]); + + await cipherRiskService.computeCipherRiskForUser(asUuid(mockCipherId1), mockUserId); + + // Verify password_reuse_map only received Login cipher + expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([ + expect.objectContaining({ password: "password1" }), + ]); + }); + + it("should compute fresh password map on each call", async () => { + const mockClient = sdkService.simulate.userLogin(mockUserId); + const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep(); + + const cipher = new CipherView(); + cipher.id = mockCipherId1; + cipher.type = CipherType.Login; + cipher.login = new LoginView(); + cipher.login.password = "password1"; + + mockCipherService.cipherViews$.mockReturnValue(new BehaviorSubject([cipher])); + mockCipherRiskClient.password_reuse_map.mockReturnValue({ password1: 1 }); + mockCipherRiskClient.compute_risk.mockResolvedValue([ + { + id: mockCipherId1 as any, + password_strength: 4, + exposed_result: { type: "NotChecked" }, + reuse_count: 1, + }, + ]); + + // First call + await cipherRiskService.computeCipherRiskForUser(asUuid(mockCipherId1), mockUserId); + + // Second call + await cipherRiskService.computeCipherRiskForUser(asUuid(mockCipherId1), mockUserId); + + // Verify password_reuse_map was called twice (fresh computation each time) + expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/libs/common/src/vault/services/default-cipher-risk.service.ts b/libs/common/src/vault/services/default-cipher-risk.service.ts new file mode 100644 index 00000000000..d9f0243edfe --- /dev/null +++ b/libs/common/src/vault/services/default-cipher-risk.service.ts @@ -0,0 +1,115 @@ +import { firstValueFrom, switchMap } from "rxjs"; + +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { + CipherLoginDetails, + CipherRiskOptions, + PasswordReuseMap, + CipherId, + CipherRiskResult, +} from "@bitwarden/sdk-internal"; + +import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service"; +import { UserId } from "../../types/guid"; +import { CipherRiskService as CipherRiskServiceAbstraction } from "../abstractions/cipher-risk.service"; +import { CipherType } from "../enums/cipher-type"; +import { CipherView } from "../models/view/cipher.view"; + +export class DefaultCipherRiskService implements CipherRiskServiceAbstraction { + constructor( + private sdkService: SdkService, + private cipherService: CipherService, + ) {} + + async computeRiskForCiphers( + ciphers: CipherView[], + userId: UserId, + options?: CipherRiskOptions, + ): Promise { + const loginDetails = this.mapToLoginDetails(ciphers); + + if (loginDetails.length === 0) { + return []; + } + + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + using ref = sdk.take(); + const cipherRiskClient = ref.value.vault().cipher_risk(); + return await cipherRiskClient.compute_risk( + loginDetails, + options ?? { checkExposed: false }, + ); + }), + ), + ); + } + + async computeCipherRiskForUser( + cipherId: CipherId, + userId: UserId, + checkExposed: boolean = true, + ): Promise { + // Get all ciphers for the user + const allCiphers = await firstValueFrom(this.cipherService.cipherViews$(userId)); + + // Find the specific cipher + const targetCipher = allCiphers?.find((c) => asUuid(c.id) === cipherId); + if (!targetCipher) { + throw new Error(`Cipher with id ${cipherId} not found`); + } + + // Build fresh password reuse map from all ciphers + const passwordMap = await this.buildPasswordReuseMap(allCiphers, userId); + + // Call existing computeRiskForCiphers with single cipher and map + const results = await this.computeRiskForCiphers([targetCipher], userId, { + passwordMap, + checkExposed, + }); + + return results[0]; + } + + async buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise { + const loginDetails = this.mapToLoginDetails(ciphers); + + if (loginDetails.length === 0) { + return {}; + } + + return await firstValueFrom( + this.sdkService.userClient$(userId).pipe( + switchMap(async (sdk) => { + using ref = sdk.take(); + const cipherRiskClient = ref.value.vault().cipher_risk(); + return cipherRiskClient.password_reuse_map(loginDetails); + }), + ), + ); + } + + /** + * Maps CipherView array to CipherLoginDetails array for SDK consumption. + * Only includes Login ciphers with non-empty passwords. + */ + private mapToLoginDetails(ciphers: CipherView[]): CipherLoginDetails[] { + return ciphers + .filter((cipher) => { + return ( + cipher.type === CipherType.Login && + cipher.login?.password != null && + cipher.login.password !== "" + ); + }) + .map( + (cipher) => + ({ + id: asUuid(cipher.id), + password: cipher.login.password!, + username: cipher.login.username, + }) satisfies CipherLoginDetails, + ); + } +} diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index 350d493f832..6ef5309b018 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -92,7 +92,6 @@ export class ButtonComponent implements ButtonLikeAbstraction { "hover:!tw-text-muted", "aria-disabled:tw-cursor-not-allowed", "hover:tw-no-underline", - "aria-disabled:tw-pointer-events-none", ] : [], ) diff --git a/libs/components/src/card/base-card/base-card.component.ts b/libs/components/src/card/base-card/base-card.component.ts index 44f82a32c47..8c4dd80f2d1 100644 --- a/libs/components/src/card/base-card/base-card.component.ts +++ b/libs/components/src/card/base-card/base-card.component.ts @@ -6,6 +6,8 @@ import { BaseCardDirective } from "./base-card.directive"; * The base card component is a container that applies our standard card border and box-shadow. * In most cases using our `` component should suffice. */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-base-card", template: ``, diff --git a/libs/components/src/card/card-content.component.ts b/libs/components/src/card/card-content.component.ts index 60be20e78f0..650a2665475 100644 --- a/libs/components/src/card/card-content.component.ts +++ b/libs/components/src/card/card-content.component.ts @@ -1,5 +1,7 @@ import { Component } from "@angular/core"; +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-card-content", template: `
      `, diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index f1edee7c089..9887c0bde8b 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -17,6 +17,7 @@ import { setA11yTitleAndAriaLabel } from "../a11y/set-a11y-title-and-aria-label" import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; import { FocusableElement } from "../shared/focusable-element"; import { SpinnerComponent } from "../spinner"; +import { TooltipDirective } from "../tooltip"; import { ariaDisableElement } from "../utils"; export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast"; @@ -100,7 +101,10 @@ const sizes: Record = { */ "[attr.bitIconButton]": "icon()", }, - hostDirectives: [AriaDisableDirective], + hostDirectives: [ + AriaDisableDirective, + { directive: TooltipDirective, inputs: ["tooltipPosition"] }, + ], }) export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { readonly icon = model.required({ alias: "bitIconButton" }); @@ -109,6 +113,9 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE readonly size = model("default"); + private elementRef = inject(ElementRef); + private tooltip = inject(TooltipDirective, { host: true, optional: true }); + /** * label input will be used to set the `aria-label` attributes on the button. * This is for accessibility purposes, as it provides a text alternative for the icon button. @@ -186,8 +193,6 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE return this.elementRef.nativeElement; } - private elementRef = inject(ElementRef); - constructor() { const element = this.elementRef.nativeElement; @@ -198,9 +203,15 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE effect(() => { setA11yTitleAndAriaLabel({ element: this.elementRef.nativeElement, - title: originalTitle ?? this.label(), + title: undefined, label: this.label(), }); + + const tooltipContent: string = originalTitle || this.label(); + + if (tooltipContent) { + this.tooltip?.tooltipContent.set(tooltipContent); + } }); } } diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index f36a3fdddf5..643b5d69da7 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -42,6 +42,7 @@ export * from "./table"; export * from "./tabs"; export * from "./toast"; export * from "./toggle-group"; +export * from "./tooltip"; export * from "./typography"; export * from "./utils"; export * from "./stepper"; diff --git a/libs/components/src/navigation/nav-group.component.html b/libs/components/src/navigation/nav-group.component.html index 195569292f6..bcf6ae2b5b7 100644 --- a/libs/components/src/navigation/nav-group.component.html +++ b/libs/components/src/navigation/nav-group.component.html @@ -8,7 +8,8 @@ [routerLinkActiveOptions]="routerLinkActiveOptions()" (mainContentClicked)="handleMainContentClicked()" [ariaLabel]="ariaLabel()" - [hideActiveStyles]="parentHideActiveStyles" + [hideActiveStyles]="parentHideActiveStyles()" + [ariaCurrentWhenActive]="ariaCurrent()" >