diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 412f166629e..05b89d66c33 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -38,7 +38,7 @@ defaults: jobs: cloc: name: CLOC - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout repo uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 @@ -54,7 +54,7 @@ jobs: setup: name: Setup - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: repo_url: ${{ steps.gen_vars.outputs.repo_url }} adj_build_number: ${{ steps.gen_vars.outputs.adj_build_number }} @@ -71,7 +71,7 @@ jobs: locales-test: name: Locales Test - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - setup defaults: @@ -108,7 +108,7 @@ jobs: build: name: Build - runs-on: windows-2019 + runs-on: ubuntu-22.04 needs: - setup - locales-test @@ -137,6 +137,7 @@ jobs: run: | node --version npm --version + node-gyp --version - name: NPM setup run: npm ci @@ -152,24 +153,27 @@ jobs: run: gulp ci - name: Build sources for reviewers - shell: cmd run: | - REM Remove ".git" directory - rmdir /S /Q ".git" + # Include hidden files in glob copy + shopt -s dotglob - REM Copy root level files to source directory + # Remove ".git" directory + rm -r .git + + # Copy root level files to source directory mkdir browser-source - copy * browser-source + FILES=$(find . -maxdepth 1 -type f) + for FILE in $FILES; do cp "$FILE" browser-source/; done - REM Copy apps\browser to Browser source directory - mkdir browser-source\apps\browser - xcopy apps\browser\* browser-source\apps\browser /E + # Copy apps/browser to Browser source directory + mkdir -p browser-source/apps/browser + cp -r apps/browser/* browser-source/apps/browser - REM Copy libs to Browser source directory - mkdir browser-source\libs - xcopy libs\* browser-source\libs /E + # Copy libs to Browser source directory + mkdir browser-source/libs + cp -r libs/* browser-source/libs - call 7z a browser-source.zip "browser-source\*" + zip -r browser-source.zip browser-source working-directory: ./ - name: Upload Opera artifact @@ -339,7 +343,7 @@ jobs: crowdin-push: name: Crowdin Push if: github.ref == 'refs/heads/master' - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - build - build-safari @@ -354,7 +358,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@37ffa14164a7308bc273829edfe75c97cd562375 with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" @@ -374,7 +378,7 @@ jobs: check-failures: name: Check for failures if: always() - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - cloc - setup @@ -416,7 +420,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@37ffa14164a7308bc273829edfe75c97cd562375 with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/release-browser.yml b/.github/workflows/release-browser.yml index 20f3f7efac5..407f81deb60 100644 --- a/.github/workflows/release-browser.yml +++ b/.github/workflows/release-browser.yml @@ -22,7 +22,7 @@ defaults: jobs: setup: name: Setup - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: release-version: ${{ steps.version.outputs.version }} steps: @@ -41,7 +41,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/release-version-check@58a2fdfbd3f1fc7e6727bc5dc51d159f4df07072 with: release-type: ${{ github.event.inputs.release_type }} project-type: ts @@ -52,7 +52,7 @@ jobs: locales-test: name: Locales Test - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: setup steps: - name: Checkout repo @@ -86,7 +86,7 @@ jobs: release: name: Create GitHub Release - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - setup - locales-test diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 99119725a3e..22b76f46408 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -56,7 +56,7 @@ jobs: uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Branch check - if: ${{ github.event.inputs.release_type != 'Dry Run' }} + if: ${{ inputs.release_type != 'Dry Run' }} run: | if [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc-desktop" ]]; then echo "===================================" @@ -69,7 +69,7 @@ jobs: id: version uses: bitwarden/gh-actions/release-version-check@67ab95d7a466bcefdedf3f93cbc10bcff436edfe with: - release-type: ${{ github.event.inputs.release_type }} + release-type: ${{ inputs.release_type }} project-type: ts file: apps/desktop/src/package.json monorepo: true @@ -93,7 +93,7 @@ jobs: esac - name: Create GitHub deployment - if: ${{ github.event.inputs.release_type != 'Dry Run' }} + if: ${{ inputs.release_type != 'Dry Run' }} uses: chrnorm/deployment-action@d42cde7132fcec920de534fffc3be83794335c00 # v2.0.5 id: deployment with: @@ -122,7 +122,7 @@ jobs: cf-prod-account" - name: Download all artifacts - if: ${{ github.event.inputs.release_type != 'Dry Run' }} + if: ${{ inputs.release_type != 'Dry Run' }} uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe with: workflow: build-desktop.yml @@ -131,7 +131,7 @@ jobs: path: apps/desktop/artifacts - name: Dry Run - Download all artifacts - if: ${{ github.event.inputs.release_type == 'Dry Run' }} + if: ${{ inputs.release_type == 'Dry Run' }} uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe with: workflow: build-desktop.yml @@ -146,17 +146,17 @@ jobs: run: mv Bitwarden-${{ env.PKG_VERSION }}-universal.pkg Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive - name: Set staged rollout percentage - if: ${{ github.event.inputs.electron_publish }} + if: ${{ inputs.electron_publish == 'true' }} env: RELEASE_CHANNEL: ${{ steps.release-channel.outputs.channel }} - ROLLOUT_PCT: ${{ github.event.inputs.rollout_percentage }} + ROLLOUT_PCT: ${{ inputs.rollout_percentage }} run: | echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}.yml echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}-linux.yml echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}-mac.yml - name: Publish artifacts to S3 - if: ${{ github.event.inputs.release_type != 'Dry Run' && github.event.inputs.electron_publish }} + if: ${{ inputs.release_type != 'Dry Run' && inputs.electron_publish == 'true' }} env: AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }} AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.aws-electron-access-key }} @@ -170,7 +170,7 @@ jobs: --quiet - name: Publish artifacts to R2 - if: ${{ github.event.inputs.release_type != 'Dry Run' && github.event.inputs.electron_publish }} + if: ${{ inputs.release_type != 'Dry Run' && inputs.electron_publish == 'true' }} env: AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.r2-electron-access-id }} AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.r2-electron-access-key }} @@ -192,7 +192,7 @@ jobs: - name: Create Release uses: ncipollo/release-action@a2e71bdd4e7dab70ca26a852f29600c98b33153e # v1.12.0 - if: ${{ steps.release-channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' && inputs.github_release }} + if: ${{ steps.release-channel.outputs.channel == 'latest' && inputs.release_type != 'Dry Run' && inputs.github_release == 'true' }} env: PKG_VERSION: ${{ steps.version.outputs.version }} RELEASE_CHANNEL: ${{ steps.release-channel.outputs.channel }} @@ -230,7 +230,7 @@ jobs: draft: true - name: Update deployment status to Success - if: ${{ github.event.inputs.release_type != 'Dry Run' && success() }} + if: ${{ inputs.release_type != 'Dry Run' && success() }} uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1 with: token: '${{ secrets.GITHUB_TOKEN }}' @@ -238,7 +238,7 @@ jobs: deployment-id: ${{ steps.deployment.outputs.deployment_id }} - name: Update deployment status to Failure - if: ${{ github.event.inputs.release_type != 'Dry Run' && failure() }} + if: ${{ inputs.release_type != 'Dry Run' && failure() }} uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1 with: token: '${{ secrets.GITHUB_TOKEN }}' @@ -249,7 +249,7 @@ jobs: name: Deploy Snap runs-on: ubuntu-22.04 needs: setup - if: inputs.snap_publish + if: ${{ inputs.snap_publish == 'true' }} env: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: @@ -278,7 +278,7 @@ jobs: working-directory: apps/desktop - name: Download Snap artifact - if: ${{ github.event.inputs.release_type != 'Dry Run' }} + if: ${{ inputs.release_type != 'Dry Run' }} uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe with: workflow: build-desktop.yml @@ -288,7 +288,7 @@ jobs: path: apps/desktop/dist - name: Dry Run - Download Snap artifact - if: ${{ github.event.inputs.release_type == 'Dry Run' }} + if: ${{ inputs.release_type == 'Dry Run' }} uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe with: workflow: build-desktop.yml @@ -298,7 +298,7 @@ jobs: path: apps/desktop/dist - name: Deploy to Snap Store - if: ${{ github.event.inputs.release_type != 'Dry Run' }} + if: ${{ inputs.release_type != 'Dry Run' }} env: SNAPCRAFT_STORE_CREDENTIALS: ${{ steps.retrieve-secrets.outputs.snapcraft-store-token }} run: | @@ -310,7 +310,7 @@ jobs: name: Deploy Choco runs-on: windows-2019 needs: setup - if: inputs.choco_publish + if: ${{ inputs.choco_publish == 'true' }} env: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: @@ -346,7 +346,7 @@ jobs: working-directory: apps/desktop - name: Download choco artifact - if: ${{ github.event.inputs.release_type != 'Dry Run' }} + if: ${{ inputs.release_type != 'Dry Run' }} uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe with: workflow: build-desktop.yml @@ -356,7 +356,7 @@ jobs: path: apps/desktop/dist - name: Dry Run - Download choco artifact - if: ${{ github.event.inputs.release_type == 'Dry Run' }} + if: ${{ inputs.release_type == 'Dry Run' }} uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe with: workflow: build-desktop.yml @@ -366,7 +366,7 @@ jobs: path: apps/desktop/dist - name: Push to Chocolatey - if: ${{ github.event.inputs.release_type != 'Dry Run' }} + if: ${{ inputs.release_type != 'Dry Run' }} shell: pwsh run: choco push --source=https://push.chocolatey.org/ working-directory: apps/desktop/dist diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index 857099db511..7b1a787d946 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -44,4 +44,5 @@ jobs: uses: ./.github/workflows/version-bump.yml with: version_number: ${{ needs.setup.outputs.version_number }} - client: "Desktop" + bump_desktop: true + secrets: inherit diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 420ef456ec0..563facdb40c 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -4,16 +4,22 @@ name: Version Bump on: workflow_dispatch: inputs: - client: - description: "Client Project" - required: true - type: choice - options: - - Browser - - CLI - - Desktop - - Web - - All + bump_browser: + description: "Browser Project Version Bump" + type: boolean + default: false + bump_cli: + description: "CLI Project Version Bump" + type: boolean + default: false + bump_desktop: + description: "Desktop Project Version Bump" + type: boolean + default: false + bump_web: + description: "Web Project Version Bump" + type: boolean + default: false version_number: description: "New Version" required: true @@ -23,9 +29,10 @@ on: version_number: required: true type: string - client: - required: true - type: string + bump_desktop: + description: "Desktop Project Version Bump" + type: boolean + default: false defaults: run: @@ -33,8 +40,8 @@ defaults: jobs: bump_version: - name: "Bump ${{ github.event.inputs.client }} Version" - runs-on: ubuntu-20.04 + name: "Bump Version" + runs-on: ubuntu-22.04 steps: - name: Checkout Branch uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 @@ -42,7 +49,7 @@ jobs: - name: Login to Azure - Prod Subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - name: Retrieve secrets id: retrieve-secrets @@ -62,13 +69,27 @@ jobs: - name: Create Version Branch id: branch env: - CLIENT_NAME: ${{ github.event.inputs.client }} - VERSION: ${{ github.event.inputs.version_number }} + VERSION: ${{ inputs.version_number }} run: | - CLIENT=$(python -c "print('$CLIENT_NAME'.lower())") - echo "client=$CLIENT" >> $GITHUB_OUTPUT + CLIENTS=() + if [[ ${{ inputs.bump_browser }} == true ]]; then + CLIENTS+=("browser") + fi + if [[ ${{ inputs.bump_cli }} == true ]]; then + CLIENTS+=("cli") + fi + if [[ ${{ inputs.bump_desktop }} == true ]]; then + CLIENTS+=("desktop") + fi + if [[ ${{ inputs.bump_web }} == true ]]; then + CLIENTS+=("web") + fi + printf -v joined '%s,' "${CLIENTS[@]}" + echo "client=${joined%,}" >> $GITHUB_OUTPUT - git switch -c ${CLIENT}_version_bump_${VERSION} + BRANCH=version_bump_${VERSION}_${GITHUB_SHA:0:7} + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + git switch -c ${BRANCH} ######################## # VERSION BUMP SECTION # @@ -76,27 +97,27 @@ jobs: ### Browser - name: Bump Browser Version - if: ${{ github.event.inputs.client == 'Browser' || github.event.inputs.client == 'All' }} + if: ${{ inputs.bump_browser == true }} env: - VERSION: ${{ github.event.inputs.version_number }} + VERSION: ${{ inputs.version_number }} run: npm version --workspace=@bitwarden/browser ${VERSION} - name: Bump Browser Version - Manifest - if: ${{ github.event.inputs.client == 'Browser' || github.event.inputs.client == 'All' }} + if: ${{ inputs.bump_browser == true }} uses: bitwarden/gh-actions/version-bump@67ab95d7a466bcefdedf3f93cbc10bcff436edfe with: - version: ${{ github.event.inputs.version_number }} + version: ${{ inputs.version_number }} file_path: "apps/browser/src/manifest.json" - name: Bump Browser Version - Manifest v3 - if: ${{ github.event.inputs.client == 'Browser' || github.event.inputs.client == 'All' }} + if: ${{ inputs.bump_browser == true }} uses: bitwarden/gh-actions/version-bump@67ab95d7a466bcefdedf3f93cbc10bcff436edfe with: - version: ${{ github.event.inputs.version_number }} + version: ${{ inputs.version_number }} file_path: "apps/browser/src/manifest.v3.json" - name: Run Prettier after Browser Version Bump - if: ${{ github.event.inputs.client == 'Browser' || github.event.inputs.client == 'All' }} + if: ${{ inputs.bump_browser == true }} run: | npm install -g prettier prettier --write apps/browser/src/manifest.json @@ -104,30 +125,30 @@ jobs: ### CLI - name: Bump CLI Version - if: ${{ github.event.inputs.client == 'CLI' || github.event.inputs.client == 'All' }} + if: ${{ inputs.bump_cli == true }} env: - VERSION: ${{ github.event.inputs.version_number }} + VERSION: ${{ inputs.version_number }} run: npm version --workspace=@bitwarden/cli ${VERSION} ### Desktop - name: Bump Desktop Version - Root - if: ${{ github.event.inputs.client == 'Desktop' || github.event.inputs.client == 'All' }} + if: ${{ inputs.bump_desktop == true }} env: - VERSION: ${{ github.event.inputs.version_number }} + VERSION: ${{ inputs.version_number }} run: npm version --workspace=@bitwarden/desktop ${VERSION} - name: Bump Desktop Version - App - if: ${{ github.event.inputs.client == 'Desktop' || github.event.inputs.client == 'All' }} + if: ${{ inputs.bump_desktop == true }} env: - VERSION: ${{ github.event.inputs.version_number }} + VERSION: ${{ inputs.version_number }} run: npm version ${VERSION} working-directory: "apps/desktop/src" ### Web - name: Bump Web Version - if: ${{ github.event.inputs.client == 'Web' || github.event.inputs.client == 'All' }} + if: ${{ inputs.bump_web == true }} env: - VERSION: ${{ github.event.inputs.version_number }} + VERSION: ${{ inputs.version_number }} run: npm version --workspace=@bitwarden/web-vault ${VERSION} ######################## @@ -151,27 +172,26 @@ jobs: if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} env: CLIENT: ${{ steps.branch.outputs.client }} - VERSION: ${{ github.event.inputs.version_number }} + VERSION: ${{ inputs.version_number }} run: git commit -m "Bumped ${CLIENT} version to ${VERSION}" -a - name: Push changes if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} env: - CLIENT: ${{ steps.branch.outputs.client }} - VERSION: ${{ github.event.inputs.version_number }} - run: git push -u origin ${CLIENT}_version_bump_${VERSION} + BRANCH: ${{ steps.branch.outputs.branch }} + run: git push -u origin ${BRANCH} - name: Create Bump Version PR if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} env: - PR_BRANCH: "${{ steps.branch.outputs.client }}_version_bump_${{ github.event.inputs.version_number }}" - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" BASE_BRANCH: master - TITLE: "Bump ${{ github.event.inputs.client }} version to ${{ github.event.inputs.version_number }}" + BRANCH: ${{ steps.branch.outputs.branch }} + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + TITLE: "Bump ${{ steps.branch.outputs.client }} version to ${{ inputs.version_number }}" run: | gh pr create --title "$TITLE" \ - --base "$BASE" \ - --head "$PR_BRANCH" \ + --base "$BASE_BRANCH" \ + --head "$BRANCH" \ --label "version update" \ --label "automated pr" \ --body " @@ -183,5 +203,4 @@ jobs: - [X] Other ## Objective - Automated ${{ github.event.inputs.client }} version bump to ${{ github.event.inputs.version_number }}" - + Automated ${{ steps.branch.outputs.client }} version bump to ${{ inputs.version_number }}" diff --git a/apps/browser/package.json b/apps/browser/package.json index 2e866653cd3..cec3a9caab1 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,12 +1,11 @@ { "name": "@bitwarden/browser", - "version": "2023.8.2", + "version": "2023.8.3", "scripts": { "build": "webpack", "build:mv3": "cross-env MANIFEST_VERSION=3 webpack", "build:watch": "webpack --watch", "build:watch:mv3": "cross-env MANIFEST_VERSION=3 webpack --watch", - "build:watch:autofill": "cross-env AUTOFILL_VERSION=2 webpack --watch", "build:prod": "cross-env NODE_ENV=production webpack", "build:prod:watch": "cross-env NODE_ENV=production webpack --watch", "dist": "npm run build:prod && gulp dist", @@ -19,6 +18,7 @@ "dist:safari:masdev": "npm run build:prod && gulp dist:safari:masdev", "dist:safari:dmg": "npm run build:prod && gulp dist:safari:dmg", "test": "jest", + "test:coverage": "jest --coverage --coverageDirectory=coverage", "test:watch": "jest --watch", "test:watch:all": "jest --watchAll" } diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 2b6c041a06d..6bb0efb8fd3 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 جيغابايت وحدة تخزين مشفرة لمرفقات الملفات." }, - "ppremiumSignUpTwoStep": { - "message": "خيارات تسجيل الدخول الإضافية من خطوتين مثل YubiKey و FIDO U2F و Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "نظافة كلمة المرور، صحة الحساب، وتقارير خرق البيانات للحفاظ على سلامة خزنتك." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "إصدار الخادم" }, - "selfHosted": { - "message": "استضافة ذاتية" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 347fc449fa4..4ee8ab8edaf 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "Fayl qoşmaları üçün 1 GB şifrələnmiş saxlama sahəsi" }, - "ppremiumSignUpTwoStep": { - "message": "YubiKey, FIDO U2F və Duo kimi iki mərhələli giriş seçimləri" + "premiumSignUpTwoStepOptions": { + "message": "YubiKey və Duo kimi mülkiyyətçi iki addımlı giriş seçimləri." }, "ppremiumSignUpReports": { "message": "Anbarınızın təhlükəsiyini təmin etmək üçün parol gigiyenası, hesab sağlamlığı və verilənlərin pozulması hesabatları." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server Versiyası" }, - "selfHosted": { - "message": "Öz-özünə sahiblik edən" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Üçüncü tərəf" diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 1df3d80b2e0..e57ea2eff54 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 ГБ зашыфраванага сховішча для далучаных файлаў." }, - "ppremiumSignUpTwoStep": { - "message": "Дадатковыя варыянты двухэтапнага ўваходу, такія як YubiKey, FIDO U2F і Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Гігіена пароляў, здароўе ўліковага запісу і справаздачы аб уцечках даных для забеспячэння бяспекі вашага сховішча." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Версія сервера" }, - "selfHosted": { - "message": "Уласнае размяшчэнне" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Іншы пастаўшчык" diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index ee3049ffe22..9e4d696193e 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB пространство за файлове, които се шифрират." }, - "ppremiumSignUpTwoStep": { - "message": "Двустепенно удостоверяване чрез YubiKey, FIDO U2F и Duo." + "premiumSignUpTwoStepOptions": { + "message": "Частно двустепенно удостоверяване чрез YubiKey и Duo." }, "ppremiumSignUpReports": { "message": "Проверки в списъците с публикувани пароли, проверка на регистрациите и доклади за пробивите в сигурността, което спомага трезорът ви да е допълнително защитен." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Версия на сървъра" }, - "selfHosted": { - "message": "Собствен хостинг" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index a7b49de5ab3..261ceea180d 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "ফাইল সংযুক্তির জন্য ১ জিবি এনক্রিপ্টেড স্থান।" }, - "ppremiumSignUpTwoStep": { - "message": "YubiKey, FIDO U2F, ও Duo এর মতো অতিরিক্ত দ্বি-পদক্ষেপ লগইন বিকল্পগুলি।" + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "আপনার ভল্টটি সুরক্ষিত রাখতে পাসওয়ার্ড স্বাস্থ্যকরন, অ্যাকাউন্ট স্বাস্থ্য এবং ডেটা লঙ্ঘনের প্রতিবেদন।" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index cf2b0fd8a52..5b27e7186d4 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index f00775b993e..7a7edc3d896 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -339,7 +339,7 @@ "message": "Altres" }, "unlockMethodNeededToChangeTimeoutActionDesc": { - "message": "Set up an unlock method to change your vault timeout action." + "message": "Configura un mètode de desbloqueig per canviar l'acció del temps d'espera de la caixa forta." }, "rateExtension": { "message": "Valora aquesta extensió" @@ -634,10 +634,10 @@ "message": "Actualitza" }, "notificationUnlockDesc": { - "message": "Unlock your Bitwarden vault to complete the auto-fill request." + "message": "Desbloquegeu la vostra caixa forta de Bitwarden per completar la sol·licitud d'emplenament automàtic." }, "notificationUnlock": { - "message": "Unlock" + "message": "Desbloqueja" }, "enableContextMenuItem": { "message": "Mostra les opcions del menú contextual" @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB d'emmagatzematge xifrat per als fitxers adjunts." }, - "ppremiumSignUpTwoStep": { - "message": "Opcions addicionals d'inici de sessió en dues passes com ara YubiKey, FIDO U2F i Duo." + "premiumSignUpTwoStepOptions": { + "message": "Opcions propietàries de doble factor com ara YubiKey i Duo." }, "ppremiumSignUpReports": { "message": "Requisits d'higiene de la contrasenya, salut del compte i informe d'infraccions de dades per mantenir la seguretat de la vostra caixa forta." @@ -1606,10 +1606,10 @@ "message": "La biometria del navegador no és compatible amb aquest dispositiu." }, "biometricsFailedTitle": { - "message": "Biometrics failed" + "message": "La biometria ha fallat" }, "biometricsFailedDesc": { - "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + "message": "La biometria no es pot completar, considereu utilitzar una contrasenya mestra o tancar la sessió. Si això continua, poseu-vos en contacte amb el servei d'assistència de Bitwarden." }, "nativeMessaginPermissionErrorTitle": { "message": "No s'ha proporcionat el permís" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Versió del servidor" }, - "selfHosted": { - "message": "Autoallotjat" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Tercers" @@ -2153,7 +2153,7 @@ "message": "S'ha enviat una notificació al vostre dispositiu." }, "loginInitiated": { - "message": "Login initiated" + "message": "S'ha iniciat la sessió" }, "exposedMasterPassword": { "message": "Contrasenya mestra exposada" @@ -2234,34 +2234,34 @@ } }, "loggingInOn": { - "message": "Logging in on" + "message": "Inici de sessió en" }, "opensInANewWindow": { "message": "S'obri en una finestra nova" }, "deviceApprovalRequired": { - "message": "Device approval required. Select an approval option below:" + "message": "Cal l'aprovació del dispositiu. Seleccioneu una opció d'aprovació a continuació:" }, "rememberThisDevice": { - "message": "Remember this device" + "message": "Recorda aquest dispositiu" }, "uncheckIfPublicDevice": { - "message": "Uncheck if using a public device" + "message": "Desmarqueu si utilitzeu un dispositiu públic" }, "approveFromYourOtherDevice": { - "message": "Approve from your other device" + "message": "Aproveu des d'un altre dispositiu vostre" }, "requestAdminApproval": { - "message": "Request admin approval" + "message": "Sol·liciteu l'aprovació de l'administrador" }, "approveWithMasterPassword": { - "message": "Approve with master password" + "message": "Aprova amb contrasenya mestra" }, "ssoIdentifierRequired": { - "message": "Organization SSO identifier is required." + "message": "Es requereix un identificador SSO de l'organització." }, "eu": { - "message": "EU", + "message": "UE", "description": "European Union" }, "usDomain": { @@ -2280,28 +2280,28 @@ "message": "Mostra" }, "accountSuccessfullyCreated": { - "message": "Account successfully created!" + "message": "Compte creat correctament!" }, "adminApprovalRequested": { - "message": "Admin approval requested" + "message": "S'ha sol·licitat l'aprovació de l'administrador" }, "adminApprovalRequestSentToAdmins": { - "message": "Your request has been sent to your admin." + "message": "La vostra sol·licitud s'ha enviat a l'administrador." }, "youWillBeNotifiedOnceApproved": { - "message": "You will be notified once approved." + "message": "Se us notificarà una vegada aprovat." }, "troubleLoggingIn": { - "message": "Trouble logging in?" + "message": "Teniu problemes per iniciar la sessió?" }, "loginApproved": { - "message": "Login approved" + "message": "S'ha aprovat l'inici de sessió" }, "userEmailMissing": { - "message": "User email missing" + "message": "Falta el correu electrònic de l'usuari" }, "deviceTrusted": { - "message": "Device trusted" + "message": "Dispositiu de confiança" }, "inputRequired": { "message": "L'entrada és obligatòria." diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 4d642f35640..253aab484b7 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB šifrovaného úložiště pro přílohy." }, - "ppremiumSignUpTwoStep": { - "message": "Další možnosti dvoufázového přihlášení, jako je například YubiKey, FIDO U2F a Duo." + "premiumSignUpTwoStepOptions": { + "message": "Volby proprietálních dvoufázových přihlášení jako je YubiKey a Duo." }, "ppremiumSignUpReports": { "message": "Reporty o hygieně Vašich hesel, zdraví účtu a narušeních bezpečnosti." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Verze serveru" }, - "selfHosted": { - "message": "Vlastní hosting" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Tretí strana" diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 6b278cbd93b..7861a5e758b 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -7,7 +7,7 @@ "description": "Extension name, MUST be less than 40 characters (Safari restriction)" }, "extDesc": { - "message": "A secure and free password manager for all of your devices.", + "message": "Rheolydd cyfrineiriau diogel a rhad ac am ddim ar gyfer eich holl ddyfeisiau.", "description": "Extension description" }, "loginOrCreateNewAccount": { @@ -29,7 +29,7 @@ "message": "Cau" }, "submit": { - "message": "Submit" + "message": "Cyflwyno" }, "emailAddress": { "message": "Cyfeiriad ebost" @@ -227,10 +227,10 @@ "message": "Cell we Bitwarden" }, "importItems": { - "message": "Import items" + "message": "Mewnforio eitemau" }, "select": { - "message": "Select" + "message": "Dewis" }, "generatePassword": { "message": "Cynhyrchu cyfrinair" @@ -345,7 +345,7 @@ "message": "Rate the extension" }, "rateExtensionDesc": { - "message": "Please consider helping us out with a good review!" + "message": "Ystyriwch ein helpu ni gydag adolygiad da!" }, "browserNotSupportClipboard": { "message": "Your web browser does not support easy clipboard copying. Copy it manually instead." @@ -360,7 +360,7 @@ "message": "Datgloi" }, "loggedInAsOn": { - "message": "Logged in as $EMAIL$ on $HOSTNAME$.", + "message": "Wedi mewngofnodi gyda $EMAIL$ ar $HOSTNAME$.", "placeholders": { "email": { "content": "$1", @@ -379,7 +379,7 @@ "message": "Cloi'r gell" }, "lockNow": { - "message": "Lock now" + "message": "Cloi nawr" }, "immediately": { "message": "ar unwaith" @@ -427,7 +427,7 @@ "message": "Diogelwch" }, "errorOccurred": { - "message": "An error has occurred" + "message": "Bu gwall" }, "emailRequired": { "message": "Mae angen cyfeiriad ebost." @@ -513,13 +513,13 @@ "message": "Two-step login makes your account more secure by requiring you to verify your login with another device such as a security key, authenticator app, SMS, phone call, or email. Two-step login can be set up on the bitwarden.com web vault. Do you want to visit the website now?" }, "editedFolder": { - "message": "Folder saved" + "message": "Ffolder wedi'i chadw" }, "deleteFolderConfirmation": { "message": "Are you sure you want to delete this folder?" }, "deletedFolder": { - "message": "Folder deleted" + "message": "Ffolder wedi'i dileu" }, "gettingStartedTutorial": { "message": "Getting started tutorial" @@ -534,7 +534,7 @@ "message": "Syncing failed" }, "passwordCopied": { - "message": "Password copied" + "message": "Cyfrinair wedi'i gopïo" }, "uri": { "message": "URI" @@ -553,10 +553,10 @@ "message": "URI newydd" }, "addedItem": { - "message": "Item added" + "message": "Eitem wedi'i hychwanegu" }, "editedItem": { - "message": "Item saved" + "message": "Eitem wedi'i chadw" }, "deleteItemConfirmation": { "message": "Ydych chi wir eisiau anfon i'r sbwriel?" @@ -565,13 +565,13 @@ "message": "Anfonwyd yr eitem i'r sbwriel" }, "overwritePassword": { - "message": "Overwrite password" + "message": "Trosysgrifo'r cyfrinair" }, "overwritePasswordConfirmation": { "message": "Are you sure you want to overwrite the current password?" }, "overwriteUsername": { - "message": "Overwrite username" + "message": "Trosysgrifo'r enw defnyddiwr" }, "overwriteUsernameConfirmation": { "message": "Are you sure you want to overwrite the current username?" @@ -608,7 +608,7 @@ "message": "List identity items on the Tab page for easy auto-fill." }, "clearClipboard": { - "message": "Clear clipboard", + "message": "Clirio'r clipfwrdd", "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "clearClipboardDesc": { @@ -637,7 +637,7 @@ "message": "Unlock your Bitwarden vault to complete the auto-fill request." }, "notificationUnlock": { - "message": "Unlock" + "message": "Datgloi" }, "enableContextMenuItem": { "message": "Show context menu options" @@ -711,7 +711,7 @@ "message": "Rhannu" }, "movedItemToOrg": { - "message": "$ITEMNAME$ moved to $ORGNAME$", + "message": "Symudwyd $ITEMNAME$ i $ORGNAME$", "placeholders": { "itemname": { "content": "$1", @@ -751,10 +751,10 @@ "message": "Attachment deleted" }, "newAttachment": { - "message": "Add new attachment" + "message": "Ychwanegu atodiad newydd" }, "noAttachments": { - "message": "No attachments." + "message": "Dim atodiadau." }, "attachmentSaved": { "message": "Attachment saved" @@ -763,7 +763,7 @@ "message": "Ffeil" }, "selectFile": { - "message": "Select a file" + "message": "Dewis ffeil" }, "maxFileSize": { "message": "Maximum file size is 500 MB." @@ -787,40 +787,40 @@ "message": "Adnewyddu'ch aelodaeth" }, "premiumNotCurrentMember": { - "message": "You are not currently a Premium member." + "message": "Does gennych chi ddim aeloaeth uwch ar hyn o bryd." }, "premiumSignUpAndGet": { - "message": "Sign up for a Premium membership and get:" + "message": "Cofrestrwch ar gyfer aelodaeth uwch i gael:" }, "ppremiumSignUpStorage": { - "message": "1 GB encrypted storage for file attachments." + "message": "Storfa 1GB wedi'i hamgryptio ar gyfer atodiadau ffeiliau." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Dewisiadau mewngofnodi dau gam perchenogol megis YubiKey a Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." }, "ppremiumSignUpTotp": { - "message": "TOTP verification code (2FA) generator for logins in your vault." + "message": "Cynhyrchydd codau dilysu TOTP (2FA) ar gyfer manylion mewngofnodi yn eich cell." }, "ppremiumSignUpSupport": { - "message": "Priority customer support." + "message": "Cymorth wedi'i flaenoriaethu." }, "ppremiumSignUpFuture": { "message": "All future Premium features. More coming soon!" }, "premiumPurchase": { - "message": "Purchase Premium" + "message": "Prynu aelodaeth uwch" }, "premiumPurchaseAlert": { "message": "You can purchase Premium membership on the bitwarden.com web vault. Do you want to visit the website now?" }, "premiumCurrentMember": { - "message": "You are a Premium member!" + "message": "Mae gennych aelodaeth uwch!" }, "premiumCurrentMemberThanks": { - "message": "Thank you for supporting Bitwarden." + "message": "Diolch am gefnogi Bitwarden." }, "premiumPrice": { "message": "Hyn oll am $PRICE$ y flwyddyn!", @@ -844,10 +844,10 @@ "message": "Ask for biometrics on launch" }, "premiumRequired": { - "message": "Premium required" + "message": "Mae angen aelodaeth uwch" }, "premiumRequiredDesc": { - "message": "A Premium membership is required to use this feature." + "message": "Mae angen aelodaeth uwch i ddefnyddio'r nodwedd hon." }, "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." @@ -904,7 +904,7 @@ "message": "Please use a supported web browser (such as Chrome) and/or add additional providers that are better supported across web browsers (such as an authenticator app)." }, "twoStepOptions": { - "message": "Two-step login options" + "message": "Dewisiadau mewngofnodi dau gam" }, "recoveryCodeDesc": { "message": "Lost access to all of your two-factor providers? Use your recovery code to turn off all two-factor providers from your account." @@ -1033,7 +1033,7 @@ "message": "Copy value" }, "value": { - "message": "Value" + "message": "Gwerth" }, "newCustomField": { "message": "Maes addasedig newydd" @@ -1048,7 +1048,7 @@ "message": "Hidden" }, "cfTypeBoolean": { - "message": "Boolean" + "message": "Gwerth Boole" }, "cfTypeLinked": { "message": "Linked", @@ -1134,7 +1134,7 @@ "message": "Cod diogelwch" }, "ex": { - "message": "ex." + "message": "engh." }, "title": { "message": "Teitl" @@ -1297,7 +1297,7 @@ "message": "Starts with" }, "regEx": { - "message": "Regular expression", + "message": "Mynegiant rheolaidd", "description": "A programming term, also known as 'RegEx'." }, "matchDetection": { @@ -1427,7 +1427,7 @@ "message": "Vault timeout action" }, "lock": { - "message": "Lock", + "message": "Cloi", "description": "Verb form: to make secure or inaccesible by" }, "trash": { @@ -1438,13 +1438,13 @@ "message": "Chwilio drwy'r sbwriel" }, "permanentlyDeleteItem": { - "message": "Permanently delete item" + "message": "Dileu'r eitem yn barhaol" }, "permanentlyDeleteItemConfirmation": { "message": "Are you sure you want to permanently delete this item?" }, "permanentlyDeletedItem": { - "message": "Item permanently deleted" + "message": "Eitem wedi'i dileu'n barhaol" }, "restoreItem": { "message": "Adfer yr eitem" @@ -1546,7 +1546,7 @@ "message": "Terms of Service and Privacy Policy have not been acknowledged." }, "termsOfService": { - "message": "Terms of Service" + "message": "Telerau gwasanaeth" }, "privacyPolicy": { "message": "Polisi preifatrwydd" @@ -1671,7 +1671,7 @@ "description": "This text will be displayed after a Send has been accessed the maximum amount of times." }, "expired": { - "message": "Expired" + "message": "Wedi dod i ben" }, "pendingDeletion": { "message": "Pending deletion" @@ -1744,10 +1744,10 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "oneDay": { - "message": "1 day" + "message": "1 diwrnod" }, "days": { - "message": "$DAYS$ days", + "message": "$DAYS$ o ddyddiau", "placeholders": { "days": { "content": "$1", @@ -1854,7 +1854,7 @@ "message": "There was an error saving your deletion and expiration dates." }, "hideEmail": { - "message": "Hide my email address from recipients." + "message": "Cuddio fy nghyfeiriad ebost rhag derbynwyr." }, "sendOptionsPolicyInEffect": { "message": "One or more organization policies are affecting your Send options." @@ -2007,10 +2007,10 @@ "message": "Regenerate username" }, "generateUsername": { - "message": "Generate username" + "message": "Cynhyrchu enw defnyddiwr" }, "usernameType": { - "message": "Username type" + "message": "Math o enw defnyddiwr" }, "plusAddressedEmail": { "message": "Plus addressed email", @@ -2026,10 +2026,10 @@ "message": "Use your domain's configured catch-all inbox." }, "random": { - "message": "Random" + "message": "Hap" }, "randomWord": { - "message": "Random word" + "message": "Gair ar hap" }, "websiteName": { "message": "Website name" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2129,7 +2129,7 @@ "message": "New around here?" }, "rememberEmail": { - "message": "Remember email" + "message": "Cofio'r ebost" }, "loginWithDevice": { "message": "Mewngofnodi â dyfais" @@ -2165,19 +2165,19 @@ "message": "Weak and Exposed Master Password" }, "weakAndBreachedMasterPasswordDesc": { - "message": "Weak password identified and found in a data breach. Use a strong and unique password to protect your account. Are you sure you want to use this password?" + "message": "Cyfrinair gwan a gafodd ei ganfod mewn achos o ddatgelu data. Defnyddiwch gyfrinair cryf ac unigryw i ddiogelu eich cyfrif. Ydych chi wir eisiau defnyddio cyfrinair sydd wedi'i ddatgelu?" }, "checkForBreaches": { - "message": "Check known data breaches for this password" + "message": "Chwilio am achosion o ddatgelu data sy'n cynnwys y cyfrinair hwn" }, "important": { "message": "Pwysig:" }, "masterPasswordHint": { - "message": "Your master password cannot be recovered if you forget it!" + "message": "Allwch chi ddim adfer eich prif gyfrinair os caiff ei anghofio!" }, "characterMinimum": { - "message": "$LENGTH$ character minimum", + "message": "Isafswm o $LENGTH$ nod", "placeholders": { "length": { "content": "$1", @@ -2243,7 +2243,7 @@ "message": "Device approval required. Select an approval option below:" }, "rememberThisDevice": { - "message": "Remember this device" + "message": "Cofio'r ddyfais hon" }, "uncheckIfPublicDevice": { "message": "Uncheck if using a public device" @@ -2261,7 +2261,7 @@ "message": "Organization SSO identifier is required." }, "eu": { - "message": "EU", + "message": "UE", "description": "European Union" }, "usDomain": { @@ -2310,7 +2310,7 @@ "message": "required" }, "search": { - "message": "Search" + "message": "Chwilio" }, "inputMinLength": { "message": "Input must be at least $COUNT$ characters long.", @@ -2377,19 +2377,19 @@ } }, "selectPlaceholder": { - "message": "-- Select --" + "message": "-- Dewis --" }, "multiSelectPlaceholder": { - "message": "-- Type to filter --" + "message": "-- Teipiwch i hidlo --" }, "multiSelectLoading": { - "message": "Retrieving options..." + "message": "Yn nôl dewisiadau..." }, "multiSelectNotFound": { - "message": "No items found" + "message": "Heb ganfod eitemau" }, "multiSelectClearAll": { - "message": "Clear all" + "message": "Clirio'r cyfan" }, "plusNMore": { "message": "+ $QUANTITY$ more", @@ -2401,7 +2401,7 @@ } }, "submenu": { - "message": "Submenu" + "message": "Is-ddewislen" }, "toggleCollapse": { "message": "Toggle collapse", diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 7d258e9a5d1..df94e9aab71 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB krypteret lager til vedhæftede filer." }, - "ppremiumSignUpTwoStep": { - "message": "Yderligere to-trins login muligheder såsom YubiKey, FIDO U2F og Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietære totrins-login muligheder, såsom YubiKey og Duo." }, "ppremiumSignUpReports": { "message": "Adgangskodehygiejne, kontosundhed og rapporter om datalæk til at holde din boks sikker." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Selv-hostet" + "selfHostedServer": { + "message": "selv-hostet" }, "thirdParty": { "message": "Tredjepart" diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 8dfe56577e8..7dcdbc3c51f 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB verschlüsselter Speicherplatz für Dateianhänge." }, - "ppremiumSignUpTwoStep": { - "message": "Zusätzliche Zweifaktor-Anmeldung über YubiKey, FIDO U2F, und Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietäre Optionen für die Zwei-Faktor Authentifizierung wie YubiKey und Duo." }, "ppremiumSignUpReports": { "message": "Berichte über Kennworthygiene, Kontostatus und Datenschutzverletzungen, um deinen Tresor sicher zu halten." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server-Version" }, - "selfHosted": { - "message": "Selbst gehostet" + "selfHostedServer": { + "message": "selbst gehostet" }, "thirdParty": { "message": "Drittanbieter" diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index 5295936e303..1e377c35d0d 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -339,7 +339,7 @@ "message": "Άλλες" }, "unlockMethodNeededToChangeTimeoutActionDesc": { - "message": "Set up an unlock method to change your vault timeout action." + "message": "Ρυθμίστε μια μέθοδο ξεκλειδώματος για να αλλάξετε την ενέργεια χρονικού ορίου θησαυ/κιου." }, "rateExtension": { "message": "Βαθμολογήστε την επέκταση" @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB κρυπτογραφημένο αποθηκευτικό χώρο για συνημμένα αρχεία." }, - "ppremiumSignUpTwoStep": { - "message": "Πρόσθετες επιλογές σύνδεσης δύο βημάτων, όπως το YubiKey, το FIDO U2F και το Duo." + "premiumSignUpTwoStepOptions": { + "message": "Πρόσθετες επιλογές σύνδεσης δύο βημάτων, όπως το YubiKey και το Duo." }, "ppremiumSignUpReports": { "message": "Ασφάλεια κωδικών, υγεία λογαριασμού και αναφορές παραβίασης δεδομένων για να διατηρήσετε ασφαλές το vault σας." @@ -1438,7 +1438,7 @@ "message": "Αναζήτηση Κάδου" }, "permanentlyDeleteItem": { - "message": "Μόνιμη Διαγραφή Αντικειμένου" + "message": "Οριστική διαγραφή αντικειμένου" }, "permanentlyDeleteItemConfirmation": { "message": "Είστε βέβαιοι ότι θέλετε να διαγράψετε μόνιμα αυτό το στοιχείο;" @@ -1471,13 +1471,13 @@ "message": "Προειδοποίηση: Αυτή είναι μια μη ασφαλή σελίδα HTTP και οποιαδήποτε πληροφορία υποβάλλετε μπορεί να γίνει ορατή και επεμβάσιμη από άλλους. Αυτή η σύνδεση αποθηκεύτηκε αρχικά σε μια ασφαλή (HTTPS) σελίδα." }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "Θέλετε ακόμα να συμπληρώσετε αυτή τη σύνδεση;" }, "autofillIframeWarning": { "message": "Η φόρμα φιλοξενείται από διαφορετικό τομέα (domain) από το λινκ (uri) της αποθηκευμένης σύνδεσης σας (login). Επιλέξτε OK για αυτόματη συμπλήρωση, ή Ακύρωση για να σταματήσετε." }, "autofillIframeWarningTip": { - "message": "To prevent this warning in the future, save this URI, $HOSTNAME$, to your Bitwarden login item for this site.", + "message": "Για να αποτρέψετε αυτή την προειδοποίηση στο μέλλον, αποθηκεύστε αυτό το URI, $HOSTNAME$, στο στοιχείο σύνδεσης Bitwarden σας για αυτόν τον ιστότοπο.", "placeholders": { "hostname": { "content": "$1", @@ -1486,13 +1486,13 @@ } }, "setMasterPassword": { - "message": "Ορισμός Κύριου Κωδικού" + "message": "Καθορισμός κύριου κωδικού" }, "currentMasterPass": { "message": "Τρέχων Κύριος Κωδικός" }, "newMasterPass": { - "message": "Νέος Κύριος Κωδικός" + "message": "Νέος κύριος κωδικός" }, "confirmNewMasterPass": { "message": "Επιβεβαίωση Νέου Κύριου Κωδικού" @@ -1606,10 +1606,10 @@ "message": "Τα βιομετρικά στοιχεία του προγράμματος περιήγησης δεν υποστηρίζονται σε αυτήν τη συσκευή." }, "biometricsFailedTitle": { - "message": "Biometrics failed" + "message": "Ο βιομετρικός έλεγχος απέτυχε" }, "biometricsFailedDesc": { - "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + "message": "Τα βιομετρικά δεν μπόρεσαν να ολοκληρωθούν, σκεφτείτε να χρησιμοποιήσετε έναν κύριο κωδικό πρόσβασης ή να αποσυνδεθείτε. Αν αυτό εξακολουθεί να συμβαίνει, παρακαλώ επικοινωνήστε με την υποστήριξη της Bitwarden." }, "nativeMessaginPermissionErrorTitle": { "message": "Δεν Έχει Χορηγηθεί Άδεια" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Έκδοση διακομιστή" }, - "selfHosted": { - "message": "Αυτο-φιλοξενείται" + "selfHostedServer": { + "message": "αυτο-φιλοξενούμενο" }, "thirdParty": { "message": "Τρίτο μέρος" @@ -2153,7 +2153,7 @@ "message": "Μια ειδοποίηση έχει σταλεί στη συσκευή σας." }, "loginInitiated": { - "message": "Login initiated" + "message": "Η σύνδεση ξεκίνησε" }, "exposedMasterPassword": { "message": "Εκτεθειμένος Κύριος Κωδικός Πρόσβασης" @@ -2240,28 +2240,28 @@ "message": "Ανοίγει σε νέο παράθυρο" }, "deviceApprovalRequired": { - "message": "Device approval required. Select an approval option below:" + "message": "Απαιτείται έγκριση συσκευής. Επιλέξτε μια επιλογή έγκρισης παρακάτω:" }, "rememberThisDevice": { - "message": "Remember this device" + "message": "Απομνημόνευση αυτής της συσκευής" }, "uncheckIfPublicDevice": { - "message": "Uncheck if using a public device" + "message": "Αποεπιλέξτε αν γίνεται χρήση δημόσιας συσκευής" }, "approveFromYourOtherDevice": { - "message": "Approve from your other device" + "message": "Έγκριση από άλλη συσκευή σας" }, "requestAdminApproval": { - "message": "Request admin approval" + "message": "Αίτηση έγκρισης διαχειριστή" }, "approveWithMasterPassword": { - "message": "Approve with master password" + "message": "Έγκριση με τον κύριο κωδικό" }, "ssoIdentifierRequired": { - "message": "Organization SSO identifier is required." + "message": "Απαιτείται αναγνωριστικό οργανισμού SSO." }, "eu": { - "message": "EU", + "message": "ΕΕ", "description": "European Union" }, "usDomain": { @@ -2274,46 +2274,46 @@ "message": "Δεν επιτρέπεται η πρόσβαση. Δεν έχετε άδεια για να δείτε αυτή τη σελίδα." }, "general": { - "message": "General" + "message": "Γενικά" }, "display": { - "message": "Display" + "message": "Εμφάνιση" }, "accountSuccessfullyCreated": { - "message": "Account successfully created!" + "message": "Επιτυχής δημιουργία λογαριασμού!" }, "adminApprovalRequested": { - "message": "Admin approval requested" + "message": "Ζητήθηκε έγκριση διαχειριστή" }, "adminApprovalRequestSentToAdmins": { - "message": "Your request has been sent to your admin." + "message": "Το αίτημά σας εστάλη στον διαχειριστή σας." }, "youWillBeNotifiedOnceApproved": { - "message": "You will be notified once approved." + "message": "Θα ειδοποιηθείτε μόλις εγκριθεί." }, "troubleLoggingIn": { - "message": "Trouble logging in?" + "message": "Δεν μπορείτε να συνδεθείτε;" }, "loginApproved": { - "message": "Login approved" + "message": "Η σύνδεση εγκρίθηκε" }, "userEmailMissing": { - "message": "User email missing" + "message": "Το email του χρήστη απουσιάζει" }, "deviceTrusted": { - "message": "Device trusted" + "message": "Αξιόπιστη συσκευή" }, "inputRequired": { - "message": "Input is required." + "message": "Απαιτείται εισαγωγή." }, "required": { - "message": "required" + "message": "απαιτείται" }, "search": { - "message": "Search" + "message": "Αναζήτηση" }, "inputMinLength": { - "message": "Input must be at least $COUNT$ characters long.", + "message": "Η καταχώρηση πρέπει να είναι τουλάχιστον $COUNT$ χαρακτήρες.", "placeholders": { "count": { "content": "$1", @@ -2322,7 +2322,7 @@ } }, "inputMaxLength": { - "message": "Input must not exceed $COUNT$ characters in length.", + "message": "Η καταχώρηση δεν πρέπει να υπερβαίνει τους $COUNT$ χαρακτήρες σε μήκος.", "placeholders": { "count": { "content": "$1", @@ -2331,7 +2331,7 @@ } }, "inputForbiddenCharacters": { - "message": "The following characters are not allowed: $CHARACTERS$", + "message": "Οι ακόλουθοι χαρακτήρες δεν επιτρέπονται: $CHARACTERS$", "placeholders": { "characters": { "content": "$1", @@ -2340,7 +2340,7 @@ } }, "inputMinValue": { - "message": "Input value must be at least $MIN$.", + "message": "Η τιμή καταχώρησης πρέπει να είναι τουλάχιστον $MIN$", "placeholders": { "min": { "content": "$1", @@ -2349,7 +2349,7 @@ } }, "inputMaxValue": { - "message": "Input value must not exceed $MAX$.", + "message": "Η τιμή καταχώρησης δεν πρέπει να υπερβαίνει το $MAX$.", "placeholders": { "max": { "content": "$1", @@ -2358,17 +2358,17 @@ } }, "multipleInputEmails": { - "message": "1 or more emails are invalid" + "message": "1 ή περισσότερα email δεν είναι έγκυρα" }, "inputTrimValidator": { - "message": "Input must not contain only whitespace.", + "message": "Η καταχώρηση δεν πρέπει να περιέχει μόνο κενά.", "description": "Notification to inform the user that a form's input can't contain only whitespace." }, "inputEmail": { - "message": "Input is not an email address." + "message": "Η καταχώρηση δεν είναι διεύθυνση email." }, "fieldsNeedAttention": { - "message": "$COUNT$ field(s) above need your attention.", + "message": "$COUNT$ Το/α παραπάνω πεδίo/α χρειάζονται την προσοχή σας.", "placeholders": { "count": { "content": "$1", @@ -2377,22 +2377,22 @@ } }, "selectPlaceholder": { - "message": "-- Select --" + "message": "-- Επιλογή --" }, "multiSelectPlaceholder": { - "message": "-- Type to filter --" + "message": "-- Πληκτρολογήστε για φιλτράρισμα --" }, "multiSelectLoading": { - "message": "Retrieving options..." + "message": "Ανάκτηση επιλογών..." }, "multiSelectNotFound": { - "message": "No items found" + "message": "Δεν βρέθηκαν αντικείμενα" }, "multiSelectClearAll": { - "message": "Clear all" + "message": "Εκκαθάριση όλων" }, "plusNMore": { - "message": "+ $QUANTITY$ more", + "message": "+ $QUANTITY$ περισσότερα", "placeholders": { "quantity": { "content": "$1", @@ -2401,10 +2401,10 @@ } }, "submenu": { - "message": "Submenu" + "message": "Υπομενού" }, "toggleCollapse": { - "message": "Toggle collapse", + "message": "Εναλλαγή σύμπτυξης", "description": "Toggling an expand/collapse state." } } diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index b068befd0d0..c71a2af99c5 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2095,8 +2095,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index b10b53657a3..84fee7b77c3 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 8d46a4cbe26..4bf280ce707 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server Version" }, - "selfHosted": { - "message": "Self-Hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-Party" diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 71764078cf4..50b721348c8 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB de espacio cifrado en disco para adjuntos." }, - "ppremiumSignUpTwoStep": { - "message": "Métodos de autenticación en dos pasos adicionales como YubiKey, FIDO U2F y Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Higiene de contraseña, salud de la cuenta e informes de violaciones de datos para mantener su caja fuerte segura." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Versión del servidor" }, - "selfHosted": { - "message": "Autoalojado" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Aplicaciones de terceros" diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 24c15541572..f9a11297795 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB ulatuses krüpteeritud salvestusruum." }, - "ppremiumSignUpTwoStep": { - "message": "Lisavõimalused kaheastmeliseks kinnitamiseks, näiteks YubiKey, FIDO U2F ja Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Parooli hügieen, konto seisukord ja andmelekete raportid aitavad hoidlat turvalisena hoida." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Serveri versioon" }, - "selfHosted": { - "message": "Enda majutatud" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Kolmanda osapoole" diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index 4e5a6857c4c..987565d609d 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "Eranskinentzako 1GB-eko zifratutako biltegia." }, - "ppremiumSignUpTwoStep": { - "message": "YubiKey, FIDO U2F eta Duo bezalako bi urratseko saio hasierarako aukera gehigarriak." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Pasahitzaren higienea, kontuaren egoera eta datu-bortxaketen txostenak, kutxa gotorra seguru mantentzeko." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Zerbitzariaren bertsioa" }, - "selfHosted": { - "message": "Ostatatze propioduna" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Hirugarrenen aplikazioak" diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index b7aadc46e30..ebb75c9f893 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "۱ گیگابایت فضای ذخیره سازی رمزگذاری شده برای پیوست های پرونده." }, - "ppremiumSignUpTwoStep": { - "message": "گزینه‌های ورود دو مرحله‌ای اضافی مانند YubiKey, FIDO U2F و Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "گزارش‌های بهداشت رمز عبور، سلامت حساب و نقض داده‌ها برای ایمن نگهداشتن گاوصندوق شما." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "نسخه سرور" }, - "selfHosted": { - "message": "خود میزبان" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "شخص ثالث" diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 4ab409bfcb2..2abd01eefa6 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 Gt salattua tallennustilaa tiedostoliitteille." }, - "ppremiumSignUpTwoStep": { - "message": "Muita kaksivaiheisen kirjautumisen todennusmenetelmiä kuten YubiKey, FIDO U2F ja Duo Security." + "premiumSignUpTwoStepOptions": { + "message": "Omisteiset kaksivaiheisen kirjautumisen vaihtoehdot, kuten YubiKey ja Duo." }, "ppremiumSignUpReports": { "message": "Salasanahygienian, tilin terveyden ja tietovuotojen raportointitoiminnot pitävät holvisi turvassa." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Palvelimen versio" }, - "selfHosted": { - "message": "Itse ylläpidetty" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Ulkopuolinen taho" @@ -2246,7 +2246,7 @@ "message": "Muista tämä laite" }, "uncheckIfPublicDevice": { - "message": "Poista käytöstä julkisilla laitteilla" + "message": "Poista valinta julkisilla laitteilla" }, "approveFromYourOtherDevice": { "message": "Hyväksy muilta laitteiltasi" @@ -2286,7 +2286,7 @@ "message": "Hyväksyntää pyydetty ylläpidolta" }, "adminApprovalRequestSentToAdmins": { - "message": "Pyyntösi on välitetty ylläpidolle." + "message": "Pyyntösi on välitetty ylläpidollesi." }, "youWillBeNotifiedOnceApproved": { "message": "Saat ilmoituksen kun se on hyväksytty." @@ -2310,7 +2310,7 @@ "message": "pakollinen" }, "search": { - "message": "Etsi" + "message": "Hae" }, "inputMinLength": { "message": "Syötteen tulee sisältää ainakin $COUNT$ merkkiä.", diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 802d686c5af..3f046898751 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage para sa mga file attachment." }, - "ppremiumSignUpTwoStep": { - "message": "Dagdag na dalawang hakbang na login option gaya ng YubiKey, FIDO U2F, at Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Pasahod higiyena, kalusugan ng account, at mga ulat sa data breach upang panatilihing ligtas ang iyong vault." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Bersyon ng server" }, - "selfHosted": { - "message": "Auto-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Ika-tatlong-partido" diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 67c126dd9fc..d9dcd906c10 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 Go de stockage chiffré pour les fichiers joints." }, - "ppremiumSignUpTwoStep": { - "message": "Options additionnelles d'identification à deux étapes telles que YubiKey, FIDO U2F et Duo." + "premiumSignUpTwoStepOptions": { + "message": "Options de connexion propriétaires à deux facteurs telles que YubiKey et Duo." }, "ppremiumSignUpReports": { "message": "Hygiène du mot de passe, santé du compte et rapports sur les brèches de données pour assurer la sécurité de votre coffre." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Version du serveur" }, - "selfHosted": { - "message": "Auto-hébergé" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Tierce partie" diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 56640a8af8e..43c3cc0b68e 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index ebdc30e269d..d199a2e8db9 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 ג'יגה של מקום אחסון עבור קבצים מצורפים." }, - "ppremiumSignUpTwoStep": { - "message": "אפשרויות כניסה דו שלבית מתקדמות כמו YubiKey, FIDO U2F, וגם Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "היגיינת סיסמאות, מצב בריאות החשבון, ודיווחים מעודכנים על פרצות חדשות בכדי לשמור על הכספת שלך בטוחה." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 7d08de998db..e1d89271f21 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB of encrypted file storage." }, - "ppremiumSignUpTwoStep": { - "message": "अतिरिक्त दो-चरण लॉगिन विकल्प जैसे YubiKey, FIDO U2F, और डुओ।" + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "अपनी वॉल्ट को सुरक्षित रखने के लिए पासवर्ड स्वच्छता, खाता स्वास्थ्य और डेटा उल्लंघन रिपोर्ट।" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 18f0d151312..ee296293572 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB šifriranog prostora za pohranu podataka." }, - "ppremiumSignUpTwoStep": { - "message": "Dodatne mogućnosti za prijavu dvostrukom autentifikacijom kao što su YubiKey, FIDO U2F i Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Higijenu lozinki, zdravlje računa i izvještaje o krađi podatak radi zaštite svojeg trezora." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Verzija poslužitelja" }, - "selfHosted": { - "message": "Vlastiti poslužitelj" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index e32509dbbc3..62a14307f6e 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB titkosított tárhely a fájlmellékleteknek." }, - "ppremiumSignUpTwoStep": { - "message": "További két lépcsős bejelentkezés lehetőségek, mint például YubiKey, FIDO U2F és Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Jelszó higiénia, fiók biztonság és adatszivárgási jelentések a széf biztonsága érdekében." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Szerver verzió" }, - "selfHosted": { - "message": "Saját kiszolgáló" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Harmadik fél" diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index b53f92ba44c..5b1d144591d 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB penyimpanan berkas yang dienkripsi." }, - "ppremiumSignUpTwoStep": { - "message": "Pilihan info masuk dua langkah tambahan seperti YubiKey, FIDO U2F, dan Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Kebersihan kata sandi, kesehatan akun, dan laporan kebocoran data untuk tetap menjaga keamanan brankas Anda." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 75154c2453f..ad335ccd56c 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB di spazio di archiviazione criptato per gli allegati." }, - "ppremiumSignUpTwoStep": { - "message": "Più opzioni di verifica in due passaggi come YubiKey, FIDO U2F, e Duo." + "premiumSignUpTwoStepOptions": { + "message": "Opzioni di verifica in due passaggi proprietarie come YubiKey e Duo." }, "ppremiumSignUpReports": { "message": "Sicurezza delle password, integrità dell'account, e rapporti su violazioni di dati per mantenere sicura la tua cassaforte." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Versione Server" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Terze parti" diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 93af04dd99f..629a1644510 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1GB の暗号化されたファイルストレージ" }, - "ppremiumSignUpTwoStep": { - "message": "YubiKey、FIDO U2F、Duoなどの追加の2段階認証ログインオプション" + "premiumSignUpTwoStepOptions": { + "message": "YubiKey、Duo などのプロプライエタリな2段階認証オプション。" }, "ppremiumSignUpReports": { "message": "保管庫を安全に保つための、パスワードやアカウントの健全性、データ侵害に関するレポート" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "サーバーのバージョン" }, - "selfHosted": { - "message": "セルフホスト" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "サードパーティー" diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index c8c379ec377..f950febf1f3 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 56640a8af8e..43c3cc0b68e 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index daaf3011f6f..3635ce719ba 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "ಫೈಲ್ ಲಗತ್ತುಗಳಿಗಾಗಿ 1 ಜಿಬಿ ಎನ್‌ಕ್ರಿಪ್ಟ್ ಮಾಡಿದ ಸಂಗ್ರಹ." }, - "ppremiumSignUpTwoStep": { - "message": "ಹೆಚ್ಚುವರಿ ಎರಡು-ಹಂತದ ಲಾಗಿನ್ ಆಯ್ಕೆಗಳಾದ ಯೂಬಿಕೆ, ಎಫ್‌ಐಡಿಒ ಯು 2 ಎಫ್, ಮತ್ತು ಡ್ಯುವೋ." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "ನಿಮ್ಮ ವಾಲ್ಟ್ ಅನ್ನು ಸುರಕ್ಷಿತವಾಗಿರಿಸಲು ಪಾಸ್ವರ್ಡ್ ನೈರ್ಮಲ್ಯ, ಖಾತೆ ಆರೋಗ್ಯ ಮತ್ತು ಡೇಟಾ ಉಲ್ಲಂಘನೆ ವರದಿಗಳು." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 4d302bc5834..c8ad9cff366 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1GB의 암호화된 파일 저장소." }, - "ppremiumSignUpTwoStep": { - "message": "YubiKey나 FIDO U2F, Duo 등의 추가적인 2단계 인증 옵션." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "보관함을 안전하게 유지하기 위한 암호 위생, 계정 상태, 데이터 유출 보고서" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 72748ba4a37..b8f9c6cf12a 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB užšifruotos vietos diske bylų prisegimams." }, - "ppremiumSignUpTwoStep": { - "message": "Papildomos dviejų žingsių prisijungimo opcijos, tokios kaip YubiKey, FIDO U2F ir Duo." + "premiumSignUpTwoStepOptions": { + "message": "Patentuotos dviejų žingsnių prisijungimo parinktys, tokios kaip YubiKey ir Duo." }, "ppremiumSignUpReports": { "message": "Slaptažodžio higiena, prieigos sveikata ir duomenų nutekinimo ataskaitos, kad tavo saugyklas būtų saugus." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Trečioji šalis" diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index ee46e51a6b2..dfe5405e037 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB šifrētas krātuves datņu pielikumiem." }, - "ppremiumSignUpTwoStep": { - "message": "Tādas papildu divpakāpju pieteikšanās iespējas kā YubiKey, FIDO U2F un Duo." + "premiumSignUpTwoStepOptions": { + "message": "Tādas slēgtā pirmavota divpakāpju pieteikšanās iespējas kā YubiKey un Duo." }, "ppremiumSignUpReports": { "message": "Paroļu higiēnas, konta veselības un datu noplūžu pārskati, lai uzturētu glabātavu drošu." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Servera versija" }, - "selfHosted": { - "message": "Pašizvietots" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Trešās puses" diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index c7b3bd91b0d..dab1da00605 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "ഫയൽ അറ്റാച്ചുമെന്റുകൾക്കായി 1 ജിബി എൻക്രിപ്റ്റുചെയ്‌ത സംഭരണം." }, - "ppremiumSignUpTwoStep": { - "message": "രണ്ട്-ഘട്ട പ്രവേശന ഓപ്ഷനുകളായ Yubikey, FIDO U2F, Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "നിങ്ങളുടെ വാൾട് സൂക്ഷിക്കുന്നതിന്. പാസ്‌വേഡ് ശുചിത്വം, അക്കൗണ്ട് ആരോഗ്യം, ഡാറ്റ ലംഘന റിപ്പോർട്ടുകൾ." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 5943fb9724e..4401946ca5d 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -208,10 +208,10 @@ "message": "संकालन" }, "syncVaultNow": { - "message": "Sync vault now" + "message": "तिजोरी संकालन आता करा" }, "lastSync": { - "message": "Last sync:" + "message": "शेवटचे संकालन:" }, "passGen": { "message": "पासवर्ड जनित्र" @@ -279,7 +279,7 @@ "message": "Avoid ambiguous characters" }, "searchVault": { - "message": "Search vault" + "message": "तिजोरीत शोधा" }, "edit": { "message": "Edit" @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 56640a8af8e..43c3cc0b68e 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 8b3889b799d..5a3cb1a6675 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB med kryptert fillagring for filvedlegg." }, - "ppremiumSignUpTwoStep": { - "message": "Ytterligere 2-trinnsinnloggingsmuligheter, slik som YubiKey, FIDO U2F, og Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Passordhygiene, kontohelse, og databruddsrapporter som holder hvelvet ditt trygt." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server Versjon" }, - "selfHosted": { - "message": "Selvbetjent" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Tredjepart" diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 56640a8af8e..43c3cc0b68e 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index ed6f5394af3..7149b18ff68 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB versleutelde opslag voor bijlagen." }, - "ppremiumSignUpTwoStep": { - "message": "Extra opties voor tweestapsaanmelding zoals YubiKey, FIDO U2F en Duo." + "premiumSignUpTwoStepOptions": { + "message": "Eigen opties voor tweestapsaanmelding zoals YubiKey en Duo." }, "ppremiumSignUpReports": { "message": "Wachtwoordhygiëne, gezondheid van je account en datalekken om je kluis veilig te houden." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Serverversie" }, - "selfHosted": { - "message": "Zelfgehost" + "selfHostedServer": { + "message": "zelfgehost" }, "thirdParty": { "message": "van derden" diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 56640a8af8e..43c3cc0b68e 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 56640a8af8e..43c3cc0b68e 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 37e5b701472..c2ec8abe5d0 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB miejsca na zaszyfrowane załączniki." }, - "ppremiumSignUpTwoStep": { - "message": "Dodatkowe opcje logowania dwustopniowego, takie jak klucze YubiKey, FIDO U2F oraz Duo." + "premiumSignUpTwoStepOptions": { + "message": "Własnościowe opcje logowania dwuetapowego, takie jak YubiKey i Duo." }, "ppremiumSignUpReports": { "message": "Raporty bezpieczeństwa haseł, stanu konta i raporty wycieków danych, aby Twoje dane były bezpieczne." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Wersja serwera" }, - "selfHosted": { - "message": "Samodzielnie hostowany" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Inny dostawca" diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 0dd3ed1eee6..474174a36e9 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB de armazenamento de arquivos encriptados." }, - "ppremiumSignUpTwoStep": { - "message": "Opções de autenticação de duas etapas adicionais como YubiKey, FIDO U2F, e Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Higiene de senha, saúde da conta, e relatórios sobre violação de dados para manter o seu cofre seguro." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Versão do servidor" }, - "selfHosted": { - "message": "Auto-hospedado" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Terceiros" diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 68a69fcf9c5..28362dc31ad 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB de armazenamento encriptado para anexos de ficheiros." }, - "ppremiumSignUpTwoStep": { - "message": "Opções adicionais de verificação de dois passos, como YubiKey, FIDO U2F e Duo." + "premiumSignUpTwoStepOptions": { + "message": "Opções proprietárias de verificação de dois passos, como YubiKey e Duo." }, "ppremiumSignUpReports": { "message": "Higiene de palavras-passe, saúde da conta e relatórios de violação de dados para manter o seu cofre seguro." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Versão do servidor" }, - "selfHosted": { - "message": "Auto-hospedado" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "De terceiros" diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 6f577a8da57..40c86ecf37d 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -196,13 +196,13 @@ "message": "Ajutor și feedback" }, "helpCenter": { - "message": "Bitwarden Help center" + "message": "Centrul de Ajutor Bitwarden" }, "communityForums": { - "message": "Explore Bitwarden community forums" + "message": "Explorează forumurile comunității Bitwarden" }, "contactSupport": { - "message": "Contact Bitwarden support" + "message": "Contactați asistența Bitwarden" }, "sync": { "message": "Sincronizare" @@ -442,7 +442,7 @@ "message": "Este necesară rescrierea parolei principale." }, "masterPasswordMinlength": { - "message": "Master password must be at least $VALUE$ characters long.", + "message": "Parola principală trebuie să aibă cel puțin $VALUE$ caractere.", "description": "The Master Password must be at least a specific number of characters long.", "placeholders": { "value": { @@ -634,10 +634,10 @@ "message": "Actualizare" }, "notificationUnlockDesc": { - "message": "Unlock your Bitwarden vault to complete the auto-fill request." + "message": "Deblochează seiful Bitwarden pentru a finaliza solicitarea de completare automată." }, "notificationUnlock": { - "message": "Unlock" + "message": "Deblocare" }, "enableContextMenuItem": { "message": "Afișați opțiunile meniului contextual" @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB spațiu de stocare criptat pentru atașamente de fișiere." }, - "ppremiumSignUpTwoStep": { - "message": "Opțiuni adiționale de conectare în două etape, cum ar fi YubiKey, FIDO U2F și Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Rapoarte privind igiena parolelor, sănătatea contului și breșele de date pentru a vă păstra seiful în siguranță." @@ -985,7 +985,7 @@ "message": "Dacă se detectează un formular de autentificare, completați-l automat la încărcarea paginii web." }, "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "message": "Site-urile web compromise sau nesigure pot exploata funcția de autocompletare la încărcarea paginii." }, "learnMoreAboutAutofill": { "message": "Learn more about auto-fill" @@ -1468,7 +1468,7 @@ "message": "Articolul s-a completat automat " }, "insecurePageWarning": { - "message": "Warning: This is an unsecured HTTP page, and any information you submit can potentially be seen and changed by others. This Login was originally saved on a secure (HTTPS) page." + "message": "Avertisment: Aceasta este o pagină HTTP nesecurizată și orice informație pe care o trimiteți poate fi văzută și modificată de alte persoane. Această Parolă a fost salvată inițial pe o pagină securizată (HTTPS)." }, "insecurePageWarningFillPrompt": { "message": "Do you still wish to fill this login?" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Versiune server" }, - "selfHosted": { - "message": "Autogăzduit" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Parte terță" diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index d9614a63c2c..df0906a0ec1 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -671,7 +671,7 @@ "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, "exportVault": { - "message": "Экспортировать хранилище" + "message": "Экспорт хранилища" }, "fileFormat": { "message": "Формат файла" @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 ГБ зашифрованного хранилища для вложенных файлов." }, - "ppremiumSignUpTwoStep": { - "message": "Дополнительные варианты двухэтапной аутентификации, такие как YubiKey, FIDO U2F и Duo." + "premiumSignUpTwoStepOptions": { + "message": "Проприетарные варианты двухэтапной аутентификации, такие как YubiKey или Duo." }, "ppremiumSignUpReports": { "message": "Гигиена паролей, здоровье аккаунта и отчеты об утечках данных для обеспечения безопасности вашего хранилища." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Версия сервера" }, - "selfHosted": { - "message": "Собственный хостинг" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Сторонний" @@ -2141,7 +2141,7 @@ "message": "Фраза отпечатка" }, "fingerprintMatchInfo": { - "message": "Убедитесь, что ваше хранилище разблокировано и фраза отпечатка пальца совпадает на другом устройстве." + "message": "Убедитесь, что ваше хранилище разблокировано и фраза отпечатка совпадает на другом устройстве." }, "resendNotification": { "message": "Отправить уведомление повторно" @@ -2168,7 +2168,7 @@ "message": "Обнаружен слабый пароль, найденный в утечке данных. Используйте надежный и уникальный пароль для защиты вашего аккаунта. Вы уверены, что хотите использовать этот пароль?" }, "checkForBreaches": { - "message": "Проверьте известные случаи утечки данных для этого пароля" + "message": "Проверять известные случаи утечки данных для этого пароля" }, "important": { "message": "Важно:" diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 0d9a9648b7e..1236f44e6eb 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "ගොනු ඇමුණුම් සඳහා 1 GB සංකේතාත්මක ගබඩා." }, - "ppremiumSignUpTwoStep": { - "message": "එවැනි YuBiKey, FIDO U2F, සහ Duo ලෙස අතිරේක පියවර දෙකක් පිවිසුම් විකල්ප." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "ඔබගේ සුරක්ෂිතාගාරය ආරක්ෂිතව තබා ගැනීම සඳහා මුරපදය සනීපාරක්ෂාව, ගිණුම් සෞඛ්යය සහ දත්ත උල්ලං ach නය වාර්තා කරයි." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index c06ad279ec6..5778db28f79 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB šifrovaného úložiska." }, - "ppremiumSignUpTwoStep": { - "message": "Ďalšie možnosti dvojstupňového prihlásenia ako YubiKey, FIDO U2F a Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietárne možnosti dvojstupňového prihlásenia ako napríklad YubiKey a Duo." }, "ppremiumSignUpReports": { "message": "Správy o sile hesla, zabezpečení účtov a únikoch dát ktoré vám pomôžu udržať vaše kontá v bezpečí." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Verzia servera" }, - "selfHosted": { - "message": "Vlastný hosting" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Tretia strana" diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index dba7c971bd7..06967ee2166 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB šifriranega prostora za shrambo podatkov." }, - "ppremiumSignUpTwoStep": { - "message": "Dodatne možnosti za prijavo v dveh korakih, n.pr. YubiKey, FIDO U2F in Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Higiena gesel, zdravje računa in poročila o kraji podatkov, ki vam pomagajo ohraniti varnost vašega trezorja." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Verzija strežnika" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 155c5f6acc5..f823208d1dd 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -339,7 +339,7 @@ "message": "Остало" }, "unlockMethodNeededToChangeTimeoutActionDesc": { - "message": "Set up an unlock method to change your vault timeout action." + "message": "Подесите метод откључавања да бисте променили радњу временског ограничења сефа." }, "rateExtension": { "message": "Оцени овај додатак" @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1ГБ шифровано складиште за прилоге." }, - "ppremiumSignUpTwoStep": { - "message": "Додатне опције пријаве у два корака као што су YubiKey, FIDO U2F, и Duo." + "premiumSignUpTwoStepOptions": { + "message": "Приоритарне опције пријаве у два корака као што су YubiKey и Duo." }, "ppremiumSignUpReports": { "message": "Извештаји о хигијени лозинки, здравственом стању налога и кршењу података да бисте заштитили сеф." @@ -1606,10 +1606,10 @@ "message": "Биометрија прегледача није подржана на овом уређају." }, "biometricsFailedTitle": { - "message": "Biometrics failed" + "message": "Биометрија није успела" }, "biometricsFailedDesc": { - "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + "message": "Биометрија се не може завршити, размислите о коришћењу главне лозинке или одјавите се. Ако се ово настави, контактирајте подршку Bitwarden-а." }, "nativeMessaginPermissionErrorTitle": { "message": "Дозвола није дата" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Верзија сервера" }, - "selfHosted": { - "message": "Личан хостинг" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Трећа страна" @@ -2153,7 +2153,7 @@ "message": "Обавештење је послато на ваш уређај." }, "loginInitiated": { - "message": "Login initiated" + "message": "Пријава је покренута" }, "exposedMasterPassword": { "message": "Изложена главна лозинка" @@ -2240,25 +2240,25 @@ "message": "Отвара се у новом прозору" }, "deviceApprovalRequired": { - "message": "Device approval required. Select an approval option below:" + "message": "Потребно је одобрење уређаја. Изаберите опцију одобрења испод:" }, "rememberThisDevice": { - "message": "Remember this device" + "message": "Запамти овај уређај" }, "uncheckIfPublicDevice": { - "message": "Uncheck if using a public device" + "message": "Искључите ако се користи јавни уређај" }, "approveFromYourOtherDevice": { - "message": "Approve from your other device" + "message": "Одобри са мојим другим уређајем" }, "requestAdminApproval": { - "message": "Request admin approval" + "message": "Затражити одобрење администратора" }, "approveWithMasterPassword": { - "message": "Approve with master password" + "message": "Одобрити са главном лозинком" }, "ssoIdentifierRequired": { - "message": "Organization SSO identifier is required." + "message": "Потребан је SSO идентификатор организације." }, "eu": { "message": "EU", @@ -2280,40 +2280,40 @@ "message": "Приказ" }, "accountSuccessfullyCreated": { - "message": "Account successfully created!" + "message": "Налог је успешно креиран!" }, "adminApprovalRequested": { - "message": "Admin approval requested" + "message": "Захтевано је одобрење администратора" }, "adminApprovalRequestSentToAdmins": { - "message": "Your request has been sent to your admin." + "message": "Ваш захтев је послат вашем администратору." }, "youWillBeNotifiedOnceApproved": { - "message": "You will be notified once approved." + "message": "Бићете обавештени када буде одобрено." }, "troubleLoggingIn": { - "message": "Trouble logging in?" + "message": "Имате проблема са пријављивањем?" }, "loginApproved": { - "message": "Login approved" + "message": "Пријава је одобрена" }, "userEmailMissing": { - "message": "User email missing" + "message": "Недостаје имејл корисника" }, "deviceTrusted": { - "message": "Device trusted" + "message": "Уређај поуздан" }, "inputRequired": { - "message": "Input is required." + "message": "Унос је потребан." }, "required": { - "message": "required" + "message": "обавезно" }, "search": { - "message": "Search" + "message": "Тражи" }, "inputMinLength": { - "message": "Input must be at least $COUNT$ characters long.", + "message": "Унос трба имати најмање $COUNT$ слова.", "placeholders": { "count": { "content": "$1", @@ -2322,7 +2322,7 @@ } }, "inputMaxLength": { - "message": "Input must not exceed $COUNT$ characters in length.", + "message": "Унос не сме бити већи од $COUNT$ карактера.", "placeholders": { "count": { "content": "$1", @@ -2331,7 +2331,7 @@ } }, "inputForbiddenCharacters": { - "message": "The following characters are not allowed: $CHARACTERS$", + "message": "Следећи знакови нису дозвољени: $CHARACTERS$", "placeholders": { "characters": { "content": "$1", @@ -2340,7 +2340,7 @@ } }, "inputMinValue": { - "message": "Input value must be at least $MIN$.", + "message": "Вредност мора бити најмање $MIN$.", "placeholders": { "min": { "content": "$1", @@ -2349,7 +2349,7 @@ } }, "inputMaxValue": { - "message": "Input value must not exceed $MAX$.", + "message": "Вредност не сме бити већа од $MAX$.", "placeholders": { "max": { "content": "$1", @@ -2358,17 +2358,17 @@ } }, "multipleInputEmails": { - "message": "1 or more emails are invalid" + "message": "1 или више имејлова су неважећи" }, "inputTrimValidator": { - "message": "Input must not contain only whitespace.", + "message": "Унос не сме да садржи само размак.", "description": "Notification to inform the user that a form's input can't contain only whitespace." }, "inputEmail": { - "message": "Input is not an email address." + "message": "Унос није имејл." }, "fieldsNeedAttention": { - "message": "$COUNT$ field(s) above need your attention.", + "message": "$COUNT$ поље(а) изнад захтевај(у) вашу пажњу.", "placeholders": { "count": { "content": "$1", @@ -2377,22 +2377,22 @@ } }, "selectPlaceholder": { - "message": "-- Select --" + "message": "-- Одабрати --" }, "multiSelectPlaceholder": { - "message": "-- Type to filter --" + "message": "-- Тип за филтрирање --" }, "multiSelectLoading": { - "message": "Retrieving options..." + "message": "Преузимање опција..." }, "multiSelectNotFound": { - "message": "No items found" + "message": "Нема предмета" }, "multiSelectClearAll": { - "message": "Clear all" + "message": "Обриши све" }, "plusNMore": { - "message": "+ $QUANTITY$ more", + "message": "+ још $QUANTITY$", "placeholders": { "quantity": { "content": "$1", @@ -2401,10 +2401,10 @@ } }, "submenu": { - "message": "Submenu" + "message": "Под-мени" }, "toggleCollapse": { - "message": "Toggle collapse", + "message": "Промени проширење", "description": "Toggling an expand/collapse state." } } diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index f3275fce001..2c90c1c8f86 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB lagring av krypterade filer." }, - "ppremiumSignUpTwoStep": { - "message": "Ytterligare alternativ för tvåstegsverifiering såsom YubiKey, FIDO U2F och Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Lösenordshygien, kontohälsa och dataintrångsrapporter för att hålla ditt valv säkert." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Serverversion" }, - "selfHosted": { - "message": "Lokalt installerad" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Tredje part" diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 56640a8af8e..43c3cc0b68e 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB encrypted storage for file attachments." }, - "ppremiumSignUpTwoStep": { - "message": "Additional two-step login options such as YubiKey, FIDO U2F, and Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Password hygiene, account health, and data breach reports to keep your vault safe." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 1cc5e9bc50c..97ce8ca58cc 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB of encrypted file storage." }, - "ppremiumSignUpTwoStep": { - "message": "ตัวเลือกการเข้าสู่ระบบแบบสองขั้นตอนเพิ่มเติม เช่น YubiKey, FIDO U2F และ Duo" + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "สุขอนามัยของรหัสผ่าน ความสมบูรณ์ของบัญชี และรายงานการละเมิดข้อมูลเพื่อให้ตู้นิรภัยของคุณปลอดภัย" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 8bcdf1f6580..e32dcb4ac81 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "Dosya ekleri için 1 GB şifrelenmiş depolama." }, - "ppremiumSignUpTwoStep": { - "message": "YubiKey, FIDO U2F ve Duo gibi iki aşamalı giriş seçenekleri." + "premiumSignUpTwoStepOptions": { + "message": "YubiKey ve Duo gibi marka bazlı iki aşamalı giriş seçenekleri." }, "ppremiumSignUpReports": { "message": "Kasanızı güvende tutmak için parola hijyeni, hesap sağlığı ve veri ihlali raporları." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Sunucu sürümü" }, - "selfHosted": { - "message": "Barındırılan" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Üçüncü taraf" diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index dfa5ac4c7d3..c695aaadf2e 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -285,7 +285,7 @@ "message": "Змінити" }, "view": { - "message": "Перегляд" + "message": "Переглянути" }, "noItemsInList": { "message": "Немає записів." @@ -321,7 +321,7 @@ "message": "Видалити запис" }, "viewItem": { - "message": "Перегляд запису" + "message": "Переглянути запис" }, "launch": { "message": "Перейти" @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 ГБ зашифрованого сховища для файлів." }, - "ppremiumSignUpTwoStep": { - "message": "Додаткові можливості двоетапної перевірки, наприклад, YubiKey, FIDO U2F та Duo." + "premiumSignUpTwoStepOptions": { + "message": "Додаткові можливості двоетапної авторизації, як-от YubiKey та Duo." }, "ppremiumSignUpReports": { "message": "Гігієна паролів, здоров'я облікового запису, а також звіти про вразливості даних, щоб зберігати ваше сховище в безпеці." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Версія сервера" }, - "selfHosted": { - "message": "Власне розміщення" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Сторонній" @@ -2304,16 +2304,16 @@ "message": "Довірений пристрій" }, "inputRequired": { - "message": "Input is required." + "message": "Необхідно ввести дані." }, "required": { - "message": "required" + "message": "обов'язково" }, "search": { - "message": "Search" + "message": "Пошук" }, "inputMinLength": { - "message": "Input must be at least $COUNT$ characters long.", + "message": "Введені дані мають бути довжиною принаймні $COUNT$ символів.", "placeholders": { "count": { "content": "$1", @@ -2322,7 +2322,7 @@ } }, "inputMaxLength": { - "message": "Input must not exceed $COUNT$ characters in length.", + "message": "Вхідне значення не повинно перевищувати $COUNT$ символів.", "placeholders": { "count": { "content": "$1", @@ -2331,7 +2331,7 @@ } }, "inputForbiddenCharacters": { - "message": "The following characters are not allowed: $CHARACTERS$", + "message": "Вказані символи заборонені: $CHARACTERS$", "placeholders": { "characters": { "content": "$1", @@ -2340,7 +2340,7 @@ } }, "inputMinValue": { - "message": "Input value must be at least $MIN$.", + "message": "Значення має бути принаймні $MIN$.", "placeholders": { "min": { "content": "$1", @@ -2349,7 +2349,7 @@ } }, "inputMaxValue": { - "message": "Input value must not exceed $MAX$.", + "message": "Значення не може перевищувати $MAX$.", "placeholders": { "max": { "content": "$1", @@ -2358,17 +2358,17 @@ } }, "multipleInputEmails": { - "message": "1 or more emails are invalid" + "message": "1 або більше адрес е-пошти недійсні" }, "inputTrimValidator": { - "message": "Input must not contain only whitespace.", + "message": "Введене значення не повинно містити лише пробіл.", "description": "Notification to inform the user that a form's input can't contain only whitespace." }, "inputEmail": { - "message": "Input is not an email address." + "message": "Введені дані не є адресою е-пошти." }, "fieldsNeedAttention": { - "message": "$COUNT$ field(s) above need your attention.", + "message": "$COUNT$ поле (поля) вище потребують вашої уваги.", "placeholders": { "count": { "content": "$1", @@ -2377,22 +2377,22 @@ } }, "selectPlaceholder": { - "message": "-- Select --" + "message": "-- Оберіть--" }, "multiSelectPlaceholder": { - "message": "-- Type to filter --" + "message": "-- Введіть для фільтрування --" }, "multiSelectLoading": { - "message": "Retrieving options..." + "message": "Параметри отримання..." }, "multiSelectNotFound": { - "message": "No items found" + "message": "Нічого не знайдено" }, "multiSelectClearAll": { - "message": "Clear all" + "message": "Очистити все" }, "plusNMore": { - "message": "+ $QUANTITY$ more", + "message": "+ ще $QUANTITY$", "placeholders": { "quantity": { "content": "$1", @@ -2401,10 +2401,10 @@ } }, "submenu": { - "message": "Submenu" + "message": "Підменю" }, "toggleCollapse": { - "message": "Toggle collapse", + "message": "Згорнути/розгорнути", "description": "Toggling an expand/collapse state." } } diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 7faabed46e0..95716b3fc36 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1GB bộ nhớ lưu trữ tập tin được mã hóa." }, - "ppremiumSignUpTwoStep": { - "message": "Tuỳ chọn đăng nhập 2 bước bổ sung như YubiKey, FIDO U2F, và Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Thanh lọc mật khẩu, kiểm tra an toàn tài khoản và các báo cáo rò rĩ dữ liệu là để giữ cho kho của bạn an toàn." @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "Phiên bản máy chủ" }, - "selfHosted": { - "message": "Tự lưu trữ" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Bên thứ ba" diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index eb02a54f453..eded4b220c7 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "1 GB 文件附件加密存储。" }, - "ppremiumSignUpTwoStep": { - "message": "额外的两步登录选项,如 YubiKey、FIDO U2F 和 Duo。" + "premiumSignUpTwoStepOptions": { + "message": "专有的两步登录选项,如 YubiKey 和 Duo。" }, "ppremiumSignUpReports": { "message": "密码健康、账户体检以及数据泄露报告,保障您的密码库安全。" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "服务器版本" }, - "selfHosted": { - "message": "自托管" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "第三方" @@ -2135,7 +2135,7 @@ "message": "设备登录" }, "loginWithDeviceEnabledInfo": { - "message": "设备登录必须在 Bitwarden 应用程序的设置中设启用。需要其他选项吗?" + "message": "设备登录必须在 Bitwarden 应用程序的设置中启用。需要其他登录选项吗?" }, "fingerprintPhraseHeader": { "message": "指纹短语" diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index a0a2c9e3eb3..96bdfaaa4df 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -795,8 +795,8 @@ "ppremiumSignUpStorage": { "message": "用於檔案附件的 1 GB 加密儲存空間。" }, - "ppremiumSignUpTwoStep": { - "message": "YubiKey、FIDO U2F 和 Duo 等額外的兩步驟登入選項。" + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "密碼健康度檢查、提供帳戶體檢以及資料外洩報告,以保障您的密碼庫安全。" @@ -2092,8 +2092,8 @@ "serverVersion": { "message": "伺服器版本" }, - "selfHosted": { - "message": "自我裝載" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "第三方" diff --git a/apps/browser/src/auth/popup/login.component.html b/apps/browser/src/auth/popup/login.component.html index d3db7069644..64c3048507e 100644 --- a/apps/browser/src/auth/popup/login.component.html +++ b/apps/browser/src/auth/popup/login.component.html @@ -44,7 +44,11 @@
- +
-
+
+ +
- +
@@ -112,7 +112,9 @@ selectedProviderType === providerType.OrganizationDuo " > -
+
+ +
@@ -123,7 +125,7 @@
- +

{{ "noTwoStepProviders" | i18n }}

diff --git a/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts b/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts index dbe391ce4ab..d7cac8d44b2 100644 --- a/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts +++ b/apps/browser/src/autofill/browser/cipher-context-menu-handler.spec.ts @@ -1,7 +1,6 @@ import { mock, MockProxy } from "jest-mock-extended"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; @@ -14,7 +13,6 @@ describe("CipherContextMenuHandler", () => { let mainContextMenuHandler: MockProxy; let authService: MockProxy; let cipherService: MockProxy; - let userVerificationService: MockProxy; let sut: CipherContextMenuHandler; @@ -22,17 +20,10 @@ describe("CipherContextMenuHandler", () => { mainContextMenuHandler = mock(); authService = mock(); cipherService = mock(); - userVerificationService = mock(); - userVerificationService.hasMasterPassword.mockResolvedValue(true); jest.spyOn(MainContextMenuHandler, "removeAll").mockResolvedValue(); - sut = new CipherContextMenuHandler( - mainContextMenuHandler, - authService, - cipherService, - userVerificationService - ); + sut = new CipherContextMenuHandler(mainContextMenuHandler, authService, cipherService); }); afterEach(() => jest.resetAllMocks()); @@ -78,7 +69,7 @@ describe("CipherContextMenuHandler", () => { expect(mainContextMenuHandler.noLogins).toHaveBeenCalledTimes(1); }); - it("only adds valid ciphers", async () => { + it("only adds login ciphers including ciphers that require reprompt", async () => { authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked); mainContextMenuHandler.init.mockResolvedValue(true); @@ -90,47 +81,6 @@ describe("CipherContextMenuHandler", () => { name: "Test Cipher", login: { username: "Test Username" }, }; - - cipherService.getAllDecryptedForUrl.mockResolvedValue([ - null, // invalid cipher - undefined, // invalid cipher - { type: CipherType.Card }, // invalid cipher - { type: CipherType.Login, reprompt: CipherRepromptType.Password }, // invalid cipher - realCipher, // valid cipher - ] as any[]); - - await sut.update("https://test.com"); - - expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1); - - expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com"); - - expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledTimes(2); - - expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith( - "Test Cipher (Test Username)", - "5", - "https://test.com", - realCipher - ); - }); - - it("adds ciphers with master password reprompt if the user does not have a master password", async () => { - authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked); - - // User does not have a master password, or has one but hasn't logged in with it (key connector user or TDE user) - userVerificationService.hasMasterPasswordAndMasterKeyHash.mockResolvedValue(false); - - mainContextMenuHandler.init.mockResolvedValue(true); - - const realCipher = { - id: "5", - type: CipherType.Login, - reprompt: CipherRepromptType.None, - name: "Test Cipher", - login: { username: "Test Username" }, - }; - const repromptCipher = { id: "6", type: CipherType.Login, @@ -143,8 +93,8 @@ describe("CipherContextMenuHandler", () => { null, // invalid cipher undefined, // invalid cipher { type: CipherType.Card }, // invalid cipher - repromptCipher, // valid cipher realCipher, // valid cipher + repromptCipher, ] as any[]); await sut.update("https://test.com"); @@ -153,7 +103,6 @@ describe("CipherContextMenuHandler", () => { expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com"); - // Should call this twice, once for each valid cipher expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledTimes(2); expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith( diff --git a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts index 1d1be8f8386..6140db260f5 100644 --- a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts @@ -1,5 +1,4 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -12,7 +11,6 @@ import { authServiceFactory, AuthServiceInitOptions, } from "../../auth/background/service-factories/auth-service.factory"; -import { userVerificationServiceFactory } from "../../auth/background/service-factories/user-verification-service.factory"; import { Account } from "../../models/account"; import { CachedServices } from "../../platform/background/service-factories/factory-options"; import { BrowserApi } from "../../platform/browser/browser-api"; @@ -39,8 +37,7 @@ export class CipherContextMenuHandler { constructor( private mainContextMenuHandler: MainContextMenuHandler, private authService: AuthService, - private cipherService: CipherService, - private userVerificationService: UserVerificationService + private cipherService: CipherService ) {} static async create(cachedServices: CachedServices) { @@ -69,9 +66,6 @@ export class CipherContextMenuHandler { clipboardWriteCallback: NOT_IMPLEMENTED, win: self, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, @@ -79,8 +73,7 @@ export class CipherContextMenuHandler { return new CipherContextMenuHandler( await MainContextMenuHandler.mv3Create(cachedServices), await authServiceFactory(cachedServices, serviceOptions), - await cipherServiceFactory(cachedServices, serviceOptions), - await userVerificationServiceFactory(cachedServices, serviceOptions) + await cipherServiceFactory(cachedServices, serviceOptions) ); } @@ -180,11 +173,7 @@ export class CipherContextMenuHandler { } private async updateForCipher(url: string, cipher: CipherView) { - if ( - cipher == null || - cipher.type !== CipherType.Login || - (await this.userVerificationService.hasMasterPasswordAndMasterKeyHash()) - ) { + if (cipher == null || cipher.type !== CipherType.Login) { return; } diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts index a9dbcbaacc5..021d15df89e 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.spec.ts @@ -3,6 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { TotpService } from "@bitwarden/common/abstractions/totp.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; @@ -63,6 +64,7 @@ describe("ContextMenuClickedHandler", () => { let cipherService: MockProxy; let totpService: MockProxy; let eventCollectionService: MockProxy; + let userVerificationService: MockProxy; let sut: ContextMenuClickedHandler; @@ -82,7 +84,8 @@ describe("ContextMenuClickedHandler", () => { authService, cipherService, totpService, - eventCollectionService + eventCollectionService, + userVerificationService ); }); diff --git a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts index 38e605abe70..a6bff50a195 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -1,6 +1,7 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { TotpService } from "@bitwarden/common/abstractions/totp.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EventType } from "@bitwarden/common/enums"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; @@ -14,6 +15,7 @@ import { AuthServiceInitOptions, } from "../../auth/background/service-factories/auth-service.factory"; import { totpServiceFactory } from "../../auth/background/service-factories/totp-service.factory"; +import { userVerificationServiceFactory } from "../../auth/background/service-factories/user-verification-service.factory"; import LockedVaultPendingNotificationsItem from "../../background/models/lockedVaultPendingNotificationsItem"; import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory"; import { Account } from "../../models/account"; @@ -56,7 +58,8 @@ export class ContextMenuClickedHandler { private authService: AuthService, private cipherService: CipherService, private totpService: TotpService, - private eventCollectionService: EventCollectionService + private eventCollectionService: EventCollectionService, + private userVerificationService: UserVerificationService ) {} static async mv3Create(cachedServices: CachedServices) { @@ -85,9 +88,6 @@ export class ContextMenuClickedHandler { clipboardWriteCallback: NOT_IMPLEMENTED, win: self, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, @@ -109,7 +109,8 @@ export class ContextMenuClickedHandler { await authServiceFactory(cachedServices, serviceOptions), await cipherServiceFactory(cachedServices, serviceOptions), await totpServiceFactory(cachedServices, serviceOptions), - await eventCollectionServiceFactory(cachedServices, serviceOptions) + await eventCollectionServiceFactory(cachedServices, serviceOptions), + await userVerificationServiceFactory(cachedServices, serviceOptions) ); } @@ -204,7 +205,7 @@ export class ContextMenuClickedHandler { return; } - if (cipher.reprompt !== CipherRepromptType.None) { + if (await this.isPasswordRepromptRequired(cipher)) { await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { cipherId: cipher.id, action: AUTOFILL_ID, @@ -218,7 +219,7 @@ export class ContextMenuClickedHandler { this.copyToClipboard({ text: cipher.login.username, tab: tab }); break; case COPY_PASSWORD_ID: - if (cipher.reprompt !== CipherRepromptType.None) { + if (await this.isPasswordRepromptRequired(cipher)) { await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { cipherId: cipher.id, action: COPY_PASSWORD_ID, @@ -230,7 +231,7 @@ export class ContextMenuClickedHandler { break; case COPY_VERIFICATIONCODE_ID: - if (cipher.reprompt !== CipherRepromptType.None) { + if (await this.isPasswordRepromptRequired(cipher)) { await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { cipherId: cipher.id, action: COPY_VERIFICATIONCODE_ID, @@ -246,6 +247,13 @@ export class ContextMenuClickedHandler { } } + private async isPasswordRepromptRequired(cipher: CipherView): Promise { + return ( + cipher.reprompt === CipherRepromptType.Password && + (await this.userVerificationService.hasMasterPasswordAndMasterKeyHash()) + ); + } + private async getIdentifier(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) { return new Promise((resolve, reject) => { BrowserApi.sendTabsMessage( diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.ts index 9b16aa266db..b9af3dd191f 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -79,9 +79,6 @@ export class MainContextMenuHandler { logServiceOptions: { isDev: false, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, diff --git a/apps/browser/src/autofill/constants.ts b/apps/browser/src/autofill/constants.ts new file mode 100644 index 00000000000..7f3637180b0 --- /dev/null +++ b/apps/browser/src/autofill/constants.ts @@ -0,0 +1,13 @@ +export const TYPE_CHECK = { + FUNCTION: "function", + NUMBER: "number", + STRING: "string", +} as const; + +export const EVENTS = { + CHANGE: "change", + INPUT: "input", + KEYDOWN: "keydown", + KEYPRESS: "keypress", + KEYUP: "keyup", +} as const; diff --git a/apps/browser/src/autofill/content/abstractions/autofill-init.ts b/apps/browser/src/autofill/content/abstractions/autofill-init.ts new file mode 100644 index 00000000000..706c6da4ee1 --- /dev/null +++ b/apps/browser/src/autofill/content/abstractions/autofill-init.ts @@ -0,0 +1,21 @@ +import AutofillScript from "../../models/autofill-script"; + +type AutofillExtensionMessage = { + command: string; + tab?: chrome.tabs.Tab; + sender?: string; + fillScript?: AutofillScript; +}; + +type AutofillExtensionMessageHandlers = { + [key: string]: CallableFunction; + collectPageDetails: (message: { message: AutofillExtensionMessage }) => void; + collectPageDetailsImmediately: (message: { message: AutofillExtensionMessage }) => void; + fillForm: (message: { message: AutofillExtensionMessage }) => void; +}; + +interface AutofillInit { + init(): void; +} + +export { AutofillExtensionMessage, AutofillExtensionMessageHandlers, AutofillInit }; diff --git a/apps/browser/src/autofill/content/autofill-init.spec.ts b/apps/browser/src/autofill/content/autofill-init.spec.ts new file mode 100644 index 00000000000..447fe31a8a3 --- /dev/null +++ b/apps/browser/src/autofill/content/autofill-init.spec.ts @@ -0,0 +1,175 @@ +import { mock } from "jest-mock-extended"; + +import AutofillPageDetails from "../models/autofill-page-details"; +import AutofillScript from "../models/autofill-script"; + +import { AutofillExtensionMessage } from "./abstractions/autofill-init"; + +describe("AutofillInit", () => { + let bitwardenAutofillInit: any; + + beforeEach(() => { + require("../content/autofill-init"); + bitwardenAutofillInit = window.bitwardenAutofillInit; + }); + + afterEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + describe("init", () => { + it("sets up the extension message listeners", () => { + jest.spyOn(bitwardenAutofillInit, "setupExtensionMessageListeners"); + + bitwardenAutofillInit.init(); + + expect(bitwardenAutofillInit.setupExtensionMessageListeners).toHaveBeenCalled(); + }); + }); + + describe("collectPageDetails", () => { + let extensionMessage: AutofillExtensionMessage; + let pageDetails: AutofillPageDetails; + + beforeEach(() => { + extensionMessage = { + command: "collectPageDetails", + tab: mock(), + sender: "sender", + }; + pageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + jest + .spyOn(bitwardenAutofillInit.collectAutofillContentService, "getPageDetails") + .mockReturnValue(pageDetails); + }); + + it("returns collected page details for autofill if set to send the details in the response", async () => { + const response = await bitwardenAutofillInit["collectPageDetails"](extensionMessage, true); + + expect(bitwardenAutofillInit.collectAutofillContentService.getPageDetails).toHaveBeenCalled(); + expect(response).toEqual(pageDetails); + }); + + it("sends the collected page details for autofill using a background script message", async () => { + jest.spyOn(chrome.runtime, "sendMessage"); + + await bitwardenAutofillInit["collectPageDetails"](extensionMessage); + + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + command: "collectPageDetailsResponse", + tab: extensionMessage.tab, + details: pageDetails, + sender: extensionMessage.sender, + }); + }); + }); + + describe("fillForm", () => { + it("will call the InsertAutofillContentService to fill the form", () => { + const fillScript = mock(); + jest + .spyOn(bitwardenAutofillInit.insertAutofillContentService, "fillForm") + .mockImplementation(); + + bitwardenAutofillInit.fillForm(fillScript); + + expect(bitwardenAutofillInit.insertAutofillContentService.fillForm).toHaveBeenCalledWith( + fillScript + ); + }); + }); + + describe("setupExtensionMessageListeners", () => { + it("sets up a chrome runtime on message listener", () => { + jest.spyOn(chrome.runtime.onMessage, "addListener"); + + bitwardenAutofillInit["setupExtensionMessageListeners"](); + + expect(chrome.runtime.onMessage.addListener).toHaveBeenCalledWith( + bitwardenAutofillInit["handleExtensionMessage"] + ); + }); + }); + + describe("handleExtensionMessage", () => { + let message: AutofillExtensionMessage; + let sender: chrome.runtime.MessageSender; + const sendResponse = jest.fn(); + + beforeEach(() => { + message = { + command: "collectPageDetails", + tab: mock(), + sender: "sender", + }; + sender = mock(); + }); + + it("returns a false value if a extension message handler is not found with the given message command", () => { + message.command = "unknownCommand"; + + const response = bitwardenAutofillInit["handleExtensionMessage"]( + message, + sender, + sendResponse + ); + + expect(response).toBe(false); + }); + + it("returns a false value if the message handler does not return a response", async () => { + const response1 = await bitwardenAutofillInit["handleExtensionMessage"]( + message, + sender, + sendResponse + ); + await Promise.resolve(response1); + + expect(response1).not.toBe(false); + + message.command = "fillForm"; + message.fillScript = mock(); + + const response2 = await bitwardenAutofillInit["handleExtensionMessage"]( + message, + sender, + sendResponse + ); + + expect(response2).toBe(false); + }); + + it("returns a true value and calls sendResponse if the message handler returns a response", async () => { + message.command = "collectPageDetailsImmediately"; + const pageDetails: AutofillPageDetails = { + title: "title", + url: "http://example.com", + documentUrl: "documentUrl", + forms: {}, + fields: [], + collectedTimestamp: 0, + }; + jest + .spyOn(bitwardenAutofillInit.collectAutofillContentService, "getPageDetails") + .mockReturnValue(pageDetails); + + const response = await bitwardenAutofillInit["handleExtensionMessage"]( + message, + sender, + sendResponse + ); + await Promise.resolve(response); + + expect(response).toBe(true); + expect(sendResponse).toHaveBeenCalledWith(pageDetails); + }); + }); +}); diff --git a/apps/browser/src/autofill/content/autofill-init.ts b/apps/browser/src/autofill/content/autofill-init.ts new file mode 100644 index 00000000000..8b441ae0e20 --- /dev/null +++ b/apps/browser/src/autofill/content/autofill-init.ts @@ -0,0 +1,130 @@ +import AutofillPageDetails from "../models/autofill-page-details"; +import AutofillScript from "../models/autofill-script"; +import CollectAutofillContentService from "../services/collect-autofill-content.service"; +import DomElementVisibilityService from "../services/dom-element-visibility.service"; +import InsertAutofillContentService from "../services/insert-autofill-content.service"; + +import { + AutofillExtensionMessage, + AutofillExtensionMessageHandlers, + AutofillInit as AutofillInitInterface, +} from "./abstractions/autofill-init"; + +class AutofillInit implements AutofillInitInterface { + private readonly domElementVisibilityService: DomElementVisibilityService; + private readonly collectAutofillContentService: CollectAutofillContentService; + private readonly insertAutofillContentService: InsertAutofillContentService; + private readonly extensionMessageHandlers: AutofillExtensionMessageHandlers = { + collectPageDetails: ({ message }) => this.collectPageDetails(message), + collectPageDetailsImmediately: ({ message }) => this.collectPageDetails(message, true), + fillForm: ({ message }) => this.fillForm(message.fillScript), + }; + + /** + * AutofillInit constructor. Initializes the DomElementVisibilityService, + * CollectAutofillContentService and InsertAutofillContentService classes. + */ + constructor() { + this.domElementVisibilityService = new DomElementVisibilityService(); + this.collectAutofillContentService = new CollectAutofillContentService( + this.domElementVisibilityService + ); + this.insertAutofillContentService = new InsertAutofillContentService( + this.domElementVisibilityService, + this.collectAutofillContentService + ); + } + + /** + * Initializes the autofill content script, setting up + * the extension message listeners. This method should + * be called once when the content script is loaded. + * @public + */ + init() { + this.setupExtensionMessageListeners(); + } + + /** + * Collects the page details and sends them to the + * extension background script. If the `sendDetailsInResponse` + * parameter is set to true, the page details will be + * returned to facilitate sending the details in the + * response to the extension message. + * @param {AutofillExtensionMessage} message + * @param {boolean} sendDetailsInResponse + * @returns {AutofillPageDetails | void} + * @private + */ + private async collectPageDetails( + message: AutofillExtensionMessage, + sendDetailsInResponse = false + ): Promise { + const pageDetails: AutofillPageDetails = + await this.collectAutofillContentService.getPageDetails(); + if (sendDetailsInResponse) { + return pageDetails; + } + + chrome.runtime.sendMessage({ + command: "collectPageDetailsResponse", + tab: message.tab, + details: pageDetails, + sender: message.sender, + }); + } + + /** + * Fills the form with the given fill script. + * @param {AutofillScript} fillScript + * @private + */ + private fillForm(fillScript: AutofillScript) { + this.insertAutofillContentService.fillForm(fillScript); + } + + /** + * Sets up the extension message listeners + * for the content script. + * @private + */ + private setupExtensionMessageListeners() { + chrome.runtime.onMessage.addListener(this.handleExtensionMessage); + } + + /** + * Handles the extension messages + * sent to the content script. + * @param {AutofillExtensionMessage} message + * @param {chrome.runtime.MessageSender} sender + * @param {(response?: any) => void} sendResponse + * @returns {boolean} + * @private + */ + private handleExtensionMessage = ( + message: AutofillExtensionMessage, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: any) => void + ): boolean => { + const command: string = message.command; + const handler: CallableFunction | undefined = this.extensionMessageHandlers[command]; + if (!handler) { + return false; + } + + const messageResponse = handler({ message, sender }); + if (!messageResponse) { + return false; + } + + Promise.resolve(messageResponse).then((response) => sendResponse(response)); + return true; + }; +} + +(function () { + if (!window.bitwardenAutofillInit) { + window.bitwardenAutofillInit = new AutofillInit(); + window.bitwardenAutofillInit.init(); + } +})(); diff --git a/apps/browser/src/autofill/content/autofiller.ts b/apps/browser/src/autofill/content/autofiller.ts index 7fe9e5514a8..7f58e72c7d3 100644 --- a/apps/browser/src/autofill/content/autofiller.ts +++ b/apps/browser/src/autofill/content/autofiller.ts @@ -1,4 +1,10 @@ -document.addEventListener("DOMContentLoaded", (event) => { +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", loadAutofiller); +} else { + loadAutofiller(); +} + +function loadAutofiller() { let pageHref: string = null; let filledThisHref = false; let delayFillTimeout: number; @@ -49,4 +55,4 @@ document.addEventListener("DOMContentLoaded", (event) => { chrome.runtime.sendMessage(msg); } } -}); +} diff --git a/apps/browser/src/autofill/content/autofillv2.ts b/apps/browser/src/autofill/content/autofillv2.ts deleted file mode 100644 index 65813b3afe6..00000000000 --- a/apps/browser/src/autofill/content/autofillv2.ts +++ /dev/null @@ -1,1399 +0,0 @@ -/* eslint-disable no-var, no-console, no-prototype-builtins */ -// These eslint rules are disabled because the original JS was not written with them in mind and we don't want to fix -// them all now - -/* - 1Password Extension - - Lovingly handcrafted by Dave Teare, Michael Fey, Rad Azzouz, and Roustem Karimov. - Copyright (c) 2014 AgileBits. All rights reserved. - - ================================================================================ - - Copyright (c) 2014 AgileBits Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - */ - -/* - MODIFICATIONS FROM ORIGINAL - - 1. Populate isFirefox - 2. Remove isChrome and isSafari since they are not used. - 3. Unminify and format to meet Mozilla review requirements. - 4. Remove unnecessary input types from getFormElements query selector and limit number of elements returned. - 5. Remove fakeTested prop. - 6. Rename com.agilebits.* stuff to com.bitwarden.* - 7. Remove "some useful globals" on window - 8. Add ability to autofill span[data-bwautofill] elements - 9. Add new handler, for new command that responds with page details in response callback - 10. Handle sandbox iframe and sandbox rule in CSP - 11. Work on array of saved urls instead of just one to determine if we should autofill non-https sites - 12. Remove setting of attribute com.browser.browser.userEdited on user-inputs - 13. Handle null value URLs in urlNotSecure - 14. Convert to Typescript, add typings and remove dead code (not marked with START/END MODIFICATION) - */ -import AutofillForm from "../models/autofill-form"; -import AutofillPageDetails from "../models/autofill-page-details"; -import AutofillScript, { - AutofillScriptOptions, - FillScript, - FillScriptOp, -} from "../models/autofill-script"; - -/** - * The Document with additional custom properties added by this script - */ -type AutofillDocument = Document & { - elementsByOPID: Record; - elementForOPID: (opId: string) => Element; -}; - -/** - * A HTMLElement (usually a form element) with additional custom properties added by this script - */ -type ElementWithOpId = T & { - opid: string; -}; - -/** - * This script's definition of a Form Element (only a subset of HTML form elements) - * This is defined by getFormElements - */ -type FormElement = HTMLInputElement | HTMLSelectElement | HTMLSpanElement; - -/** - * A Form Element that we can set a value on (fill) - */ -type FillableControl = HTMLInputElement | HTMLSelectElement; - -function collect(document: Document) { - // START MODIFICATION - var isFirefox = - navigator.userAgent.indexOf("Firefox") !== -1 || navigator.userAgent.indexOf("Gecko/") !== -1; - // END MODIFICATION - - (document as AutofillDocument).elementsByOPID = {}; - - function getPageDetails(theDoc: Document, oneShotId: string) { - // start helpers - - /** - * For a given element `el`, returns the value of the attribute `attrName`. - * @param {HTMLElement} el - * @param {string} attrName - * @returns {string} The value of the attribute - */ - function getElementAttrValue(el: any, attrName: string) { - var attrVal = el[attrName]; - if ("string" == typeof attrVal) { - return attrVal; - } - attrVal = el.getAttribute(attrName); - return "string" == typeof attrVal ? attrVal : null; - } - - /** - * Returns the value of the given element. - * @param {HTMLElement} el - * @returns {any} Value of the element - */ - function getElementValue(el: any) { - switch (toLowerString(el.type)) { - case "checkbox": - return el.checked ? "✓" : ""; - - case "hidden": - el = el.value; - if (!el || "number" != typeof el.length) { - return ""; - } - 254 < el.length && (el = el.substr(0, 254) + "...SNIPPED"); - return el; - - default: - // START MODIFICATION - if (!el.type && el.tagName.toLowerCase() === "span") { - return el.innerText; - } - // END MODIFICATION - return el.value; - } - } - - /** - * If `el` is a `` elements, an array of the element's option `text` values - */ - selectInfo: any; - /** - * The `maxLength` attribute for the field - */ - maxLength: number; + htmlClass: string | null; + + tabindex: string | null; + + title: string | null; /** * The `tagName` for the field */ - tagName: string; - [key: string]: any; + tagName?: string | null; + /** + * The concatenated `innerText` or `textContent` of all the elements that are to the "left" of the field in the DOM + */ + "label-left"?: string; + /** + * The concatenated `innerText` or `textContent` of all the elements that are to the "right" of the field in the DOM + */ + "label-right"?: string; + /** + * For fields in a data table, the contents of the table row immediately above the field + */ + "label-top"?: string; + /** + * The concatenated `innerText` or `textContent` of all elements that are HTML labels for the field + */ + "label-tag"?: string; + /** + * The `aria-label` attribute for the field + */ + "label-aria"?: string | null; + + "label-data"?: string | null; + + "aria-hidden"?: boolean; + + "aria-disabled"?: boolean; + + "aria-haspopup"?: boolean; + + "data-stripe"?: string | null; + /** + * The HTML `placeholder` attribute for the field + */ + placeholder?: string | null; + /** + * The HTML `type` attribute for the field + */ + type?: string; + /** + * The HTML `value` for the field + */ + value?: string; + /** + * The `disabled` status of the field + */ + disabled?: boolean; + /** + * The `readonly` status of the field + */ + readonly?: boolean; + /** + * The `opid` attribute value of the form that contains the field + */ + form?: string; + /** + * The `x-autocompletetype`, `autocompletetype`, or `autocomplete` attribute for the field + */ + autoCompleteType?: string | null; + /** + * For ` + + +
+`; + +describe("CollectAutofillContentService", () => { + const domElementVisibilityService = new DomElementVisibilityService(); + let collectAutofillContentService: CollectAutofillContentService; + + beforeEach(() => { + document.body.innerHTML = mockLoginForm; + collectAutofillContentService = new CollectAutofillContentService(domElementVisibilityService); + }); + + afterEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ""; + }); + + describe("getPageDetails", () => { + it("returns an object containing information about the curren page as well as autofill data for the forms and fields of the page", async () => { + const documentTitle = "Test Page"; + const formId = "validFormId"; + const formAction = "https://example.com/"; + const formMethod = "post"; + const formName = "validFormName"; + const usernameFieldId = "usernameField"; + const usernameFieldName = "username"; + const usernameFieldLabel = "User Name"; + const passwordFieldId = "passwordField"; + const passwordFieldName = "password"; + const passwordFieldLabel = "Password"; + document.title = documentTitle; + document.body.innerHTML = ` +
+ + + + +
+ `; + jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + + const pageDetails = await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["buildAutofillFormsData"]).toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFieldsData"]).toHaveBeenCalled(); + expect(pageDetails).toStrictEqual({ + title: documentTitle, + url: window.location.href, + documentUrl: document.location.href, + forms: { + __form__0: { + opid: "__form__0", + htmlAction: formAction, + htmlName: formName, + htmlID: formId, + htmlMethod: formMethod, + }, + }, + fields: [ + { + opid: "__0", + elementNumber: 0, + maxLength: 999, + viewable: true, + htmlID: usernameFieldId, + htmlName: usernameFieldName, + htmlClass: null, + tabindex: null, + title: "", + tagName: "input", + "label-tag": usernameFieldLabel, + "label-data": null, + "label-aria": null, + "label-top": null, + "label-right": passwordFieldLabel, + "label-left": usernameFieldLabel, + placeholder: "", + rel: null, + type: "text", + value: "", + checked: false, + autoCompleteType: "", + disabled: false, + readonly: false, + selectInfo: null, + form: "__form__0", + "aria-hidden": false, + "aria-disabled": false, + "aria-haspopup": false, + "data-stripe": null, + }, + { + opid: "__1", + elementNumber: 1, + maxLength: 999, + viewable: true, + htmlID: passwordFieldId, + htmlName: passwordFieldName, + htmlClass: null, + tabindex: null, + title: "", + tagName: "input", + "label-tag": passwordFieldLabel, + "label-data": null, + "label-aria": null, + "label-top": null, + "label-right": "", + "label-left": passwordFieldLabel, + placeholder: "", + rel: null, + type: "password", + value: "", + checked: false, + autoCompleteType: "", + disabled: false, + readonly: false, + selectInfo: null, + form: "__form__0", + "aria-hidden": false, + "aria-disabled": false, + "aria-haspopup": false, + "data-stripe": null, + }, + ], + collectedTimestamp: expect.any(Number), + }); + }); + }); + + describe("getAutofillFieldElementByOpid", () => { + it("returns the element with the opid property value matching the passed value", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + const passwordInput = document.querySelector( + 'input[type="password"]' + ) as FormElementWithAttribute; + textInput.opid = "__0"; + passwordInput.opid = "__1"; + + const textInputWithOpid = collectAutofillContentService.getAutofillFieldElementByOpid("__0"); + const passwordInputWithOpid = + collectAutofillContentService.getAutofillFieldElementByOpid("__1"); + + expect(textInputWithOpid).toEqual(textInput); + expect(textInputWithOpid).not.toEqual(passwordInput); + expect(passwordInputWithOpid).toEqual(passwordInput); + }); + + it("returns the first of the element with an `opid` value matching the passed value and emits a console warning if multiple fields contain the same `opid`", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + const passwordInput = document.querySelector( + 'input[type="password"]' + ) as FormElementWithAttribute; + jest.spyOn(console, "warn").mockImplementationOnce(jest.fn()); + textInput.opid = "__1"; + passwordInput.opid = "__1"; + + const elementWithOpid0 = collectAutofillContentService.getAutofillFieldElementByOpid("__0"); + const elementWithOpid1 = collectAutofillContentService.getAutofillFieldElementByOpid("__1"); + + expect(elementWithOpid0).toEqual(textInput); + expect(elementWithOpid1).toEqual(textInput); + expect(elementWithOpid1).not.toEqual(passwordInput); + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith("More than one element found with opid __1"); + }); + + it("returns the element at the index position (parsed from passed opid) of all AutofillField elements when the passed opid value cannot be found", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + const passwordInput = document.querySelector( + 'input[type="password"]' + ) as FormElementWithAttribute; + textInput.opid = undefined; + passwordInput.opid = "__1"; + + const elementWithOpid0 = collectAutofillContentService.getAutofillFieldElementByOpid("__0"); + const elementWithOpid2 = collectAutofillContentService.getAutofillFieldElementByOpid("__2"); + + expect(textInput.opid).toBeUndefined(); + expect(elementWithOpid0).toEqual(textInput); + expect(elementWithOpid0).not.toEqual(passwordInput); + expect(elementWithOpid2).toBeNull(); + }); + + it("returns null if no element can be found", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + textInput.opid = "__0"; + + const foundElementWithOpid = + collectAutofillContentService.getAutofillFieldElementByOpid("__999"); + + expect(foundElementWithOpid).toBeNull(); + }); + }); + + describe("buildAutofillFormsData", () => { + it("returns an object of AutofillForm objects with the form id as a key", () => { + const documentTitle = "Test Page"; + const formId1 = "validFormId"; + const formAction1 = "https://example.com/"; + const formMethod1 = "post"; + const formName1 = "validFormName"; + const formId2 = "validFormId2"; + const formAction2 = "https://example2.com/"; + const formMethod2 = "get"; + const formName2 = "validFormName2"; + document.title = documentTitle; + document.body.innerHTML = ` +
+ + + + +
+
+ + +
+ `; + + const autofillFormsData = collectAutofillContentService["buildAutofillFormsData"](); + + expect(autofillFormsData).toStrictEqual({ + __form__0: { + opid: "__form__0", + htmlAction: formAction1, + htmlName: formName1, + htmlID: formId1, + htmlMethod: formMethod1, + }, + __form__1: { + opid: "__form__1", + htmlAction: formAction2, + htmlName: formName2, + htmlID: formId2, + htmlMethod: formMethod2, + }, + }); + }); + }); + + describe("buildAutofillFieldsData", () => { + it("returns a promise containing an array of AutofillField objects", async () => { + jest.spyOn(collectAutofillContentService as any, "getAutofillFieldElements"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldItem"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + + const autofillFieldsPromise = collectAutofillContentService["buildAutofillFieldsData"](); + const autofillFieldsData = await Promise.resolve(autofillFieldsPromise); + + expect(collectAutofillContentService["getAutofillFieldElements"]).toHaveBeenCalledWith(50); + expect(collectAutofillContentService["buildAutofillFieldItem"]).toHaveBeenCalledTimes(2); + expect(autofillFieldsPromise).toBeInstanceOf(Promise); + expect(autofillFieldsData).toStrictEqual([ + { + "aria-disabled": false, + "aria-haspopup": false, + "aria-hidden": false, + autoCompleteType: "", + checked: false, + "data-stripe": null, + disabled: false, + elementNumber: 0, + form: null, + htmlClass: null, + htmlID: "username", + htmlName: "", + "label-aria": null, + "label-data": null, + "label-left": "", + "label-right": "", + "label-tag": "", + "label-top": null, + maxLength: 999, + opid: "__0", + placeholder: "", + readonly: false, + rel: null, + selectInfo: null, + tabindex: null, + tagName: "input", + title: "", + type: "text", + value: "", + viewable: true, + }, + { + "aria-disabled": false, + "aria-haspopup": false, + "aria-hidden": false, + autoCompleteType: "", + checked: false, + "data-stripe": null, + disabled: false, + elementNumber: 1, + form: null, + htmlClass: null, + htmlID: "", + htmlName: "", + "label-aria": null, + "label-data": null, + "label-left": "", + "label-right": "", + "label-tag": "", + "label-top": null, + maxLength: 999, + opid: "__1", + placeholder: "", + readonly: false, + rel: null, + selectInfo: null, + tabindex: null, + tagName: "input", + title: "", + type: "password", + value: "", + viewable: true, + }, + ]); + }); + }); + + describe("getAutofillFieldElements", () => { + it("returns all form elements from the targeted document if no limit is set", () => { + document.body.innerHTML = ` +
+
+ + + + + + + + + Span Element +
+
+ `; + const usernameInput = document.getElementById("username"); + const passwordInput = document.querySelector('input[type="password"]'); + const commentsTextarea = document.getElementById("comments"); + const selectElement = document.getElementById("select"); + const spanElement = document.querySelector('span[data-bwautofill="true"]'); + jest.spyOn(document, "querySelectorAll"); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + + const formElements: FormFieldElement[] = + collectAutofillContentService["getAutofillFieldElements"](); + + expect(document.querySelectorAll).toHaveBeenCalledWith( + 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]):not([type="file"]):not([data-bwignore]), textarea:not([data-bwignore]), select:not([data-bwignore]), span[data-bwautofill]' + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).not.toHaveBeenCalled(); + expect(formElements).toEqual([ + usernameInput, + passwordInput, + commentsTextarea, + selectElement, + spanElement, + ]); + }); + + it("returns up to 2 (passed as `limit`) form elements from the targeted document with more than 2 form elements", () => { + document.body.innerHTML = ` +
+ included span + + ignored span + + + + + another included span +
+ `; + const spanElement = document.querySelector("span[data-bwautofill='true']"); + const textAreaInput = document.querySelector("textarea"); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + + const formElements: FormFieldElement[] = + collectAutofillContentService["getAutofillFieldElements"](2); + + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 1, + spanElement, + "type" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 2, + textAreaInput, + "type" + ); + expect(formElements).toEqual([spanElement, textAreaInput]); + }); + + it("returns form elements from the targeted document, ignoring input types `hidden`, `submit`, `reset`, `button`, `image`, `file`, and inputs tagged with `data-bwignore`, while giving lower order priority to `checkbox` and `radio` inputs if the returned list is truncated by `limit", () => { + document.body.innerHTML = ` +
+
+ Select an option: +
+ + +
+
+ + +
+
+ + +
+
+ included span + + ignored span + + + + + + + + + + + + + + another included span +
+ `; + const inputRadioA = document.querySelector('input[type="radio"][value="option-a"]'); + const inputRadioB = document.querySelector('input[type="radio"][value="option-b"]'); + const inputRadioC = document.querySelector('input[type="radio"][value="option-c"]'); + const firstSpan = document.getElementById("first-span"); + const textAreaInput = document.querySelector("textarea"); + const checkboxInput = document.querySelector('input[type="checkbox"]'); + const selectElement = document.querySelector("select"); + const usernameInput = document.getElementById("username"); + const passwordInput = document.querySelector('input[type="password"]'); + const secondSpan = document.getElementById("second-span"); + + const formElements: FormFieldElement[] = + collectAutofillContentService["getAutofillFieldElements"](); + + expect(formElements).toEqual([ + inputRadioA, + inputRadioB, + inputRadioC, + firstSpan, + textAreaInput, + checkboxInput, + selectElement, + usernameInput, + passwordInput, + secondSpan, + ]); + }); + + it("returns form elements from the targeted document while giving lower order priority to `checkbox` and `radio` inputs if the returned list is truncated by `limit`", () => { + document.body.innerHTML = ` +
+ + + + ignored span +
+ Select an option: +
+ + +
+
+ + +
+
+ + +
+
+ + + + + another included span +
+ `; + const textAreaInput = document.querySelector("textarea"); + const selectElement = document.querySelector("select"); + const usernameInput = document.getElementById("username"); + const passwordInput = document.querySelector('input[type="password"]'); + const includedSpan = document.querySelector('span[data-bwautofill="true"]'); + const checkboxInput = document.querySelector('input[type="checkbox"]'); + const inputRadioA = document.querySelector('input[type="radio"][value="option-a"]'); + const inputRadioB = document.querySelector('input[type="radio"][value="option-b"]'); + + const truncatedFormElements: FormFieldElement[] = + collectAutofillContentService["getAutofillFieldElements"](8); + + expect(truncatedFormElements).toEqual([ + textAreaInput, + selectElement, + usernameInput, + passwordInput, + includedSpan, + checkboxInput, + inputRadioA, + inputRadioB, + ]); + }); + }); + + describe("buildAutofillFieldItem", () => { + it("returns the AutofillField base data values without the field labels or input values if the passed element is a span element", async () => { + const index = 0; + const spanElementId = "span-element"; + const spanElementClasses = "span element classes"; + const spanElementTabIndex = 0; + const spanElementTitle = "Span Element Title"; + document.body.innerHTML = ` + Span Element + `; + const spanElement = document.getElementById( + spanElementId + ) as ElementWithOpId; + jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + jest.spyOn(collectAutofillContentService as any, "getElementValue"); + + const autofillFieldItem = await collectAutofillContentService["buildAutofillFieldItem"]( + spanElement, + index + ); + + expect(collectAutofillContentService["getAutofillFieldMaxLength"]).toHaveBeenCalledWith( + spanElement + ); + expect( + collectAutofillContentService["domElementVisibilityService"].isFormFieldViewable + ).toHaveBeenCalledWith(spanElement); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 1, + spanElement, + "id" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 2, + spanElement, + "name" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 3, + spanElement, + "class" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 4, + spanElement, + "tabindex" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 5, + spanElement, + "title" + ); + expect(collectAutofillContentService["getPropertyOrAttribute"]).toHaveBeenNthCalledWith( + 6, + spanElement, + "tagName" + ); + expect(collectAutofillContentService["getElementValue"]).not.toHaveBeenCalled(); + expect(autofillFieldItem).toEqual({ + elementNumber: index, + htmlClass: spanElementClasses, + htmlID: spanElementId, + htmlName: null, + maxLength: null, + opid: `__${index}`, + tabindex: String(spanElementTabIndex), + tagName: spanElement.tagName.toLowerCase(), + title: spanElementTitle, + viewable: true, + }); + }); + + it("returns the AutofillField base data, label data, and input element data", async () => { + const index = 0; + const usernameField = { + labelText: "Username", + id: "username-id", + classes: "username input classes", + name: "username", + type: "text", + maxLength: 42, + tabIndex: 0, + title: "Username Input Title", + autocomplete: "username-autocomplete", + dataLabel: "username-data-label", + ariaLabel: "username-aria-label", + placeholder: "username-placeholder", + rel: "username-rel", + value: "username-value", + dataStripe: "data-stripe", + }; + document.body.innerHTML = ` +
+ + +
+ `; + const formElement = document.querySelector("form"); + formElement.opid = "form-opid"; + const usernameInput = document.getElementById( + usernameField.id + ) as ElementWithOpId; + jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + jest.spyOn(collectAutofillContentService as any, "getElementValue"); + + const autofillFieldItem = await collectAutofillContentService["buildAutofillFieldItem"]( + usernameInput, + index + ); + + expect(autofillFieldItem).toEqual({ + "aria-disabled": false, + "aria-haspopup": false, + "aria-hidden": false, + autoCompleteType: usernameField.autocomplete, + checked: false, + "data-stripe": usernameField.dataStripe, + disabled: false, + elementNumber: index, + form: formElement.opid, + htmlClass: usernameField.classes, + htmlID: usernameField.id, + htmlName: usernameField.name, + "label-aria": usernameField.ariaLabel, + "label-data": usernameField.dataLabel, + "label-left": usernameField.labelText, + "label-right": "", + "label-tag": usernameField.labelText, + "label-top": null, + maxLength: usernameField.maxLength, + opid: `__${index}`, + placeholder: usernameField.placeholder, + readonly: false, + rel: usernameField.rel, + selectInfo: null, + tabindex: String(usernameField.tabIndex), + tagName: usernameInput.tagName.toLowerCase(), + title: usernameField.title, + type: usernameField.type, + value: usernameField.value, + viewable: true, + }); + }); + + it("returns the AutofillField base data and input element data, but not the label data if the input element is of type `hidden`", async () => { + const index = 0; + const hiddenField = { + labelText: "Hidden Field", + id: "hidden-id", + classes: "hidden input classes", + name: "hidden", + type: "hidden", + maxLength: 42, + tabIndex: 0, + title: "Hidden Input Title", + autocomplete: "off", + rel: "hidden-rel", + value: "hidden-value", + dataStripe: "data-stripe", + }; + document.body.innerHTML = ` +
+ + +
+ `; + const formElement = document.querySelector("form"); + formElement.opid = "form-opid"; + const hiddenInput = document.getElementById( + hiddenField.id + ) as ElementWithOpId; + jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + jest.spyOn(collectAutofillContentService as any, "getElementValue"); + + const autofillFieldItem = await collectAutofillContentService["buildAutofillFieldItem"]( + hiddenInput, + index + ); + + expect(autofillFieldItem).toEqual({ + "aria-disabled": false, + "aria-haspopup": false, + "aria-hidden": false, + autoCompleteType: null, + checked: false, + "data-stripe": hiddenField.dataStripe, + disabled: false, + elementNumber: index, + form: formElement.opid, + htmlClass: hiddenField.classes, + htmlID: hiddenField.id, + htmlName: hiddenField.name, + maxLength: hiddenField.maxLength, + opid: `__${index}`, + readonly: false, + rel: hiddenField.rel, + selectInfo: null, + tabindex: String(hiddenField.tabIndex), + tagName: hiddenInput.tagName.toLowerCase(), + title: hiddenField.title, + type: hiddenField.type, + value: hiddenField.value, + viewable: true, + }); + }); + }); + + describe("createAutofillFieldLabelTag", () => { + beforeEach(() => { + jest.spyOn(collectAutofillContentService as any, "createLabelElementsTag"); + jest.spyOn(document, "querySelectorAll"); + }); + + it("returns the label tag early if the passed element contains any labels", () => { + document.body.innerHTML = ` + + + + `; + const element = document.querySelector("#username-id") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set(element.labels) + ); + expect(document.querySelectorAll).not.toHaveBeenCalled(); + expect(labelTag).toEqual("Username"); + }); + + it("queries all labels associated with the element's id", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("#country-id") as FillableFormFieldElement; + const elementLabel = document.querySelector("label[for='country-id']"); + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(document.querySelectorAll).toHaveBeenCalledWith(`label[for="${element.id}"]`); + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set([elementLabel]) + ); + expect(labelTag).toEqual("Country"); + }); + + it("queries all labels associated with the element's name", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("select") as FillableFormFieldElement; + const elementLabel = document.querySelector("label[for='country-name']"); + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(document.querySelectorAll).not.toHaveBeenCalledWith(`label[for="${element.id}"]`); + expect(document.querySelectorAll).toHaveBeenCalledWith(`label[for="${element.name}"]`); + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set([elementLabel]) + ); + expect(labelTag).toEqual("Country"); + }); + + it("will not add duplicate labels that are found to the label tag", () => { + document.body.innerHTML = ` + +
+ `; + const element = document.querySelector("#country-name") as FillableFormFieldElement; + element.name = "country-name"; + const elementLabel = document.querySelector("label[for='country-name']"); + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(document.querySelectorAll).toHaveBeenCalledWith( + `label[for="${element.id}"], label[for="${element.name}"]` + ); + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set([elementLabel]) + ); + expect(labelTag).toEqual("Country"); + }); + + it("will attempt to identify the label of an element from its parent element", () => { + document.body.innerHTML = ``; + const element = document.querySelector("#username-id") as FillableFormFieldElement; + const elementLabel = element.parentElement; + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set([elementLabel]) + ); + expect(labelTag).toEqual("Username"); + }); + + it("will attempt to identify the label of an element from a `dt` element associated with the element's parent", () => { + document.body.innerHTML = ` +
+
Username
+
+ +
+
+ `; + const element = document.querySelector("#username-id") as FillableFormFieldElement; + const elementLabel = document.querySelector("#label-element"); + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(collectAutofillContentService["createLabelElementsTag"]).toHaveBeenCalledWith( + new Set([elementLabel]) + ); + expect(labelTag).toEqual("Username"); + }); + + it("will return an empty string value if no labels can be found for an element", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("#username-id") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldLabelTag"](element); + + expect(labelTag).toEqual(""); + }); + }); + + describe("queryElementLabels", () => { + it("returns null if the passed element has no id or name", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labels = collectAutofillContentService["queryElementLabels"](element); + + expect(labels).toBeNull(); + }); + + it("returns an empty NodeList if the passed element has no label", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labels = collectAutofillContentService["queryElementLabels"](element); + + expect(labels).toEqual(document.querySelectorAll("label")); + }); + + it("returns the label of an element associated with its ID value", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labels = collectAutofillContentService["queryElementLabels"](element); + + expect(labels).toEqual(document.querySelectorAll("label[for='username-id']")); + }); + + it("returns the label of an element associated with its name value", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labels = collectAutofillContentService["queryElementLabels"](element); + + expect(labels).toEqual(document.querySelectorAll("label[for='username']")); + }); + }); + + describe("createLabelElementsTag", () => { + it("returns a string containing all the labels associated with a given input element", () => { + const firstLabelText = "Username by name"; + const secondLabelText = "Username by ID"; + document.body.innerHTML = ` + + + + `; + const labels = document.querySelectorAll("label"); + jest.spyOn(collectAutofillContentService as any, "trimAndRemoveNonPrintableText"); + + const labelTag = collectAutofillContentService["createLabelElementsTag"](new Set(labels)); + + expect( + collectAutofillContentService["trimAndRemoveNonPrintableText"] + ).toHaveBeenNthCalledWith(1, firstLabelText); + expect( + collectAutofillContentService["trimAndRemoveNonPrintableText"] + ).toHaveBeenNthCalledWith(2, secondLabelText); + expect(labelTag).toEqual(`${firstLabelText}${secondLabelText}`); + }); + }); + + describe("getAutofillFieldMaxLength", () => { + it("returns null if the passed FormFieldElement is not an element type that has a max length property", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("select") as FillableFormFieldElement; + + const maxLength = collectAutofillContentService["getAutofillFieldMaxLength"](element); + + expect(maxLength).toBeNull(); + }); + + it("returns a value of 999 if the passed FormFieldElement has no set maxLength value", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const maxLength = collectAutofillContentService["getAutofillFieldMaxLength"](element); + + expect(maxLength).toEqual(999); + }); + + it("returns a value of 999 if the passed FormFieldElement has a maxLength value higher than 999", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const maxLength = collectAutofillContentService["getAutofillFieldMaxLength"](element); + + expect(maxLength).toEqual(999); + }); + + it("returns the maxLength property of a passed FormFieldElement", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const maxLength = collectAutofillContentService["getAutofillFieldMaxLength"](element); + + expect(maxLength).toEqual(10); + }); + }); + + describe("createAutofillFieldRightLabel", () => { + it("returns an empty string if no siblings are found", () => { + document.body.innerHTML = ` + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldRightLabel"](element); + + expect(labelTag).toEqual(""); + }); + + it("returns the text content of the element's next sibling element", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldRightLabel"](element); + + expect(labelTag).toEqual("Username"); + }); + + it("returns the text content of the element's next sibling textNode", () => { + document.body.innerHTML = ` + + Username + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldRightLabel"](element); + + expect(labelTag).toEqual("Username"); + }); + }); + + describe("createAutofillFieldLeftLabel", () => { + it("returns a string value of the text content associated with the previous siblings of the passed element", () => { + document.body.innerHTML = ` +
+ Text Content + + +
+ `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labelTag = collectAutofillContentService["createAutofillFieldLeftLabel"](element); + + expect(labelTag).toEqual("Text ContentUsername"); + }); + }); + + describe("createAutofillFieldTopLabel", () => { + it("returns the table column header value for the passed table element", () => { + document.body.innerHTML = ` + + + + + + + + + + + + + +
UsernamePasswordLogin code
+ `; + const targetTableCellInput = document.querySelector( + 'input[name="password"]' + ) as HTMLInputElement; + + const targetTableCellLabel = + collectAutofillContentService["createAutofillFieldTopLabel"](targetTableCellInput); + + expect(targetTableCellLabel).toEqual("Password"); + }); + + it("will attempt to return the value for the previous sibling row as the label if a `th` cell is not found", () => { + document.body.innerHTML = ` + + + + + + + + + + + + + +
UsernamePasswordLogin code
+ `; + const targetTableCellInput = document.querySelector( + 'input[name="auth-code"]' + ) as HTMLInputElement; + + const targetTableCellLabel = + collectAutofillContentService["createAutofillFieldTopLabel"](targetTableCellInput); + + expect(targetTableCellLabel).toEqual("Login code"); + }); + + it("returns null for the passed table element it's parent row has no previous sibling row", () => { + document.body.innerHTML = ` + + + + + + + + +
+ `; + const targetTableCellInput = document.querySelector( + 'input[name="password"]' + ) as HTMLInputElement; + + const targetTableCellLabel = + collectAutofillContentService["createAutofillFieldTopLabel"](targetTableCellInput); + + expect(targetTableCellLabel).toEqual(null); + }); + + it("returns null if the input element is not structured within a `td` element", () => { + document.body.innerHTML = ` + + + + + + + + + +
+ + + +
UsernamePasswordLogin code
+ `; + const targetTableCellInput = document.querySelector( + 'input[name="password"]' + ) as HTMLInputElement; + + const targetTableCellLabel = + collectAutofillContentService["createAutofillFieldTopLabel"](targetTableCellInput); + + expect(targetTableCellLabel).toEqual(null); + }); + + it("returns null if the index of the `td` element is larger than the length of cells in the sibling row", () => { + document.body.innerHTML = ` + + + + + + + + + + + + +
UsernamePassword
+ `; + const targetTableCellInput = document.querySelector( + 'input[name="auth-code"]' + ) as HTMLInputElement; + + const targetTableCellLabel = + collectAutofillContentService["createAutofillFieldTopLabel"](targetTableCellInput); + + expect(targetTableCellLabel).toEqual(null); + }); + }); + + describe("isNewSectionElement", () => { + const validElementTags = [ + "html", + "body", + "button", + "form", + "head", + "iframe", + "input", + "option", + "script", + "select", + "table", + "textarea", + ]; + const invalidElementTags = ["div", "span"]; + + describe("given a transitional element", () => { + validElementTags.forEach((tag) => { + const element = document.createElement(tag); + + it(`returns true if the element tag is a ${tag}`, () => { + expect(collectAutofillContentService["isNewSectionElement"](element)).toEqual(true); + }); + }); + }); + + describe("given an non-transitional element", () => { + invalidElementTags.forEach((tag) => { + const element = document.createElement(tag); + + it(`returns false if the element tag is a ${tag}`, () => { + expect(collectAutofillContentService["isNewSectionElement"](element)).toEqual(false); + }); + }); + }); + + it(`returns true if the provided element is falsy`, () => { + expect(collectAutofillContentService["isNewSectionElement"](undefined)).toEqual(true); + }); + }); + + describe("getTextContentFromElement", () => { + it("returns the node value for a text node", () => { + document.body.innerHTML = ` +
+ +
+ `; + const element = document.querySelector("#username-id"); + const textNode = element.previousSibling; + const parsedTextContent = collectAutofillContentService["trimAndRemoveNonPrintableText"]( + textNode.nodeValue + ); + jest.spyOn(collectAutofillContentService as any, "trimAndRemoveNonPrintableText"); + + const textContent = collectAutofillContentService["getTextContentFromElement"](textNode); + + expect(textNode.nodeType).toEqual(Node.TEXT_NODE); + expect(collectAutofillContentService["trimAndRemoveNonPrintableText"]).toHaveBeenCalledWith( + textNode.nodeValue + ); + expect(textContent).toEqual(parsedTextContent); + }); + + it("returns the text content for an element node", () => { + document.body.innerHTML = ` +
+ + +
+ `; + const element = document.querySelector('label[for="username-id"]'); + jest.spyOn(collectAutofillContentService as any, "trimAndRemoveNonPrintableText"); + + const textContent = collectAutofillContentService["getTextContentFromElement"](element); + + expect(element.nodeType).toEqual(Node.ELEMENT_NODE); + expect(collectAutofillContentService["trimAndRemoveNonPrintableText"]).toHaveBeenCalledWith( + element.textContent + ); + expect(textContent).toEqual(element.textContent); + }); + }); + + describe("trimAndRemoveNonPrintableText", () => { + it("returns an empty string if no text content is passed", () => { + const textContent = collectAutofillContentService["trimAndRemoveNonPrintableText"](undefined); + + expect(textContent).toEqual(""); + }); + + it("returns a trimmed string with all non-printable text removed", () => { + const nonParsedText = `Hello!\nThis is a \t + test string.\x0B\x08`; + + const parsedText = + collectAutofillContentService["trimAndRemoveNonPrintableText"](nonParsedText); + + expect(parsedText).toEqual("Hello! This is a test string."); + }); + }); + + describe("recursivelyGetTextFromPreviousSiblings", () => { + it("should find text adjacent to the target element likely to be a label", () => { + document.body.innerHTML = ` +
+ Text about things +
some things
+
+

Stuff Section Header

+ Other things which are also stuff +
Not visible text
+ + +
+
+ `; + const textInput = document.querySelector("#input-tag") as FormElementWithAttribute; + + const elementList: string[] = + collectAutofillContentService["recursivelyGetTextFromPreviousSiblings"](textInput); + + expect(elementList).toEqual([ + "something else", + "Not visible text", + "Other things which are also stuff", + "Stuff Section Header", + ]); + }); + + it("should stop looking at siblings for label values when a 'new section' element is seen", () => { + document.body.innerHTML = ` +
+ Text about things +
some things
+
+

Stuff Section Header

+ Other things which are also stuff +
Not a label
+ + + +
+
+ `; + + const textInput = document.querySelector("#input-tag") as FormElementWithAttribute; + const elementList: string[] = + collectAutofillContentService["recursivelyGetTextFromPreviousSiblings"](textInput); + + expect(elementList).toEqual(["something else"]); + }); + + it("should keep looking for labels in parents when there are no siblings of the target element", () => { + document.body.innerHTML = ` +
+ Text about things + +
some things
+
+ +
+
+ `; + + const textInput = document.querySelector("#input-tag") as FormElementWithAttribute; + const elementList: string[] = + collectAutofillContentService["recursivelyGetTextFromPreviousSiblings"](textInput); + + expect(elementList).toEqual(["some things"]); + }); + + it("should find label in parent sibling last child if no other label candidates have been encountered and there are no text nodes along the way", () => { + document.body.innerHTML = ` +
+
+
not the most relevant things
+
some nested things
+
+ +
+
+
+ `; + + const textInput = document.querySelector("#input-tag") as FormElementWithAttribute; + const elementList: string[] = + collectAutofillContentService["recursivelyGetTextFromPreviousSiblings"](textInput); + + expect(elementList).toEqual(["some nested things"]); + }); + + it("should exit early if the target element has no parent element/node", () => { + const textInput = document.querySelector("html") as HTMLHtmlElement; + + const elementList: string[] = + collectAutofillContentService["recursivelyGetTextFromPreviousSiblings"](textInput); + + expect(elementList).toEqual([]); + }); + }); + + describe("getPropertyOrAttribute", () => { + it("returns the value of the named property of the target element if the property exists within the element", () => { + document.body.innerHTML += ''; + const textInput = document.querySelector("#username") as HTMLInputElement; + textInput.setAttribute("value", "jsmith"); + const checkboxInput = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + jest.spyOn(textInput, "getAttribute"); + jest.spyOn(checkboxInput, "getAttribute"); + + const textInputValue = collectAutofillContentService["getPropertyOrAttribute"]( + textInput, + "value" + ); + const textInputId = collectAutofillContentService["getPropertyOrAttribute"](textInput, "id"); + const textInputBaseURI = collectAutofillContentService["getPropertyOrAttribute"]( + textInput, + "baseURI" + ); + const textInputAutofocus = collectAutofillContentService["getPropertyOrAttribute"]( + textInput, + "autofocus" + ); + const checkboxInputChecked = collectAutofillContentService["getPropertyOrAttribute"]( + checkboxInput, + "checked" + ); + + expect(textInput.getAttribute).not.toHaveBeenCalled(); + expect(checkboxInput.getAttribute).not.toHaveBeenCalled(); + expect(textInputValue).toEqual("jsmith"); + expect(textInputId).toEqual("username"); + expect(textInputBaseURI).toEqual("http://localhost/"); + expect(textInputAutofocus).toEqual(false); + expect(checkboxInputChecked).toEqual(true); + }); + + it("returns the value of the named attribute of the element if it does not exist as a property within the element", () => { + const textInput = document.querySelector("#username") as HTMLInputElement; + textInput.setAttribute("data-unique-attribute", "unique-value"); + jest.spyOn(textInput, "getAttribute"); + + const textInputUniqueAttribute = collectAutofillContentService["getPropertyOrAttribute"]( + textInput, + "data-unique-attribute" + ); + + expect(textInputUniqueAttribute).toEqual("unique-value"); + expect(textInput.getAttribute).toHaveBeenCalledWith("data-unique-attribute"); + }); + + it("returns a null value if the element does not contain the passed attribute name as either a property or attribute value", () => { + const textInput = document.querySelector("#username") as HTMLInputElement; + jest.spyOn(textInput, "getAttribute"); + + const textInputNonExistentAttribute = collectAutofillContentService["getPropertyOrAttribute"]( + textInput, + "non-existent-attribute" + ); + + expect(textInputNonExistentAttribute).toEqual(null); + expect(textInput.getAttribute).toHaveBeenCalledWith("non-existent-attribute"); + }); + }); + + describe("getElementValue", () => { + it("returns an empty string of passed input elements whose value is not set", () => { + document.body.innerHTML += ` + + + + `; + const textInput = document.querySelector("#username") as HTMLInputElement; + const checkboxInput = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + const hiddenInput = document.querySelector("#hidden-input") as HTMLInputElement; + const spanInput = document.querySelector("#span-input") as HTMLInputElement; + + const textInputValue = collectAutofillContentService["getElementValue"](textInput); + const checkboxInputValue = collectAutofillContentService["getElementValue"](checkboxInput); + const hiddenInputValue = collectAutofillContentService["getElementValue"](hiddenInput); + const spanInputValue = collectAutofillContentService["getElementValue"](spanInput); + + expect(textInputValue).toEqual(""); + expect(checkboxInputValue).toEqual(""); + expect(hiddenInputValue).toEqual(""); + expect(spanInputValue).toEqual(""); + }); + + it("returns the value of the passed input element", () => { + document.body.innerHTML += ` + + + A span input value + `; + const textInput = document.querySelector("#username") as HTMLInputElement; + textInput.value = "jsmith"; + const checkboxInput = document.querySelector('input[type="checkbox"]') as HTMLInputElement; + checkboxInput.checked = true; + const hiddenInput = document.querySelector("#hidden-input") as HTMLInputElement; + hiddenInput.value = "aHiddenInputValue"; + const spanInput = document.querySelector("#span-input") as HTMLInputElement; + + const textInputValue = collectAutofillContentService["getElementValue"](textInput); + const checkboxInputValue = collectAutofillContentService["getElementValue"](checkboxInput); + const hiddenInputValue = collectAutofillContentService["getElementValue"](hiddenInput); + const spanInputValue = collectAutofillContentService["getElementValue"](spanInput); + + expect(textInputValue).toEqual("jsmith"); + expect(checkboxInputValue).toEqual("✓"); + expect(hiddenInputValue).toEqual("aHiddenInputValue"); + expect(spanInputValue).toEqual("A span input value"); + }); + + it("return the truncated value of the passed hidden input type if the value length exceeds 256 characters", () => { + document.body.innerHTML += ` + + `; + const longValueHiddenInput = document.querySelector( + "#long-value-hidden-input" + ) as HTMLInputElement; + + const longHiddenValue = + collectAutofillContentService["getElementValue"](longValueHiddenInput); + + expect(longHiddenValue).toEqual( + "’Twas brillig, and the slithy toves | Did gyre and gimble in the wabe: | All mimsy were the borogoves, | And the mome raths outgrabe. | “Beware the Jabberwock, my son! | The jaws that bite, the claws that catch! | Beware the Jubjub bird, and shun | The f...SNIPPED" + ); + }); + }); + + describe("getSelectElementOptions", () => { + it("returns the inner text and values of each `option` within the passed `select`", () => { + document.body.innerHTML = ` + + + `; + const selectWithOptions = document.querySelector("#select-with-options") as HTMLSelectElement; + const selectWithoutOptions = document.querySelector( + "#select-without-options" + ) as HTMLSelectElement; + + const selectWithOptionsOptions = + collectAutofillContentService["getSelectElementOptions"](selectWithOptions); + const selectWithoutOptionsOptions = + collectAutofillContentService["getSelectElementOptions"](selectWithoutOptions); + + expect(selectWithOptionsOptions).toEqual({ + options: [ + ["option1", "1"], + ["optionb", "b"], + ["optioniii", "iii"], + [null, "four"], + ], + }); + expect(selectWithoutOptionsOptions).toEqual({ options: [] }); + }); + }); +}); diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts new file mode 100644 index 00000000000..ec7658c9863 --- /dev/null +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -0,0 +1,578 @@ +import AutofillField from "../models/autofill-field"; +import AutofillForm from "../models/autofill-form"; +import AutofillPageDetails from "../models/autofill-page-details"; +import { + ElementWithOpId, + FillableFormFieldElement, + FormFieldElement, + FormElementWithAttribute, +} from "../types"; + +import { CollectAutofillContentService as CollectAutofillContentServiceInterface } from "./abstractions/collect-autofill-content.service"; +import DomElementVisibilityService from "./dom-element-visibility.service"; + +class CollectAutofillContentService implements CollectAutofillContentServiceInterface { + private readonly domElementVisibilityService: DomElementVisibilityService; + + constructor(domElementVisibilityService: DomElementVisibilityService) { + this.domElementVisibilityService = domElementVisibilityService; + } + + /** + * Builds the data for all the forms and fields + * that are found within the page DOM. + * @returns {Promise} + * @public + */ + async getPageDetails(): Promise { + const autofillFormsData: Record = this.buildAutofillFormsData(); + const autofillFieldsData: AutofillField[] = await this.buildAutofillFieldsData(); + + return { + title: document.title, + url: (document.defaultView || window).location.href, + documentUrl: document.location.href, + forms: autofillFormsData, + fields: autofillFieldsData, + collectedTimestamp: Date.now(), + }; + } + + /** + * Find an AutofillField element by its opid, will only return the first + * element if there are multiple elements with the same opid. If no + * element is found, null will be returned. + * @param {string} opid + * @returns {FormFieldElement | null} + */ + getAutofillFieldElementByOpid(opid: string): FormFieldElement | null { + const fieldElements = this.getAutofillFieldElements(); + const fieldElementsWithOpid = fieldElements.filter( + (fieldElement) => (fieldElement as ElementWithOpId).opid === opid + ) as ElementWithOpId[]; + + if (!fieldElementsWithOpid.length) { + const elementIndex = parseInt(opid.split("__")[1], 10); + + return fieldElements[elementIndex] || null; + } + + if (fieldElementsWithOpid.length > 1) { + // eslint-disable-next-line no-console + console.warn(`More than one element found with opid ${opid}`); + } + + return fieldElementsWithOpid[0]; + } + + /** + * Queries the DOM for all the forms elements and + * returns a collection of AutofillForm objects. + * @returns {Record} + * @private + */ + private buildAutofillFormsData(): Record { + const autofillForms: Record = {}; + const documentFormElements = document.querySelectorAll("form"); + + documentFormElements.forEach((formElement: HTMLFormElement, index: number) => { + formElement.opid = `__form__${index}`; + + autofillForms[formElement.opid] = { + opid: formElement.opid, + htmlAction: new URL( + this.getPropertyOrAttribute(formElement, "action"), + window.location.href + ).href, + htmlName: this.getPropertyOrAttribute(formElement, "name"), + htmlID: this.getPropertyOrAttribute(formElement, "id"), + htmlMethod: this.getPropertyOrAttribute(formElement, "method"), + }; + }); + + return autofillForms; + } + + /** + * Queries the DOM for all the field elements and + * returns a list of AutofillField objects. + * @returns {Promise} + * @private + */ + private async buildAutofillFieldsData(): Promise { + const autofillFieldElements = this.getAutofillFieldElements(50); + const autofillFieldDataPromises = autofillFieldElements.map(this.buildAutofillFieldItem); + + return Promise.all(autofillFieldDataPromises); + } + + /** + * Queries the DOM for all the field elements that can be autofilled, + * and returns a list limited to the given `fieldsLimit` number that + * is ordered by priority. + * @param {number} fieldsLimit - The maximum number of fields to return + * @returns {FormFieldElement[]} + * @private + */ + private getAutofillFieldElements(fieldsLimit?: number): FormFieldElement[] { + const formFieldElements: FormFieldElement[] = [ + ...(document.querySelectorAll( + 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]):not([type="file"]):not([data-bwignore]), ' + + "textarea:not([data-bwignore]), " + + "select:not([data-bwignore]), " + + "span[data-bwautofill]" + ) as NodeListOf), + ]; + + if (!fieldsLimit || formFieldElements.length <= fieldsLimit) { + return formFieldElements; + } + + const priorityFormFields: FormFieldElement[] = []; + const unimportantFormFields: FormFieldElement[] = []; + const unimportantFieldTypesSet = new Set(["checkbox", "radio"]); + for (const element of formFieldElements) { + if (priorityFormFields.length >= fieldsLimit) { + return priorityFormFields; + } + + const fieldType = this.getPropertyOrAttribute(element, "type")?.toLowerCase(); + if (unimportantFieldTypesSet.has(fieldType)) { + unimportantFormFields.push(element); + continue; + } + + priorityFormFields.push(element); + } + + const numberUnimportantFieldsToInclude = fieldsLimit - priorityFormFields.length; + for (let index = 0; index < numberUnimportantFieldsToInclude; index++) { + priorityFormFields.push(unimportantFormFields[index]); + } + + return priorityFormFields; + } + + /** + * Builds an AutofillField object from the given form element. Will only return + * shared field values if the element is a span element. Will not return any label + * values if the element is a hidden input element. + * @param {ElementWithOpId} element + * @param {number} index + * @returns {Promise} + * @private + */ + private buildAutofillFieldItem = async ( + element: ElementWithOpId, + index: number + ): Promise => { + element.opid = `__${index}`; + + const autofillFieldBase = { + opid: element.opid, + elementNumber: index, + maxLength: this.getAutofillFieldMaxLength(element), + viewable: await this.domElementVisibilityService.isFormFieldViewable(element), + htmlID: this.getPropertyOrAttribute(element, "id"), + htmlName: this.getPropertyOrAttribute(element, "name"), + htmlClass: this.getPropertyOrAttribute(element, "class"), + tabindex: this.getPropertyOrAttribute(element, "tabindex"), + title: this.getPropertyOrAttribute(element, "title"), + tagName: this.getPropertyOrAttribute(element, "tagName")?.toLowerCase(), + }; + + if (element instanceof HTMLSpanElement) { + return autofillFieldBase; + } + + let autofillFieldLabels = {}; + const autoCompleteType = + this.getPropertyOrAttribute(element, "x-autocompletetype") || + this.getPropertyOrAttribute(element, "autocompletetype") || + this.getPropertyOrAttribute(element, "autocomplete"); + const elementType = this.getPropertyOrAttribute(element, "type")?.toLowerCase(); + if (elementType !== "hidden") { + autofillFieldLabels = { + "label-tag": this.createAutofillFieldLabelTag(element), + "label-data": this.getPropertyOrAttribute(element, "data-label"), + "label-aria": this.getPropertyOrAttribute(element, "aria-label"), + "label-top": this.createAutofillFieldTopLabel(element), + "label-right": this.createAutofillFieldRightLabel(element), + "label-left": this.createAutofillFieldLeftLabel(element), + placeholder: this.getPropertyOrAttribute(element, "placeholder"), + }; + } + + return { + ...autofillFieldBase, + ...autofillFieldLabels, + rel: this.getPropertyOrAttribute(element, "rel"), + type: elementType, + value: this.getElementValue(element), + checked: Boolean(this.getPropertyOrAttribute(element, "checked")), + autoCompleteType: autoCompleteType !== "off" ? autoCompleteType : null, + disabled: Boolean(this.getPropertyOrAttribute(element, "disabled")), + readonly: Boolean(this.getPropertyOrAttribute(element, "readOnly")), + selectInfo: + element instanceof HTMLSelectElement ? this.getSelectElementOptions(element) : null, + form: element.form ? this.getPropertyOrAttribute(element.form, "opid") : null, + "aria-hidden": this.getPropertyOrAttribute(element, "aria-hidden") === "true", + "aria-disabled": this.getPropertyOrAttribute(element, "aria-disabled") === "true", + "aria-haspopup": this.getPropertyOrAttribute(element, "aria-haspopup") === "true", + "data-stripe": this.getPropertyOrAttribute(element, "data-stripe"), + }; + }; + + /** + * Creates a label tag used to autofill the element pulled from a label + * associated with the element's id, name, parent element or from an + * associated description term element if no other labels can be found. + * Returns a string containing all the `textContent` or `innerText` + * values of the label elements. + * @param {FillableFormFieldElement} element + * @returns {string} + * @private + */ + private createAutofillFieldLabelTag(element: FillableFormFieldElement): string { + const labelElementsSet: Set = new Set(element.labels); + + if (labelElementsSet.size) { + return this.createLabelElementsTag(labelElementsSet); + } + + const labelElements: NodeListOf | null = this.queryElementLabels(element); + labelElements?.forEach((labelElement) => labelElementsSet.add(labelElement)); + + let currentElement: HTMLElement | null = element; + while (currentElement && currentElement !== document.documentElement) { + if (currentElement instanceof HTMLLabelElement) { + labelElementsSet.add(currentElement); + } + + currentElement = currentElement.parentElement.closest("label"); + } + + if ( + !labelElementsSet.size && + element.parentElement?.tagName.toLowerCase() === "dd" && + element.parentElement.previousElementSibling?.tagName.toLowerCase() === "dt" + ) { + labelElementsSet.add(element.parentElement.previousElementSibling as HTMLElement); + } + + return this.createLabelElementsTag(labelElementsSet); + } + + /** + * Queries the DOM for label elements associated with the given element + * by id or name. Returns a NodeList of label elements or null if none + * are found. + * @param {FillableFormFieldElement} element + * @returns {NodeListOf | null} + * @private + */ + private queryElementLabels( + element: FillableFormFieldElement + ): NodeListOf | null { + let labelQuerySelectors = element.id ? `label[for="${element.id}"]` : ""; + if (element.name) { + const forElementNameSelector = `label[for="${element.name}"]`; + labelQuerySelectors = labelQuerySelectors + ? `${labelQuerySelectors}, ${forElementNameSelector}` + : forElementNameSelector; + } + + if (!labelQuerySelectors) { + return null; + } + + return document.querySelectorAll(labelQuerySelectors); + } + + /** + * Map over all the label elements and creates a + * string of the text content of each label element. + * @param {Set} labelElementsSet + * @returns {string} + * @private + */ + private createLabelElementsTag = (labelElementsSet: Set): string => { + return [...labelElementsSet] + .map((labelElement) => { + const textContent: string | null = labelElement + ? labelElement.textContent || labelElement.innerText + : null; + + return this.trimAndRemoveNonPrintableText(textContent || ""); + }) + .join(""); + }; + + /** + * Gets the maxLength property of the passed FormFieldElement and + * returns the value or null if the element does not have a + * maxLength property. If the element has a maxLength property + * greater than 999, it will return 999. + * @param {FormFieldElement} element + * @returns {number | null} + * @private + */ + private getAutofillFieldMaxLength(element: FormFieldElement): number | null { + const elementHasMaxLengthProperty = + element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement; + const elementMaxLength = + elementHasMaxLengthProperty && element.maxLength > -1 ? element.maxLength : 999; + + return elementHasMaxLengthProperty ? Math.min(elementMaxLength, 999) : null; + } + + /** + * Iterates over the next siblings of the passed element and + * returns a string of the text content of each element. Will + * stop iterating if it encounters a new section element. + * @param {FormFieldElement} element + * @returns {string} + * @private + */ + private createAutofillFieldRightLabel(element: FormFieldElement): string { + const labelTextContent: string[] = []; + let currentElement: ChildNode = element; + + while (currentElement && currentElement.nextSibling) { + currentElement = currentElement.nextSibling; + if (this.isNewSectionElement(currentElement)) { + break; + } + + const textContent = this.getTextContentFromElement(currentElement); + if (textContent) { + labelTextContent.push(textContent); + } + } + + return labelTextContent.join(""); + } + + /** + * Recursively gets the text content from an element's previous siblings + * and returns a string of the text content of each element. + * @param {FormFieldElement} element + * @returns {string} + * @private + */ + private createAutofillFieldLeftLabel(element: FormFieldElement): string { + const labelTextContent: string[] = this.recursivelyGetTextFromPreviousSiblings(element); + + return labelTextContent.reverse().join(""); + } + + /** + * Assumes that the input elements that are to be autofilled are within a + * table structure. Queries the previous sibling of the parent row that + * the input element is in and returns the text content of the cell that + * is in the same column as the input element. + * @param {FormFieldElement} element + * @returns {string | null} + * @private + */ + private createAutofillFieldTopLabel(element: FormFieldElement): string | null { + const tableDataElement = element.closest("td"); + if (!tableDataElement) { + return null; + } + + const tableDataElementIndex = tableDataElement.cellIndex; + const parentSiblingTableRowElement = tableDataElement.closest("tr") + ?.previousElementSibling as HTMLTableRowElement; + + return parentSiblingTableRowElement?.cells?.length > tableDataElementIndex + ? this.getTextContentFromElement(parentSiblingTableRowElement.cells[tableDataElementIndex]) + : null; + } + + /** + * Check if the element's tag indicates that a transition to a new section of the + * page is occurring. If so, we should not use the element or its children in order + * to get autofill context for the previous element. + * @param {HTMLElement} currentElement + * @returns {boolean} + * @private + */ + private isNewSectionElement(currentElement: HTMLElement | Node): boolean { + if (!currentElement) { + return true; + } + + const transitionalElementTagsSet = new Set([ + "html", + "body", + "button", + "form", + "head", + "iframe", + "input", + "option", + "script", + "select", + "table", + "textarea", + ]); + return ( + "tagName" in currentElement && + transitionalElementTagsSet.has(currentElement.tagName.toLowerCase()) + ); + } + + /** + * Gets the text content from a passed element, regardless of whether it is a + * text node, an element node or an HTMLElement. + * @param {Node | HTMLElement} element + * @returns {string} + * @private + */ + private getTextContentFromElement(element: Node | HTMLElement): string { + if (element.nodeType === Node.TEXT_NODE) { + return this.trimAndRemoveNonPrintableText(element.nodeValue); + } + + return this.trimAndRemoveNonPrintableText( + element.textContent || (element as HTMLElement).innerText + ); + } + + /** + * Removes non-printable characters from the passed text + * content and trims leading and trailing whitespace. + * @param {string} textContent + * @returns {string} + * @private + */ + private trimAndRemoveNonPrintableText(textContent: string): string { + return (textContent || "") + .replace(/[^\x20-\x7E]+|\s+/g, " ") // Strip out non-primitive characters and replace multiple spaces with a single space + .trim(); // Trim leading and trailing whitespace + } + + /** + * Get the text content from the previous siblings of the element. If + * no text content is found, recursively get the text content from the + * previous siblings of the parent element. + * @param {FormFieldElement} element + * @returns {string[]} + * @private + */ + private recursivelyGetTextFromPreviousSiblings(element: Node | HTMLElement): string[] { + const textContentItems: string[] = []; + let currentElement = element; + while (currentElement && currentElement.previousSibling) { + // Ensure we are capturing text content from nodes and elements. + currentElement = currentElement.previousSibling; + + if (this.isNewSectionElement(currentElement)) { + return textContentItems; + } + + const textContent = this.getTextContentFromElement(currentElement); + if (textContent) { + textContentItems.push(textContent); + } + } + + if (!currentElement || textContentItems.length) { + return textContentItems; + } + + // Prioritize capturing text content from elements rather than nodes. + currentElement = currentElement.parentElement || currentElement.parentNode; + + let siblingElement = + currentElement instanceof HTMLElement + ? currentElement.previousElementSibling + : currentElement.previousSibling; + while (siblingElement?.lastChild && !this.isNewSectionElement(siblingElement)) { + siblingElement = siblingElement.lastChild; + } + + if (this.isNewSectionElement(siblingElement)) { + return textContentItems; + } + + const textContent = this.getTextContentFromElement(siblingElement); + if (textContent) { + textContentItems.push(textContent); + return textContentItems; + } + + return this.recursivelyGetTextFromPreviousSiblings(siblingElement); + } + + /** + * Get the value of a property or attribute from a FormFieldElement. + * @param {HTMLElement} element + * @param {string} attributeName + * @returns {string | null} + * @private + */ + private getPropertyOrAttribute(element: HTMLElement, attributeName: string): string | null { + if (attributeName in element) { + return (element as FormElementWithAttribute)[attributeName]; + } + + return element.getAttribute(attributeName); + } + + /** + * Gets the value of the element. If the element is a checkbox, returns a checkmark if the + * checkbox is checked, or an empty string if it is not checked. If the element is a hidden + * input, returns the value of the input if it is less than 254 characters, or a truncated + * value if it is longer than 254 characters. + * @param {FormFieldElement} element + * @returns {string} + * @private + */ + private getElementValue(element: FormFieldElement): string { + if (element instanceof HTMLSpanElement) { + const spanTextContent = element.textContent || element.innerText; + return spanTextContent || ""; + } + + const elementValue = element.value || ""; + const elementType = String(element.type).toLowerCase(); + if ("checked" in element && elementType === "checkbox") { + return element.checked ? "✓" : ""; + } + + if (elementType === "hidden") { + const inputValueMaxLength = 254; + + return elementValue.length > inputValueMaxLength + ? `${elementValue.substring(0, inputValueMaxLength)}...SNIPPED` + : elementValue; + } + + return elementValue; + } + + /** + * Get the options from a select element and return them as an array + * of arrays indicating the select element option text and value. + * @param {HTMLSelectElement} element + * @returns {{options: (string | null)[][]}} + * @private + */ + private getSelectElementOptions(element: HTMLSelectElement): { options: (string | null)[][] } { + const options = [...element.options].map((option) => { + const optionText = option.text + ? String(option.text) + .toLowerCase() + .replace(/[\s~`!@$%^&#*()\-_+=:;'"[\]|\\,<.>?]/gm, "") // Remove whitespace and punctuation + : null; + + return [optionText, option.value]; + }); + + return { options }; + } +} + +export default CollectAutofillContentService; diff --git a/apps/browser/src/autofill/services/dom-element-visibility.service.spec.ts b/apps/browser/src/autofill/services/dom-element-visibility.service.spec.ts new file mode 100644 index 00000000000..e17783b7a65 --- /dev/null +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.spec.ts @@ -0,0 +1,409 @@ +import { FormFieldElement } from "../types"; + +import DomElementVisibilityService from "./dom-element-visibility.service"; + +function createBoundingClientRectMock(customProperties: Partial = {}): DOMRectReadOnly { + return { + top: 0, + bottom: 0, + left: 0, + right: 0, + width: 500, + height: 500, + x: 0, + y: 0, + toJSON: jest.fn(), + ...customProperties, + }; +} + +describe("DomElementVisibilityService", () => { + let domElementVisibilityService: DomElementVisibilityService; + + beforeEach(() => { + document.body.innerHTML = ` +
+ + + + +
+ `; + domElementVisibilityService = new DomElementVisibilityService(); + }); + + afterEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ""; + }); + + describe("isFormFieldViewable", () => { + it("returns false if the element is outside viewport bounds", async () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + jest.spyOn(usernameElement, "getBoundingClientRect"); + jest + .spyOn(domElementVisibilityService as any, "isElementOutsideViewportBounds") + .mockResolvedValueOnce(true); + jest.spyOn(domElementVisibilityService, "isElementHiddenByCss"); + jest.spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement"); + + const isFormFieldViewable = await domElementVisibilityService.isFormFieldViewable( + usernameElement + ); + + expect(isFormFieldViewable).toEqual(false); + expect(usernameElement.getBoundingClientRect).toHaveBeenCalled(); + expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith( + usernameElement, + usernameElement.getBoundingClientRect() + ); + expect(domElementVisibilityService["isElementHiddenByCss"]).not.toHaveBeenCalled(); + expect( + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"] + ).not.toHaveBeenCalled(); + }); + + it("returns false if the element is hidden by CSS", async () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + jest.spyOn(usernameElement, "getBoundingClientRect"); + jest + .spyOn(domElementVisibilityService as any, "isElementOutsideViewportBounds") + .mockReturnValueOnce(false); + jest.spyOn(domElementVisibilityService, "isElementHiddenByCss").mockReturnValueOnce(true); + jest.spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement"); + + const isFormFieldViewable = await domElementVisibilityService.isFormFieldViewable( + usernameElement + ); + + expect(isFormFieldViewable).toEqual(false); + expect(usernameElement.getBoundingClientRect).toHaveBeenCalled(); + expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith( + usernameElement, + usernameElement.getBoundingClientRect() + ); + expect(domElementVisibilityService["isElementHiddenByCss"]).toHaveBeenCalledWith( + usernameElement + ); + expect( + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"] + ).not.toHaveBeenCalled(); + }); + + it("returns false if the element is hidden behind another element", async () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + jest.spyOn(usernameElement, "getBoundingClientRect"); + jest + .spyOn(domElementVisibilityService as any, "isElementOutsideViewportBounds") + .mockReturnValueOnce(false); + jest.spyOn(domElementVisibilityService, "isElementHiddenByCss").mockReturnValueOnce(false); + jest + .spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement") + .mockReturnValueOnce(false); + + const isFormFieldViewable = await domElementVisibilityService.isFormFieldViewable( + usernameElement + ); + + expect(isFormFieldViewable).toEqual(false); + expect(usernameElement.getBoundingClientRect).toHaveBeenCalled(); + expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith( + usernameElement, + usernameElement.getBoundingClientRect() + ); + expect(domElementVisibilityService["isElementHiddenByCss"]).toHaveBeenCalledWith( + usernameElement + ); + expect( + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"] + ).toHaveBeenCalledWith(usernameElement, usernameElement.getBoundingClientRect()); + }); + + it("returns true if the form field is viewable", async () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + jest.spyOn(usernameElement, "getBoundingClientRect"); + jest + .spyOn(domElementVisibilityService as any, "isElementOutsideViewportBounds") + .mockReturnValueOnce(false); + jest.spyOn(domElementVisibilityService, "isElementHiddenByCss").mockReturnValueOnce(false); + jest + .spyOn(domElementVisibilityService as any, "formFieldIsNotHiddenBehindAnotherElement") + .mockReturnValueOnce(true); + + const isFormFieldViewable = await domElementVisibilityService.isFormFieldViewable( + usernameElement + ); + + expect(isFormFieldViewable).toEqual(true); + expect(usernameElement.getBoundingClientRect).toHaveBeenCalled(); + expect(domElementVisibilityService["isElementOutsideViewportBounds"]).toHaveBeenCalledWith( + usernameElement, + usernameElement.getBoundingClientRect() + ); + expect(domElementVisibilityService["isElementHiddenByCss"]).toHaveBeenCalledWith( + usernameElement + ); + expect( + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"] + ).toHaveBeenCalledWith(usernameElement, usernameElement.getBoundingClientRect()); + }); + }); + + describe("isElementHiddenByCss", () => { + it("returns true when a non-hidden element is passed", () => { + document.body.innerHTML = ` + + `; + const usernameElement = document.getElementById("username"); + + const isElementHidden = domElementVisibilityService["isElementHiddenByCss"](usernameElement); + + expect(isElementHidden).toEqual(false); + }); + + it("returns true when the element has a `visibility: hidden;` CSS rule applied to it either inline or in a computed style", () => { + document.body.innerHTML = ` + + + + `; + const usernameElement = document.getElementById("username"); + const passwordElement = document.getElementById("password"); + jest.spyOn(usernameElement.style, "getPropertyValue"); + jest.spyOn(usernameElement.ownerDocument.defaultView, "getComputedStyle"); + jest.spyOn(passwordElement.style, "getPropertyValue"); + jest.spyOn(passwordElement.ownerDocument.defaultView, "getComputedStyle"); + + const isUsernameElementHidden = + domElementVisibilityService["isElementHiddenByCss"](usernameElement); + const isPasswordElementHidden = + domElementVisibilityService["isElementHiddenByCss"](passwordElement); + + expect(isUsernameElementHidden).toEqual(true); + expect(usernameElement.style.getPropertyValue).toHaveBeenCalled(); + expect(usernameElement.ownerDocument.defaultView.getComputedStyle).toHaveBeenCalledWith( + usernameElement + ); + expect(isPasswordElementHidden).toEqual(true); + expect(passwordElement.style.getPropertyValue).toHaveBeenCalled(); + expect(passwordElement.ownerDocument.defaultView.getComputedStyle).toHaveBeenCalledWith( + passwordElement + ); + }); + + it("returns true when the element has a `display: none;` CSS rule applied to it either inline or in a computed style", () => { + document.body.innerHTML = ` + + + + `; + const usernameElement = document.getElementById("username"); + const passwordElement = document.getElementById("password"); + + const isUsernameElementHidden = + domElementVisibilityService["isElementHiddenByCss"](usernameElement); + const isPasswordElementHidden = + domElementVisibilityService["isElementHiddenByCss"](passwordElement); + + expect(isUsernameElementHidden).toEqual(true); + expect(isPasswordElementHidden).toEqual(true); + }); + + it("returns true when the element has a `opacity: 0;` CSS rule applied to it either inline or in a computed style", () => { + document.body.innerHTML = ` + + + + `; + const usernameElement = document.getElementById("username"); + const passwordElement = document.getElementById("password"); + + const isUsernameElementHidden = + domElementVisibilityService["isElementHiddenByCss"](usernameElement); + const isPasswordElementHidden = + domElementVisibilityService["isElementHiddenByCss"](passwordElement); + + expect(isUsernameElementHidden).toEqual(true); + expect(isPasswordElementHidden).toEqual(true); + }); + + it("returns true when the element has a `clip-path` CSS rule applied to it that hides the element either inline or in a computed style", () => { + document.body.innerHTML = ` + + + + + `; + }); + }); + + describe("isElementOutsideViewportBounds", () => { + const mockViewportWidth = 1920; + const mockViewportHeight = 1080; + + beforeEach(() => { + Object.defineProperty(document.documentElement, "scrollWidth", { + writable: true, + value: mockViewportWidth, + }); + Object.defineProperty(document.documentElement, "scrollHeight", { + writable: true, + value: mockViewportHeight, + }); + }); + + it("returns true if the passed element's size is not sufficient for visibility", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({ + width: 9, + height: 9, + }); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(true); + }); + + it("returns true if the passed element is overflowing the left viewport", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({ + left: -1, + }); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(true); + }); + + it("returns true if the passed element is overflowing the right viewport", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({ + left: mockViewportWidth + 1, + }); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(true); + }); + + it("returns true if the passed element is overflowing the top viewport", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({ + top: -1, + }); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(true); + }); + + it("returns true if the passed element is overflowing the bottom viewport", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({ + top: mockViewportHeight + 1, + }); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(true); + }); + + it("returns false if the passed element is not outside of the viewport bounds", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const elementBoundingClientRect = createBoundingClientRectMock({}); + + const isElementOutsideViewportBounds = domElementVisibilityService[ + "isElementOutsideViewportBounds" + ](usernameElement, elementBoundingClientRect); + + expect(isElementOutsideViewportBounds).toEqual(false); + }); + }); + + describe("formFieldIsNotHiddenBehindAnotherElement", () => { + it("returns true if the element found at the center point of the passed targetElement is the targetElement itself", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + jest.spyOn(usernameElement, "getBoundingClientRect"); + document.elementFromPoint = jest.fn(() => usernameElement); + + const formFieldIsNotHiddenBehindAnotherElement = + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"](usernameElement); + + expect(formFieldIsNotHiddenBehindAnotherElement).toEqual(true); + expect(document.elementFromPoint).toHaveBeenCalled(); + expect(usernameElement.getBoundingClientRect).toHaveBeenCalled(); + }); + + it("returns true if the element found at the center point of the passed targetElement is an implicit label of the element", () => { + document.body.innerHTML = ` + + `; + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const labelTextElement = document.querySelector("span"); + document.elementFromPoint = jest.fn(() => labelTextElement); + + const formFieldIsNotHiddenBehindAnotherElement = + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"](usernameElement); + + expect(formFieldIsNotHiddenBehindAnotherElement).toEqual(true); + }); + + it("returns true if the element found at the center point of the passed targetElement is a label of the targetElement", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + const labelElement = document.querySelector("label[for='username']") as FormFieldElement; + const mockBoundingRect = createBoundingClientRectMock({}); + jest.spyOn(usernameElement, "getBoundingClientRect"); + document.elementFromPoint = jest.fn(() => labelElement); + + const formFieldIsNotHiddenBehindAnotherElement = domElementVisibilityService[ + "formFieldIsNotHiddenBehindAnotherElement" + ](usernameElement, mockBoundingRect); + + expect(formFieldIsNotHiddenBehindAnotherElement).toEqual(true); + expect(document.elementFromPoint).toHaveBeenCalledWith( + mockBoundingRect.left + mockBoundingRect.width / 2, + mockBoundingRect.top + mockBoundingRect.height / 2 + ); + expect(usernameElement.getBoundingClientRect).not.toHaveBeenCalled(); + }); + + it("returns false if the element found at the center point is not the passed targetElement or a label of that element", () => { + const usernameElement = document.querySelector("input[name='username']") as FormFieldElement; + document.elementFromPoint = jest.fn(() => document.createElement("div")); + + const formFieldIsNotHiddenBehindAnotherElement = + domElementVisibilityService["formFieldIsNotHiddenBehindAnotherElement"](usernameElement); + + expect(formFieldIsNotHiddenBehindAnotherElement).toEqual(false); + }); + }); +}); diff --git a/apps/browser/src/autofill/services/dom-element-visibility.service.ts b/apps/browser/src/autofill/services/dom-element-visibility.service.ts new file mode 100644 index 00000000000..4be59d7f276 --- /dev/null +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.ts @@ -0,0 +1,199 @@ +import { FillableFormFieldElement, FormFieldElement } from "../types"; + +import { DomElementVisibilityService as domElementVisibilityServiceInterface } from "./abstractions/dom-element-visibility.service"; + +class DomElementVisibilityService implements domElementVisibilityServiceInterface { + private cachedComputedStyle: CSSStyleDeclaration | null = null; + + /** + * Checks if a form field is viewable. This is done by checking if the element is within the + * viewport bounds, not hidden by CSS, and not hidden behind another element. + * @param {FormFieldElement} element + * @returns {Promise} + */ + async isFormFieldViewable(element: FormFieldElement): Promise { + const elementBoundingClientRect = element.getBoundingClientRect(); + + if ( + this.isElementOutsideViewportBounds(element, elementBoundingClientRect) || + this.isElementHiddenByCss(element) + ) { + return false; + } + + return this.formFieldIsNotHiddenBehindAnotherElement(element, elementBoundingClientRect); + } + + /** + * Check if the target element is hidden using CSS. This is done by checking the opacity, display, + * visibility, and clip-path CSS properties of the element. We also check the opacity of all + * parent elements to ensure that the target element is not hidden by a parent element. + * @param {HTMLElement} element + * @returns {boolean} + * @public + */ + isElementHiddenByCss(element: HTMLElement): boolean { + this.cachedComputedStyle = null; + + if ( + this.isElementInvisible(element) || + this.isElementNotDisplayed(element) || + this.isElementNotVisible(element) || + this.isElementClipped(element) + ) { + return true; + } + + let parentElement = element.parentElement; + while (parentElement && parentElement !== element.ownerDocument.documentElement) { + this.cachedComputedStyle = null; + if (this.isElementInvisible(parentElement)) { + return true; + } + + parentElement = parentElement.parentElement; + } + + return false; + } + + /** + * Gets the computed style of a given element, will only calculate the computed + * style if the element's style has not been previously cached. + * @param {HTMLElement} element + * @param {string} styleProperty + * @returns {string} + * @private + */ + private getElementStyle(element: HTMLElement, styleProperty: string): string { + if (!this.cachedComputedStyle) { + this.cachedComputedStyle = (element.ownerDocument.defaultView || window).getComputedStyle( + element + ); + } + + return this.cachedComputedStyle.getPropertyValue(styleProperty); + } + + /** + * Checks if the opacity of the target element is less than 0.1. + * @param {HTMLElement} element + * @returns {boolean} + * @private + */ + private isElementInvisible(element: HTMLElement): boolean { + return parseFloat(this.getElementStyle(element, "opacity")) < 0.1; + } + + /** + * Checks if the target element has a display property of none. + * @param {HTMLElement} element + * @returns {boolean} + * @private + */ + private isElementNotDisplayed(element: HTMLElement): boolean { + return this.getElementStyle(element, "display") === "none"; + } + + /** + * Checks if the target element has a visibility property of hidden or collapse. + * @param {HTMLElement} element + * @returns {boolean} + * @private + */ + private isElementNotVisible(element: HTMLElement): boolean { + return new Set(["hidden", "collapse"]).has(this.getElementStyle(element, "visibility")); + } + + /** + * Checks if the target element has a clip-path property that hides the element. + * @param {HTMLElement} element + * @returns {boolean} + * @private + */ + private isElementClipped(element: HTMLElement): boolean { + return new Set([ + "inset(50%)", + "inset(100%)", + "circle(0)", + "circle(0px)", + "circle(0px at 50% 50%)", + "polygon(0 0, 0 0, 0 0, 0 0)", + "polygon(0px 0px, 0px 0px, 0px 0px, 0px 0px)", + ]).has(this.getElementStyle(element, "clipPath")); + } + + /** + * Checks if the target element is outside the viewport bounds. This is done by checking if the + * element is too small or is overflowing the viewport bounds. + * @param {HTMLElement} targetElement + * @param {DOMRectReadOnly | null} targetElementBoundingClientRect + * @returns {boolean} + * @private + */ + private isElementOutsideViewportBounds( + targetElement: HTMLElement, + targetElementBoundingClientRect: DOMRectReadOnly | null = null + ): boolean { + const documentElement = targetElement.ownerDocument.documentElement; + const documentElementWidth = documentElement.scrollWidth; + const documentElementHeight = documentElement.scrollHeight; + const elementBoundingClientRect = + targetElementBoundingClientRect || targetElement.getBoundingClientRect(); + const elementTopOffset = elementBoundingClientRect.top - documentElement.clientTop; + const elementLeftOffset = elementBoundingClientRect.left - documentElement.clientLeft; + + const isElementSizeInsufficient = + elementBoundingClientRect.width < 10 || elementBoundingClientRect.height < 10; + const isElementOverflowingLeftViewport = elementLeftOffset < 0; + const isElementOverflowingRightViewport = + elementLeftOffset + elementBoundingClientRect.width > documentElementWidth; + const isElementOverflowingTopViewport = elementTopOffset < 0; + const isElementOverflowingBottomViewport = + elementTopOffset + elementBoundingClientRect.height > documentElementHeight; + + return ( + isElementSizeInsufficient || + isElementOverflowingLeftViewport || + isElementOverflowingRightViewport || + isElementOverflowingTopViewport || + isElementOverflowingBottomViewport + ); + } + + /** + * Checks if a passed FormField is not hidden behind another element. This is done by + * checking if the element at the center point of the FormField is the FormField itself + * or one of its labels. + * @param {FormFieldElement} targetElement + * @param {DOMRectReadOnly | null} targetElementBoundingClientRect + * @returns {boolean} + * @private + */ + private formFieldIsNotHiddenBehindAnotherElement( + targetElement: FormFieldElement, + targetElementBoundingClientRect: DOMRectReadOnly | null = null + ): boolean { + const elementBoundingClientRect = + targetElementBoundingClientRect || targetElement.getBoundingClientRect(); + const elementAtCenterPoint = targetElement.ownerDocument.elementFromPoint( + elementBoundingClientRect.left + elementBoundingClientRect.width / 2, + elementBoundingClientRect.top + elementBoundingClientRect.height / 2 + ); + + if (elementAtCenterPoint === targetElement) { + return true; + } + + const targetElementLabelsSet = new Set((targetElement as FillableFormFieldElement).labels); + if (targetElementLabelsSet.has(elementAtCenterPoint as HTMLLabelElement)) { + return true; + } + + const closestParentLabel = elementAtCenterPoint?.parentElement?.closest("label"); + + return targetElementLabelsSet.has(closestParentLabel); + } +} + +export default DomElementVisibilityService; diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts new file mode 100644 index 00000000000..828d768ca25 --- /dev/null +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -0,0 +1,1047 @@ +import { EVENTS } from "../constants"; +import AutofillScript, { FillScript, FillScriptActions } from "../models/autofill-script"; +import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types"; + +import CollectAutofillContentService from "./collect-autofill-content.service"; +import DomElementVisibilityService from "./dom-element-visibility.service"; +import InsertAutofillContentService from "./insert-autofill-content.service"; + +const mockLoginForm = ` +
+
+ + +
+
+`; + +const eventsToTest = [ + EVENTS.CHANGE, + EVENTS.INPUT, + EVENTS.KEYDOWN, + EVENTS.KEYPRESS, + EVENTS.KEYUP, + "blur", + "click", + "focus", + "focusin", + "focusout", + "mousedown", + "paste", + "select", + "selectionchange", + "touchend", + "touchstart", +]; + +const initEventCount = Object.freeze( + eventsToTest.reduce( + (eventCounts, eventName) => ({ + ...eventCounts, + [eventName]: 0, + }), + {} + ) +); + +let confirmSpy: jest.SpyInstance; +let windowSpy: jest.SpyInstance; +let savedURLs: string[] | null = ["https://bitwarden.com"]; +function setMockWindowLocation({ + protocol, + hostname, +}: { + protocol: "http:" | "https:"; + hostname: string; +}) { + windowSpy.mockImplementation(() => ({ + location: { + protocol, + hostname, + }, + })); +} + +describe("InsertAutofillContentService", () => { + const domElementVisibilityService = new DomElementVisibilityService(); + const collectAutofillContentService = new CollectAutofillContentService( + domElementVisibilityService + ); + let insertAutofillContentService: InsertAutofillContentService; + let fillScript: AutofillScript; + + beforeEach(() => { + document.body.innerHTML = mockLoginForm; + confirmSpy = jest.spyOn(window, "confirm"); + windowSpy = jest.spyOn(window, "window", "get"); + insertAutofillContentService = new InsertAutofillContentService( + domElementVisibilityService, + collectAutofillContentService + ); + fillScript = { + script: [ + ["click_on_opid", "username"], + ["focus_by_opid", "username"], + ["fill_by_opid", "username", "test"], + ], + properties: { + delay_between_operations: 20, + }, + metadata: {}, + autosubmit: null, + savedUrls: ["https://bitwarden.com"], + untrustedIframe: false, + itemType: "login", + }; + }); + + afterEach(() => { + jest.resetAllMocks(); + windowSpy.mockRestore(); + confirmSpy.mockRestore(); + document.body.innerHTML = ""; + }); + + describe("fillForm", () => { + it("returns early if the passed fill script does not have a script property", () => { + fillScript.script = []; + jest.spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe"); + jest.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill"); + jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill"); + jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); + + insertAutofillContentService.fillForm(fillScript); + + expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).not.toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledInsecureUrlAutofill"] + ).not.toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledUntrustedIframeAutofill"] + ).not.toHaveBeenCalled(); + expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); + }); + + it("returns early if the script is filling within a sand boxed iframe", () => { + jest + .spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe") + .mockReturnValue(true); + jest.spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill"); + jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill"); + jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); + + insertAutofillContentService.fillForm(fillScript); + + expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledInsecureUrlAutofill"] + ).not.toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledUntrustedIframeAutofill"] + ).not.toHaveBeenCalled(); + expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); + }); + + it("returns early if the autofill is occurring on an insecure url and the user cancels the autofill", () => { + jest + .spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe") + .mockReturnValue(false); + jest + .spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill") + .mockReturnValue(true); + jest.spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill"); + jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); + + insertAutofillContentService.fillForm(fillScript); + + expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled(); + expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledUntrustedIframeAutofill"] + ).not.toHaveBeenCalled(); + expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); + }); + + it("returns early if the iframe is untrusted and the user cancelled the autofill", () => { + jest + .spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe") + .mockReturnValue(false); + jest + .spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill") + .mockReturnValue(false); + jest + .spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill") + .mockReturnValue(true); + jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); + + insertAutofillContentService.fillForm(fillScript); + + expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled(); + expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledUntrustedIframeAutofill"] + ).toHaveBeenCalled(); + expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); + }); + + it("runs the fill script action for all scripts found within the fill script", () => { + jest + .spyOn(insertAutofillContentService as any, "fillingWithinSandboxedIframe") + .mockReturnValue(false); + jest + .spyOn(insertAutofillContentService as any, "userCancelledInsecureUrlAutofill") + .mockReturnValue(false); + jest + .spyOn(insertAutofillContentService as any, "userCancelledUntrustedIframeAutofill") + .mockReturnValue(false); + jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); + + insertAutofillContentService.fillForm(fillScript); + + expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled(); + expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledUntrustedIframeAutofill"] + ).toHaveBeenCalled(); + expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenCalledTimes(3); + expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( + 1, + fillScript.script[0], + 0, + fillScript.script + ); + expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( + 2, + fillScript.script[1], + 1, + fillScript.script + ); + expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith( + 3, + fillScript.script[2], + 2, + fillScript.script + ); + }); + }); + + describe("fillingWithinSandboxedIframe", () => { + afterEach(() => { + Object.defineProperty(globalThis, "window", { + value: { frameElement: null }, + writable: true, + }); + }); + + it("returns false if the `self.origin` value is not null", () => { + const result = insertAutofillContentService["fillingWithinSandboxedIframe"](); + + expect(result).toBe(false); + expect(self.origin).not.toBeNull(); + }); + + it("returns true if the frameElement has a sandbox attribute", () => { + Object.defineProperty(globalThis, "window", { + value: { frameElement: { hasAttribute: jest.fn(() => true) } }, + writable: true, + }); + + const result = insertAutofillContentService["fillingWithinSandboxedIframe"](); + + expect(result).toBe(true); + }); + + it("returns true if the window location hostname is empty", () => { + setMockWindowLocation({ protocol: "http:", hostname: "" }); + + const result = insertAutofillContentService["fillingWithinSandboxedIframe"](); + + expect(result).toBe(true); + }); + }); + + describe("userCancelledInsecureUrlAutofill", () => { + const currentHostname = "bitwarden.com"; + + beforeEach(() => { + savedURLs = [`https://${currentHostname}`]; + }); + + describe("returns false if Autofill occurring...", () => { + it("when there are no saved URLs", () => { + savedURLs = []; + setMockWindowLocation({ protocol: "http:", hostname: currentHostname }); + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(userCancelledInsecureUrlAutofill).toBe(false); + + savedURLs = null; + + const userCancelledInsecureUrlAutofill2 = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill2).toBe(false); + }); + + it("on http page and saved URLs contain no https values", () => { + savedURLs = ["http://bitwarden.com"]; + setMockWindowLocation({ protocol: "http:", hostname: currentHostname }); + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(false); + }); + + it("on https page with saved https URL", () => { + setMockWindowLocation({ protocol: "https:", hostname: currentHostname }); + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(false); + }); + + it("on page with no password field", () => { + setMockWindowLocation({ protocol: "https:", hostname: currentHostname }); + + document.body.innerHTML = ` +
+
+ +
+
+ `; + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(false); + }); + + it("on http page with saved https URL and user approval", () => { + setMockWindowLocation({ protocol: "http:", hostname: currentHostname }); + confirmSpy.mockImplementation(jest.fn(() => true)); + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(false); + }); + }); + + it("returns true if Autofill occurring on http page with saved https URL and user disapproval", () => { + setMockWindowLocation({ protocol: "http:", hostname: currentHostname }); + confirmSpy.mockImplementation(jest.fn(() => false)); + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(true); + }); + + it("returns false if the vault item contains uris with both secure and insecure uris, but a insecure uri is being used on a insecure web page", () => { + setMockWindowLocation({ protocol: "http:", hostname: currentHostname }); + savedURLs = ["http://bitwarden.com", "https://some-other-uri.com"]; + + const userCancelledInsecureUrlAutofill = + insertAutofillContentService["userCancelledInsecureUrlAutofill"](savedURLs); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(userCancelledInsecureUrlAutofill).toBe(false); + }); + }); + + describe("userCancelledUntrustedIframeAutofill", () => { + it("returns false if Autofill occurring within a trusted iframe", () => { + fillScript.untrustedIframe = false; + + const result = + insertAutofillContentService["userCancelledUntrustedIframeAutofill"](fillScript); + + expect(result).toBe(false); + expect(confirmSpy).not.toHaveBeenCalled(); + }); + + it("returns false if Autofill occurring within an untrusted iframe and the user approves", () => { + fillScript.untrustedIframe = true; + confirmSpy.mockImplementation(jest.fn(() => true)); + + const result = + insertAutofillContentService["userCancelledUntrustedIframeAutofill"](fillScript); + + expect(result).toBe(false); + expect(confirmSpy).toHaveBeenCalled(); + }); + + it("returns true if Autofill occurring within an untrusted iframe and the user disapproves", () => { + fillScript.untrustedIframe = true; + confirmSpy.mockImplementation(jest.fn(() => false)); + + const result = + insertAutofillContentService["userCancelledUntrustedIframeAutofill"](fillScript); + + expect(result).toBe(true); + expect(confirmSpy).toHaveBeenCalled(); + }); + }); + + describe("runFillScriptAction", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + it("returns early if no opid is provided", () => { + const action = "fill_by_opid"; + const opid = ""; + const value = "value"; + const scriptAction: FillScript = [action, opid, value]; + jest.spyOn(insertAutofillContentService["autofillInsertActions"], action); + + insertAutofillContentService["runFillScriptAction"](scriptAction, 0); + jest.advanceTimersByTime(20); + + expect(insertAutofillContentService["autofillInsertActions"][action]).not.toHaveBeenCalled(); + }); + + describe("given a valid fill script action and opid", () => { + const fillScriptActions: FillScriptActions[] = [ + "fill_by_opid", + "click_on_opid", + "focus_by_opid", + ]; + fillScriptActions.forEach((action) => { + it(`triggers a ${action} action`, () => { + const opid = "opid"; + const value = "value"; + const scriptAction: FillScript = [action, opid, value]; + jest.spyOn(insertAutofillContentService["autofillInsertActions"], action); + + insertAutofillContentService["runFillScriptAction"](scriptAction, 0); + jest.advanceTimersByTime(20); + + expect( + insertAutofillContentService["autofillInsertActions"][action] + ).toHaveBeenCalledWith({ + opid, + value, + }); + }); + }); + }); + }); + + describe("handleFillFieldByOpidAction", () => { + it("finds the field element by opid and inserts the value into the field", () => { + const opid = "__1"; + const value = "value"; + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + textInput.opid = opid; + textInput.value = value; + jest.spyOn( + insertAutofillContentService["collectAutofillContentService"], + "getAutofillFieldElementByOpid" + ); + jest.spyOn(insertAutofillContentService as any, "insertValueIntoField"); + + insertAutofillContentService["handleFillFieldByOpidAction"](opid, value); + + expect( + insertAutofillContentService["collectAutofillContentService"].getAutofillFieldElementByOpid + ).toHaveBeenCalledWith(opid); + expect(insertAutofillContentService["insertValueIntoField"]).toHaveBeenCalledWith( + textInput, + value + ); + }); + }); + + describe("handleClickOnFieldByOpidAction", () => { + it("clicks on the elements targeted by the passed opid", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + textInput.opid = "__1"; + let clickEventCount = 0; + const expectedClickEventCount = 1; + const clickEventHandler: (handledEvent: Event) => void = (handledEvent) => { + const eventTarget = handledEvent.target as HTMLInputElement; + + if (eventTarget.id === "username") { + clickEventCount++; + } + }; + textInput.addEventListener("click", clickEventHandler); + jest.spyOn( + insertAutofillContentService["collectAutofillContentService"], + "getAutofillFieldElementByOpid" + ); + jest.spyOn(insertAutofillContentService as any, "triggerClickOnElement"); + + insertAutofillContentService["handleClickOnFieldByOpidAction"]("__1"); + + expect( + insertAutofillContentService["collectAutofillContentService"].getAutofillFieldElementByOpid + ).toBeCalledWith("__1"); + expect((insertAutofillContentService as any)["triggerClickOnElement"]).toHaveBeenCalledWith( + textInput + ); + expect(clickEventCount).toBe(expectedClickEventCount); + + textInput.removeEventListener("click", clickEventHandler); + }); + + it("should not trigger click when no suitable elements can be found", () => { + const textInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + let clickEventCount = 0; + const expectedClickEventCount = 0; + const clickEventHandler: (handledEvent: Event) => void = (handledEvent) => { + const eventTarget = handledEvent.target as HTMLInputElement; + + if (eventTarget.id === "username") { + clickEventCount++; + } + }; + textInput.addEventListener("click", clickEventHandler); + + insertAutofillContentService["handleClickOnFieldByOpidAction"]("__2"); + + expect(clickEventCount).toEqual(expectedClickEventCount); + + textInput.removeEventListener("click", clickEventHandler); + }); + }); + + describe("handleFocusOnFieldByOpidAction", () => { + it("simulates click and focus events on the element targeted by the passed opid", () => { + const targetInput = document.querySelector('input[type="text"]') as FormElementWithAttribute; + targetInput.opid = "__0"; + const elementEventCount: { [key: string]: number } = { + ...initEventCount, + }; + // Testing all the relevant events to ensure downstream side-effects are firing correctly + const expectedElementEventCount: { [key: string]: number } = { + ...initEventCount, + click: 1, + focus: 1, + focusin: 1, + }; + const eventHandlers: { [key: string]: EventListener } = {}; + eventsToTest.forEach((eventType) => { + eventHandlers[eventType] = (handledEvent) => { + elementEventCount[handledEvent.type]++; + }; + targetInput.addEventListener(eventType, eventHandlers[eventType]); + }); + jest.spyOn( + insertAutofillContentService["collectAutofillContentService"], + "getAutofillFieldElementByOpid" + ); + jest.spyOn( + insertAutofillContentService as any, + "simulateUserMouseClickAndFocusEventInteractions" + ); + + insertAutofillContentService["handleFocusOnFieldByOpidAction"]("__0"); + + expect( + insertAutofillContentService["collectAutofillContentService"].getAutofillFieldElementByOpid + ).toBeCalledWith("__0"); + expect( + insertAutofillContentService["simulateUserMouseClickAndFocusEventInteractions"] + ).toHaveBeenCalledWith(targetInput, true); + expect(elementEventCount).toEqual(expectedElementEventCount); + }); + }); + + describe("insertValueIntoField", () => { + it("returns early if an element is not provided", () => { + const value = "test"; + const element: FormFieldElement | null = null; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](element, value); + + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).not.toHaveBeenCalled(); + }); + + it("returns early if a value is not provided", () => { + const value = ""; + const element: FormFieldElement | null = document.querySelector('input[type="text"]'); + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](element, value); + + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).not.toHaveBeenCalled(); + }); + + it("will set the inner text of the element if a span element is passed", () => { + document.body.innerHTML = ``; + const value = "test"; + const element = document.getElementById("username") as FormFieldElement; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](element, value); + + expect(element.innerText).toBe(value); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).toHaveBeenCalledWith(element, expect.any(Function)); + }); + + it("will set the `checked` attribute of any passed checkbox or radio elements", () => { + document.body.innerHTML = ``; + const checkboxElement = document.getElementById("checkbox") as HTMLInputElement; + const radioElement = document.getElementById("radio") as HTMLInputElement; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + const possibleValues = ["true", "y", "1", "yes", "✓"]; + possibleValues.forEach((value) => { + insertAutofillContentService["insertValueIntoField"](checkboxElement, value); + insertAutofillContentService["insertValueIntoField"](radioElement, value); + + expect(checkboxElement.checked).toBe(true); + expect(radioElement.checked).toBe(true); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).toHaveBeenCalledWith(checkboxElement, expect.any(Function)); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).toHaveBeenCalledWith(radioElement, expect.any(Function)); + + checkboxElement.checked = false; + radioElement.checked = false; + }); + }); + + it("will set the `value` attribute of any passed input or textarea elements", () => { + document.body.innerHTML = ``; + const value1 = "test"; + const value2 = "test2"; + const textInputElement = document.getElementById("username") as HTMLInputElement; + textInputElement.value = value1; + const textareaElement = document.getElementById("bio") as HTMLTextAreaElement; + textareaElement.value = value2; + jest.spyOn(insertAutofillContentService as any, "handleInsertValueAndTriggerSimulatedEvents"); + + insertAutofillContentService["insertValueIntoField"](textInputElement, value1); + + expect(textInputElement.value).toBe(value1); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).toHaveBeenCalledWith(textInputElement, expect.any(Function)); + + insertAutofillContentService["insertValueIntoField"](textareaElement, value2); + + expect(textareaElement.value).toBe(value2); + expect( + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"] + ).toHaveBeenCalledWith(textareaElement, expect.any(Function)); + }); + }); + + describe("handleInsertValueAndTriggerSimulatedEvents", () => { + it("triggers pre- and post-insert events on the element while filling the value into the element", () => { + const value = "test"; + const element = document.querySelector('input[type="text"]') as FormFieldElement; + jest.spyOn(insertAutofillContentService as any, "triggerPreInsertEventsOnElement"); + jest.spyOn(insertAutofillContentService as any, "triggerPostInsertEventsOnElement"); + jest.spyOn(insertAutofillContentService as any, "triggerFillAnimationOnElement"); + const valueChangeCallback = jest.fn( + () => ((element as FillableFormFieldElement).value = value) + ); + + insertAutofillContentService["handleInsertValueAndTriggerSimulatedEvents"]( + element, + valueChangeCallback + ); + + expect(insertAutofillContentService["triggerPreInsertEventsOnElement"]).toHaveBeenCalledWith( + element + ); + expect(valueChangeCallback).toHaveBeenCalled(); + expect(insertAutofillContentService["triggerPostInsertEventsOnElement"]).toHaveBeenCalledWith( + element + ); + expect(insertAutofillContentService["triggerFillAnimationOnElement"]).toHaveBeenCalledWith( + element + ); + expect((element as FillableFormFieldElement).value).toBe(value); + }); + }); + + describe("triggerPreInsertEventsOnElement", () => { + it("triggers a simulated click and keyboard event on the element", () => { + const initialElementValue = "test"; + document.body.innerHTML = ``; + const element = document.getElementById("username") as FillableFormFieldElement; + jest.spyOn( + insertAutofillContentService as any, + "simulateUserMouseClickAndFocusEventInteractions" + ); + jest.spyOn(insertAutofillContentService as any, "simulateUserKeyboardEventInteractions"); + + insertAutofillContentService["triggerPreInsertEventsOnElement"](element); + + expect( + insertAutofillContentService["simulateUserMouseClickAndFocusEventInteractions"] + ).toHaveBeenCalledWith(element); + expect( + insertAutofillContentService["simulateUserKeyboardEventInteractions"] + ).toHaveBeenCalledWith(element); + expect(element.value).toBe(initialElementValue); + }); + }); + + describe("triggerPostInsertEventsOnElement", () => { + it("triggers simulated event interactions and blurs the element after", () => { + const elementValue = "test"; + document.body.innerHTML = ``; + const element = document.getElementById("username") as FillableFormFieldElement; + jest.spyOn(element, "blur"); + jest.spyOn(insertAutofillContentService as any, "simulateUserKeyboardEventInteractions"); + jest.spyOn(insertAutofillContentService as any, "simulateInputElementChangedEvent"); + + insertAutofillContentService["triggerPostInsertEventsOnElement"](element); + + expect( + insertAutofillContentService["simulateUserKeyboardEventInteractions"] + ).toHaveBeenCalledWith(element); + expect(insertAutofillContentService["simulateInputElementChangedEvent"]).toHaveBeenCalledWith( + element + ); + expect(element.blur).toHaveBeenCalled(); + expect(element.value).toBe(elementValue); + }); + }); + + describe("triggerFillAnimationOnElement", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllTimers(); + }); + + describe("will not trigger the animation when...", () => { + it("the element is a non-hidden hidden input type", async () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector( + 'input[type="hidden"]' + ) as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + await jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + + it("the element is a non-hidden textarea", () => { + document.body.innerHTML = mockLoginForm + ""; + const testElement = document.querySelector("textarea") as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + + it("the element is a unsupported tag", () => { + document.body.innerHTML = mockLoginForm + '
'; + const testElement = document.querySelector("#input-tag") as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + + it("the element has a `visibility: hidden;` CSS rule applied to it", () => { + const testElement = document.querySelector( + 'input[type="password"]' + ) as FillableFormFieldElement; + testElement.style.visibility = "hidden"; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + + it("the element has a `display: none;` CSS rule applied to it", () => { + const testElement = document.querySelector( + 'input[type="password"]' + ) as FillableFormFieldElement; + testElement.style.display = "none"; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + + it("a parent of the element has an `opacity: 0;` CSS rule applied to it", () => { + document.body.innerHTML = + mockLoginForm + '
'; + const testElement = document.querySelector( + 'input[type="email"]' + ) as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).not.toHaveBeenCalled(); + expect(testElement.classList.remove).not.toHaveBeenCalled(); + }); + }); + + describe("will trigger the animation when...", () => { + it("the element is a non-hidden password field", () => { + const testElement = document.querySelector( + 'input[type="password"]' + ) as FillableFormFieldElement; + jest.spyOn( + insertAutofillContentService["domElementVisibilityService"], + "isElementHiddenByCss" + ); + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect( + insertAutofillContentService["domElementVisibilityService"].isElementHiddenByCss + ).toHaveBeenCalledWith(testElement); + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden email input", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector( + 'input[type="email"]' + ) as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden text input", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector( + 'input[type="text"]' + ) as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden number input", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector( + 'input[type="number"]' + ) as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden tel input", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector('input[type="tel"]') as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden url input", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector('input[type="url"]') as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + + it("the element is a non-hidden span", () => { + document.body.innerHTML = mockLoginForm + ''; + const testElement = document.querySelector("#input-tag") as FillableFormFieldElement; + jest.spyOn(testElement.classList, "add"); + jest.spyOn(testElement.classList, "remove"); + + insertAutofillContentService["triggerFillAnimationOnElement"](testElement); + jest.advanceTimersByTime(200); + + expect(testElement.classList.add).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + expect(testElement.classList.remove).toHaveBeenCalledWith( + "com-bitwarden-browser-animated-fill" + ); + }); + }); + }); + + describe("triggerClickOnElement", () => { + it("will trigger a click event on the passed element", () => { + const inputElement = document.querySelector('input[type="text"]') as HTMLElement; + jest.spyOn(inputElement, "click"); + + insertAutofillContentService["triggerClickOnElement"](inputElement); + + expect(inputElement.click).toHaveBeenCalled(); + }); + }); + + describe("triggerFocusOnElement", () => { + it("will trigger a focus event on the passed element and attempt to reset the value", () => { + const value = "test"; + const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; + inputElement.value = "test"; + jest.spyOn(inputElement, "focus"); + jest.spyOn(window, "String"); + + insertAutofillContentService["triggerFocusOnElement"](inputElement, true); + + expect(window.String).toHaveBeenCalledWith(value); + expect(inputElement.focus).toHaveBeenCalled(); + expect(inputElement.value).toEqual(value); + }); + + it("will not attempt to reset the value but will still focus the element", () => { + const value = "test"; + const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; + inputElement.value = "test"; + jest.spyOn(inputElement, "focus"); + jest.spyOn(window, "String"); + + insertAutofillContentService["triggerFocusOnElement"](inputElement, false); + + expect(window.String).not.toHaveBeenCalledWith(); + expect(inputElement.focus).toHaveBeenCalled(); + expect(inputElement.value).toEqual(value); + }); + }); + + describe("simulateUserMouseClickAndFocusEventInteractions", () => { + it("will trigger click and focus events on the passed element", () => { + const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; + jest.spyOn(insertAutofillContentService as any, "triggerClickOnElement"); + jest.spyOn(insertAutofillContentService as any, "triggerFocusOnElement"); + + insertAutofillContentService["simulateUserMouseClickAndFocusEventInteractions"](inputElement); + + expect(insertAutofillContentService["triggerClickOnElement"]).toHaveBeenCalledWith( + inputElement + ); + expect(insertAutofillContentService["triggerFocusOnElement"]).toHaveBeenCalledWith( + inputElement, + false + ); + }); + }); + + describe("simulateUserKeyboardEventInteractions", () => { + it("will trigger `keydown`, `keypress`, and `keyup` events on the passed element", () => { + const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; + jest.spyOn(inputElement, "dispatchEvent"); + + insertAutofillContentService["simulateUserKeyboardEventInteractions"](inputElement); + + [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP].forEach((eventName) => { + expect(inputElement.dispatchEvent).toHaveBeenCalledWith( + new KeyboardEvent(eventName, { bubbles: true }) + ); + }); + }); + }); + + describe("simulateInputElementChangedEvent", () => { + it("will trigger `input` and `change` events on the passed element", () => { + const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement; + jest.spyOn(inputElement, "dispatchEvent"); + + insertAutofillContentService["simulateInputElementChangedEvent"](inputElement); + + [EVENTS.INPUT, EVENTS.CHANGE].forEach((eventName) => { + expect(inputElement.dispatchEvent).toHaveBeenCalledWith( + new Event(eventName, { bubbles: true }) + ); + }); + }); + }); +}); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts new file mode 100644 index 00000000000..89f644ba6be --- /dev/null +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -0,0 +1,349 @@ +import { EVENTS, TYPE_CHECK } from "../constants"; +import AutofillScript, { AutofillInsertActions, FillScript } from "../models/autofill-script"; +import { FormFieldElement } from "../types"; + +import { InsertAutofillContentService as InsertAutofillContentServiceInterface } from "./abstractions/insert-autofill-content.service"; +import CollectAutofillContentService from "./collect-autofill-content.service"; +import DomElementVisibilityService from "./dom-element-visibility.service"; + +class InsertAutofillContentService implements InsertAutofillContentServiceInterface { + private readonly domElementVisibilityService: DomElementVisibilityService; + private readonly collectAutofillContentService: CollectAutofillContentService; + private readonly autofillInsertActions: AutofillInsertActions = { + fill_by_opid: ({ opid, value }) => this.handleFillFieldByOpidAction(opid, value), + click_on_opid: ({ opid }) => this.handleClickOnFieldByOpidAction(opid), + focus_by_opid: ({ opid }) => this.handleFocusOnFieldByOpidAction(opid), + }; + + /** + * InsertAutofillContentService constructor. Instantiates the + * DomElementVisibilityService and CollectAutofillContentService classes. + */ + constructor( + domElementVisibilityService: DomElementVisibilityService, + collectAutofillContentService: CollectAutofillContentService + ) { + this.domElementVisibilityService = domElementVisibilityService; + this.collectAutofillContentService = collectAutofillContentService; + } + + /** + * Handles autofill of the forms on the current page based on the + * data within the passed fill script object. + * @param {AutofillScript} fillScript + * @public + */ + fillForm(fillScript: AutofillScript) { + if ( + !fillScript.script?.length || + this.fillingWithinSandboxedIframe() || + this.userCancelledInsecureUrlAutofill(fillScript.savedUrls) || + this.userCancelledUntrustedIframeAutofill(fillScript) + ) { + return; + } + + fillScript.script.forEach(this.runFillScriptAction); + } + + /** + * Identifies if the execution of this script is happening + * within a sandboxed iframe. + * @returns {boolean} + * @private + */ + private fillingWithinSandboxedIframe() { + return ( + String(self.origin).toLowerCase() === "null" || + window.frameElement?.hasAttribute("sandbox") || + window.location.hostname === "" + ); + } + + /** + * Checks if the autofill is occurring on a page that can be considered secure. If the page is not secure, + * the user is prompted to confirm that they want to autofill on the page. + * @param {string[] | null} savedUrls + * @returns {boolean} + * @private + */ + private userCancelledInsecureUrlAutofill(savedUrls?: string[] | null): boolean { + if ( + !savedUrls?.some((url) => url.startsWith(`https://${window.location.hostname}`)) || + window.location.protocol !== "http:" || + !document.querySelectorAll("input[type=password]")?.length + ) { + return false; + } + + const confirmationWarning = [ + chrome.i18n.getMessage("insecurePageWarning"), + chrome.i18n.getMessage("insecurePageWarningFillPrompt", [window.location.hostname]), + ].join("\n\n"); + + return !confirm(confirmationWarning); + } + + /** + * Checking if the autofill is occurring within an untrusted iframe. If the page is within an untrusted iframe, + * the user is prompted to confirm that they want to autofill on the page. If the user cancels the autofill, + * the script will not continue. + * + * Note: confirm() is blocked by sandboxed iframes, but we don't want to fill sandboxed iframes anyway. + * If this occurs, confirm() returns false without displaying the dialog box, and autofill will be aborted. + * The browser may print a message to the console, but this is not a standard error that we can handle. + * @param {AutofillScript} fillScript + * @returns {boolean} + * @private + */ + private userCancelledUntrustedIframeAutofill(fillScript: AutofillScript): boolean { + if (!fillScript.untrustedIframe) { + return false; + } + + const confirmationWarning = [ + chrome.i18n.getMessage("autofillIframeWarning"), + chrome.i18n.getMessage("autofillIframeWarningTip", [window.location.hostname]), + ].join("\n\n"); + + return !confirm(confirmationWarning); + } + + /** + * Runs the autofill action based on the action type and the opid. + * Each action is subsequently delayed by 20 milliseconds. + * @param {FillScriptActions} action + * @param {string} opid + * @param {string} value + * @param {number} actionIndex + */ + private runFillScriptAction = ([action, opid, value]: FillScript, actionIndex: number): void => { + if (!opid || !this.autofillInsertActions[action]) { + return; + } + + const delayActionsInMilliseconds = 20; + setTimeout( + () => this.autofillInsertActions[action]({ opid, value }), + delayActionsInMilliseconds * actionIndex + ); + }; + + /** + * Queries the DOM for an element by opid and inserts the passed value into the element. + * @param {string} opid + * @param {string} value + * @private + */ + private handleFillFieldByOpidAction(opid: string, value: string) { + const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid); + this.insertValueIntoField(element, value); + } + + /** + * Handles finding an element by opid and triggering a click event on the element. + * @param {string} opid + * @private + */ + private handleClickOnFieldByOpidAction(opid: string) { + const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid); + this.triggerClickOnElement(element); + } + + /** + * Handles finding an element by opid and triggering click and focus events on the element. + * @param {string} opid + * @private + */ + private handleFocusOnFieldByOpidAction(opid: string) { + const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid); + this.simulateUserMouseClickAndFocusEventInteractions(element, true); + } + + /** + * Identifies the type of element passed and inserts the value into the element. + * Will trigger simulated events on the element to ensure that the element is + * properly updated. + * @param {FormFieldElement | null} element + * @param {string} value + * @private + */ + private insertValueIntoField(element: FormFieldElement | null, value: string) { + const elementCanBeReadonly = + element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement; + const elementCanBeFilled = elementCanBeReadonly || element instanceof HTMLSelectElement; + + if ( + !element || + !value || + (elementCanBeReadonly && element.readOnly) || + (elementCanBeFilled && element.disabled) + ) { + return; + } + + if (element instanceof HTMLSpanElement) { + this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.innerText = value)); + return; + } + + const isFillableCheckboxOrRadioElement = + element instanceof HTMLInputElement && + new Set(["checkbox", "radio"]).has(element.type) && + new Set(["true", "y", "1", "yes", "✓"]).has(String(value).toLowerCase()); + if (isFillableCheckboxOrRadioElement) { + this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.checked = true)); + return; + } + + this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.value = value)); + } + + /** + * Simulates pre- and post-insert events on the element meant to mimic user interactions + * while inserting the autofill value into the element. + * @param {FormFieldElement} element + * @param {Function} valueChangeCallback + * @private + */ + private handleInsertValueAndTriggerSimulatedEvents( + element: FormFieldElement, + valueChangeCallback: CallableFunction + ): void { + this.triggerPreInsertEventsOnElement(element); + valueChangeCallback(); + this.triggerPostInsertEventsOnElement(element); + this.triggerFillAnimationOnElement(element); + } + + /** + * Simulates a mouse click event on the element, including focusing the event, and + * the triggers a simulated keyboard event on the element. Will attempt to ensure + * that the initial element value is not arbitrarily changed by the simulated events. + * @param {FormFieldElement} element + * @private + */ + private triggerPreInsertEventsOnElement(element: FormFieldElement): void { + const initialElementValue = "value" in element ? element.value : ""; + + this.simulateUserMouseClickAndFocusEventInteractions(element); + this.simulateUserKeyboardEventInteractions(element); + + if ("value" in element && initialElementValue !== element.value) { + element.value = initialElementValue; + } + } + + /** + * Simulates a keyboard event on the element before assigning the autofilled value to the element, and then + * simulates an input change event on the element to trigger expected events after autofill occurs. + * @param {FormFieldElement} element + * @private + */ + private triggerPostInsertEventsOnElement(element: FormFieldElement): void { + const autofilledValue = "value" in element ? element.value : ""; + this.simulateUserKeyboardEventInteractions(element); + + if ("value" in element && autofilledValue !== element.value) { + element.value = autofilledValue; + } + + this.simulateInputElementChangedEvent(element); + element.blur(); + } + + /** + * Identifies if a passed element can be animated and sets a class on the element + * to trigger a CSS animation. The animation is removed after a short delay. + * @param {FormFieldElement} element + * @private + */ + private triggerFillAnimationOnElement(element: FormFieldElement): void { + const skipAnimatingElement = + !(element instanceof HTMLSpanElement) && + !new Set(["email", "text", "password", "number", "tel", "url"]).has(element?.type); + + if (this.domElementVisibilityService.isElementHiddenByCss(element) || skipAnimatingElement) { + return; + } + + element.classList.add("com-bitwarden-browser-animated-fill"); + setTimeout(() => element.classList.remove("com-bitwarden-browser-animated-fill"), 200); + } + + /** + * Simulates a click event on the element. + * @param {HTMLElement} element + * @private + */ + private triggerClickOnElement(element?: HTMLElement): void { + if (typeof element?.click !== TYPE_CHECK.FUNCTION) { + return; + } + + element.click(); + } + + /** + * Simulates a focus event on the element. Will optionally reset the value of the element + * if the element has a value property. + * @param {HTMLElement | undefined} element + * @param {boolean} shouldResetValue + * @private + */ + private triggerFocusOnElement(element: HTMLElement | undefined, shouldResetValue = false): void { + if (typeof element?.focus !== TYPE_CHECK.FUNCTION) { + return; + } + + let initialValue = ""; + if (shouldResetValue && "value" in element) { + initialValue = String(element.value); + } + + element.focus(); + + if (initialValue && "value" in element) { + element.value = initialValue; + } + } + + /** + * Simulates a mouse click and focus event on the element. + * @param {FormFieldElement} element + * @param {boolean} shouldResetValue + * @private + */ + private simulateUserMouseClickAndFocusEventInteractions( + element: FormFieldElement, + shouldResetValue = false + ): void { + this.triggerClickOnElement(element); + this.triggerFocusOnElement(element, shouldResetValue); + } + + /** + * Simulates several keyboard events on the element, mocking a user interaction with the element. + * @param {FormFieldElement} element + * @private + */ + private simulateUserKeyboardEventInteractions(element: FormFieldElement): void { + [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP].forEach((eventType) => + element.dispatchEvent(new KeyboardEvent(eventType, { bubbles: true })) + ); + } + + /** + * Simulates an input change event on the element, mocking behavior that would occur if a user + * manually changed a value for the element. + * @param {FormFieldElement} element + * @private + */ + private simulateInputElementChangedEvent(element: FormFieldElement): void { + [EVENTS.INPUT, EVENTS.CHANGE].forEach((eventType) => + element.dispatchEvent(new Event(eventType, { bubbles: true })) + ); + } +} + +export default InsertAutofillContentService; diff --git a/apps/browser/src/autofill/types/index.ts b/apps/browser/src/autofill/types/index.ts index d6891325353..8bab87709d2 100644 --- a/apps/browser/src/autofill/types/index.ts +++ b/apps/browser/src/autofill/types/index.ts @@ -39,3 +39,22 @@ export type UserSettings = { vaultTimeout: number; vaultTimeoutAction: VaultTimeoutAction; }; + +/** + * A HTMLElement (usually a form element) with additional custom properties added by this script + */ +export type ElementWithOpId = T & { + opid: string; +}; + +/** + * A Form Element that we can set a value on (fill) + */ +export type FillableFormFieldElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; + +/** + * The autofill script's definition of a Form Element (only a subset of HTML form elements) + */ +export type FormFieldElement = FillableFormFieldElement | HTMLSpanElement; + +export type FormElementWithAttribute = FormFieldElement & Record; diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 75734fcdad4..754279017b7 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -35,7 +35,6 @@ import { UserVerificationApiService } from "@bitwarden/common/auth/services/user import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -53,14 +52,12 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; -import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; -import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; import { SystemService } from "@bitwarden/common/platform/services/system.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; @@ -129,6 +126,7 @@ import { flagEnabled } from "../platform/flags"; import { UpdateBadge } from "../platform/listeners/update-badge"; import BrowserPopoutWindowService from "../platform/popup/browser-popout-window.service"; import { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service"; +import { BrowserConfigService } from "../platform/services/browser-config.service"; import { BrowserCryptoService } from "../platform/services/browser-crypto.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import { BrowserI18nService } from "../platform/services/browser-i18n.service"; @@ -185,7 +183,6 @@ export default class MainBackground { searchService: SearchServiceAbstraction; notificationsService: NotificationsServiceAbstraction; stateService: StateServiceAbstraction; - stateMigrationService: StateMigrationService; systemService: SystemServiceAbstraction; eventCollectionService: EventCollectionServiceAbstraction; eventUploadService: EventUploadServiceAbstraction; @@ -212,7 +209,7 @@ export default class MainBackground { avatarUpdateService: AvatarUpdateServiceAbstraction; mainContextMenuHandler: MainContextMenuHandler; cipherContextMenuHandler: CipherContextMenuHandler; - configService: ConfigServiceAbstraction; + configService: BrowserConfigService; configApiService: ConfigApiServiceAbstraction; devicesApiService: DevicesApiServiceAbstraction; devicesService: DevicesServiceAbstraction; @@ -274,17 +271,11 @@ export default class MainBackground { new KeyGenerationService(this.cryptoFunctionService) ) : new MemoryStorageService(); - this.stateMigrationService = new StateMigrationService( - this.storageService, - this.secureStorageService, - new StateFactory(GlobalState, Account) - ); this.stateService = new BrowserStateService( this.storageService, this.secureStorageService, this.memoryStorageService, this.logService, - this.stateMigrationService, new StateFactory(GlobalState, Account) ); this.platformUtilsService = new BrowserPlatformUtilsService( @@ -552,15 +543,16 @@ export default class MainBackground { this.authService, this.messagingService ); + this.configApiService = new ConfigApiService(this.apiService, this.authService); - this.configService = new ConfigService( + this.configService = new BrowserConfigService( this.stateService, this.configApiService, this.authService, - this.environmentService + this.environmentService, + true ); - this.browserPopoutWindowService = new BrowserPopoutWindowService(); this.popupUtilsService = new PopupUtilsService(this.isPrivateMode); @@ -663,7 +655,8 @@ export default class MainBackground { this.authService, this.cipherService, this.totpService, - this.eventCollectionService + this.eventCollectionService, + this.userVerificationService ); this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler); @@ -698,8 +691,7 @@ export default class MainBackground { this.cipherContextMenuHandler = new CipherContextMenuHandler( this.mainContextMenuHandler, this.authService, - this.cipherService, - this.userVerificationService + this.cipherService ); } } @@ -716,6 +708,7 @@ export default class MainBackground { await this.notificationBackground.init(); await this.commandsBackground.init(); + this.configService.init(); this.twoFactorService.init(); await this.tabsBackground.init(); diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index f8754798241..2abd4662d1a 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -1,4 +1,5 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -120,7 +121,7 @@ export default class RuntimeBackground { await this.main.refreshMenu(); }, 2000); this.main.avatarUpdateService.loadColorFromState(); - this.configService.fetchServerConfig(); + this.configService.triggerServerConfigFetch(); } break; case "openPopup": @@ -153,6 +154,12 @@ export default class RuntimeBackground { BrowserApi.closeBitwardenExtensionTab(); }, msg.delay ?? 0); break; + case "triggerAutofillScriptInjection": + await this.autofillService.injectAutofillScripts( + sender, + await this.configService.getFeatureFlag(FeatureFlag.AutofillV2) + ); + break; case "bgCollectPageDetails": await this.main.collectPageDetailsForContentScript(sender.tab, msg.sender, sender.frameId); break; diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index f295cb4bd3e..a0a9548c7f3 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": "__MSG_appName__", - "version": "2023.8.2", + "version": "2023.8.3", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", @@ -17,13 +17,7 @@ "content_scripts": [ { "all_frames": true, - "js": [ - "content/autofill.js", - "content/autofiller.js", - "content/notificationBar.js", - "content/contextMenuHandler.js", - "content/fido2/content-script.js" - ], + "js": ["content/trigger-autofill-script-injection.js", "content/fido2/content-script.js"], "matches": ["http://*/*", "https://*/*", "file:///*"], "run_at": "document_start" }, diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index f459c20c171..af8700066f5 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": "__MSG_appName__", - "version": "2023.8.2", + "version": "2023.8.3", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/background/service-factories/state-migration-service.factory.ts b/apps/browser/src/platform/background/service-factories/state-migration-service.factory.ts deleted file mode 100644 index 8d4ee969583..00000000000 --- a/apps/browser/src/platform/background/service-factories/state-migration-service.factory.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; -import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; -import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; - -import { Account } from "../../../models/account"; - -import { CachedServices, factory, FactoryOptions } from "./factory-options"; -import { - diskStorageServiceFactory, - DiskStorageServiceInitOptions, - secureStorageServiceFactory, - SecureStorageServiceInitOptions, -} from "./storage-service.factory"; - -type StateMigrationServiceFactoryOptions = FactoryOptions & { - stateMigrationServiceOptions: { - stateFactory: StateFactory; - }; -}; - -export type StateMigrationServiceInitOptions = StateMigrationServiceFactoryOptions & - DiskStorageServiceInitOptions & - SecureStorageServiceInitOptions; - -export function stateMigrationServiceFactory( - cache: { stateMigrationService?: StateMigrationService } & CachedServices, - opts: StateMigrationServiceInitOptions -): Promise { - return factory( - cache, - "stateMigrationService", - opts, - async () => - new StateMigrationService( - await diskStorageServiceFactory(cache, opts), - await secureStorageServiceFactory(cache, opts), - opts.stateMigrationServiceOptions.stateFactory - ) - ); -} diff --git a/apps/browser/src/platform/background/service-factories/state-service.factory.ts b/apps/browser/src/platform/background/service-factories/state-service.factory.ts index f926d428890..7d3aaf9b6f3 100644 --- a/apps/browser/src/platform/background/service-factories/state-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/state-service.factory.ts @@ -6,10 +6,6 @@ import { BrowserStateService } from "../../services/browser-state.service"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; -import { - stateMigrationServiceFactory, - StateMigrationServiceInitOptions, -} from "./state-migration-service.factory"; import { diskStorageServiceFactory, secureStorageServiceFactory, @@ -30,8 +26,7 @@ export type StateServiceInitOptions = StateServiceFactoryOptions & DiskStorageServiceInitOptions & SecureStorageServiceInitOptions & MemoryStorageServiceInitOptions & - LogServiceInitOptions & - StateMigrationServiceInitOptions; + LogServiceInitOptions; export async function stateServiceFactory( cache: { stateService?: BrowserStateService } & CachedServices, @@ -47,7 +42,6 @@ export async function stateServiceFactory( await secureStorageServiceFactory(cache, opts), await memoryStorageServiceFactory(cache, opts), await logServiceFactory(cache, opts), - await stateMigrationServiceFactory(cache, opts), opts.stateServiceOptions.stateFactory, opts.stateServiceOptions.useAccountCache ) diff --git a/apps/browser/src/platform/browser/browser-api.spec.ts b/apps/browser/src/platform/browser/browser-api.spec.ts new file mode 100644 index 00000000000..af9e633a7f1 --- /dev/null +++ b/apps/browser/src/platform/browser/browser-api.spec.ts @@ -0,0 +1,56 @@ +import { mock } from "jest-mock-extended"; + +import { BrowserApi } from "./browser-api"; + +describe("BrowserApi", () => { + const executeScriptResult = ["value"]; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("executeScriptInTab", () => { + it("calls to the extension api to execute a script within the give tabId", async () => { + const tabId = 1; + const injectDetails = mock(); + jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2); + (chrome.tabs.executeScript as jest.Mock).mockImplementation( + (tabId, injectDetails, callback) => callback(executeScriptResult) + ); + + const result = await BrowserApi.executeScriptInTab(tabId, injectDetails); + + expect(chrome.tabs.executeScript).toHaveBeenCalledWith( + tabId, + injectDetails, + expect.any(Function) + ); + expect(result).toEqual(executeScriptResult); + }); + + it("calls the manifest v3 scripting API if the extension manifest is for v3", async () => { + const tabId = 1; + const injectDetails = mock({ + file: "file.js", + allFrames: true, + runAt: "document_start", + frameId: null, + }); + jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3); + (chrome.scripting.executeScript as jest.Mock).mockResolvedValue(executeScriptResult); + + const result = await BrowserApi.executeScriptInTab(tabId, injectDetails); + + expect(chrome.scripting.executeScript).toHaveBeenCalledWith({ + target: { + tabId: tabId, + allFrames: injectDetails.allFrames, + frameIds: null, + }, + files: [injectDetails.file], + injectImmediately: true, + }); + expect(result).toEqual(executeScriptResult); + }); + }); +}); diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index efe4d3fb3ee..823f6d3932e 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -341,4 +341,31 @@ export class BrowserApi { } return win.opr?.sidebarAction || browser.sidebarAction; } + + /** + * Extension API helper method used to execute a script in a tab. + * @see https://developer.chrome.com/docs/extensions/reference/tabs/#method-executeScript + * @param {number} tabId + * @param {chrome.tabs.InjectDetails} details + * @returns {Promise} + */ + static executeScriptInTab(tabId: number, details: chrome.tabs.InjectDetails) { + if (BrowserApi.manifestVersion === 3) { + return chrome.scripting.executeScript({ + target: { + tabId: tabId, + allFrames: details.allFrames, + frameIds: details.frameId ? [details.frameId] : null, + }, + files: details.file ? [details.file] : null, + injectImmediately: details.runAt === "document_start", + }); + } + + return new Promise((resolve) => { + chrome.tabs.executeScript(tabId, details, (result) => { + resolve(result); + }); + }); + } } diff --git a/apps/browser/src/platform/listeners/on-command-listener.ts b/apps/browser/src/platform/listeners/on-command-listener.ts index 65af31e173c..0e2cf03828d 100644 --- a/apps/browser/src/platform/listeners/on-command-listener.ts +++ b/apps/browser/src/platform/listeners/on-command-listener.ts @@ -47,9 +47,6 @@ const doAutoFillLogin = async (tab: chrome.tabs.Tab): Promise => { stateServiceOptions: { stateFactory: new StateFactory(GlobalState, Account), }, - stateMigrationServiceOptions: { - stateFactory: new StateFactory(GlobalState, Account), - }, apiServiceOptions: { logoutCallback: () => Promise.resolve(), }, @@ -94,9 +91,6 @@ const doGeneratePasswordToClipboard = async (tab: chrome.tabs.Tab): Promise Promise.resolve(), win: self, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, diff --git a/apps/browser/src/platform/listeners/on-install-listener.ts b/apps/browser/src/platform/listeners/on-install-listener.ts index 480e811fd26..0394941e283 100644 --- a/apps/browser/src/platform/listeners/on-install-listener.ts +++ b/apps/browser/src/platform/listeners/on-install-listener.ts @@ -23,9 +23,6 @@ export async function onInstallListener(details: chrome.runtime.InstalledDetails stateServiceOptions: { stateFactory: new StateFactory(GlobalState, Account), }, - stateMigrationServiceOptions: { - stateFactory: new StateFactory(GlobalState, Account), - }, }; const environmentService = await environmentServiceFactory(cache, opts); diff --git a/apps/browser/src/platform/listeners/update-badge.ts b/apps/browser/src/platform/listeners/update-badge.ts index 89b620ad6fe..1b692eb9b97 100644 --- a/apps/browser/src/platform/listeners/update-badge.ts +++ b/apps/browser/src/platform/listeners/update-badge.ts @@ -272,9 +272,6 @@ export class UpdateBadge { stateServiceOptions: { stateFactory: new StateFactory(GlobalState, Account), }, - stateMigrationServiceOptions: { - stateFactory: new StateFactory(GlobalState, Account), - }, apiServiceOptions: { logoutCallback: () => Promise.reject("not implemented"), }, diff --git a/apps/browser/src/platform/popup/browser-popout-window.service.ts b/apps/browser/src/platform/popup/browser-popout-window.service.ts index 95be15cc20d..ee03e3a2ec4 100644 --- a/apps/browser/src/platform/popup/browser-popout-window.service.ts +++ b/apps/browser/src/platform/popup/browser-popout-window.service.ts @@ -12,7 +12,6 @@ class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface { }; async openUnlockPrompt(senderWindowId: number) { - await this.closeUnlockPrompt(); await this.openSingleActionPopout( senderWindowId, "popup/index.html?uilocation=popout", @@ -36,8 +35,6 @@ class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface { action: string; } ) { - await this.closePasswordRepromptPrompt(); - const promptWindowPath = "popup/index.html#/view-cipher" + "?uilocation=popout" + @@ -73,18 +70,16 @@ class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface { const popupWindow = await BrowserApi.createWindow(windowOptions); - if (!singleActionPopoutKey) { - return; - } + await this.closeSingleActionPopout(singleActionPopoutKey); this.singleActionPopoutTabIds[singleActionPopoutKey] = popupWindow?.tabs[0].id; } private async closeSingleActionPopout(popoutKey: string) { const tabId = this.singleActionPopoutTabIds[popoutKey]; - if (!tabId) { - return; + + if (tabId) { + await BrowserApi.removeTab(tabId); } - await BrowserApi.removeTab(tabId); this.singleActionPopoutTabIds[popoutKey] = null; } } diff --git a/apps/browser/src/platform/services/browser-config.service.ts b/apps/browser/src/platform/services/browser-config.service.ts index 68237b4c206..f928fdd0726 100644 --- a/apps/browser/src/platform/services/browser-config.service.ts +++ b/apps/browser/src/platform/services/browser-config.service.ts @@ -1,6 +1,10 @@ -import { BehaviorSubject } from "rxjs"; +import { ReplaySubject } from "rxjs"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { browserSession, sessionSync } from "../decorators/session-sync-observable"; @@ -8,5 +12,15 @@ import { browserSession, sessionSync } from "../decorators/session-sync-observab @browserSession export class BrowserConfigService extends ConfigService { @sessionSync({ initializer: ServerConfig.fromJSON }) - protected _serverConfig: BehaviorSubject; + protected _serverConfig: ReplaySubject; + + constructor( + stateService: StateService, + configApiService: ConfigApiServiceAbstraction, + authService: AuthService, + environmentService: EnvironmentService, + subscribe = false + ) { + super(stateService, configApiService, authService, environmentService, subscribe); + } } diff --git a/apps/browser/src/platform/services/browser-state.service.spec.ts b/apps/browser/src/platform/services/browser-state.service.spec.ts index d6bb83f7fb5..0712416172c 100644 --- a/apps/browser/src/platform/services/browser-state.service.spec.ts +++ b/apps/browser/src/platform/services/browser-state.service.spec.ts @@ -8,7 +8,6 @@ import { import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { State } from "@bitwarden/common/platform/models/domain/state"; -import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; @@ -26,7 +25,6 @@ describe("Browser State Service", () => { let secureStorageService: MockProxy; let diskStorageService: MockProxy; let logService: MockProxy; - let stateMigrationService: MockProxy; let stateFactory: MockProxy>; let useAccountCache: boolean; @@ -39,7 +37,6 @@ describe("Browser State Service", () => { secureStorageService = mock(); diskStorageService = mock(); logService = mock(); - stateMigrationService = mock(); stateFactory = mock(); // turn off account cache for tests useAccountCache = false; @@ -64,7 +61,6 @@ describe("Browser State Service", () => { secureStorageService, memoryStorageService, logService, - stateMigrationService, stateFactory, useAccountCache ); diff --git a/apps/browser/src/platform/services/browser-state.service.ts b/apps/browser/src/platform/services/browser-state.service.ts index 34fa1a1d0f3..5e356e7fbe8 100644 --- a/apps/browser/src/platform/services/browser-state.service.ts +++ b/apps/browser/src/platform/services/browser-state.service.ts @@ -1,7 +1,6 @@ import { BehaviorSubject } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateMigrationService } from "@bitwarden/common/platform/abstractions/state-migration.service"; import { AbstractStorageService, AbstractMemoryStorageService, @@ -41,7 +40,6 @@ export class BrowserStateService secureStorageService: AbstractStorageService, memoryStorageService: AbstractMemoryStorageService, logService: LogService, - stateMigrationService: StateMigrationService, stateFactory: StateFactory, useAccountCache = true ) { @@ -50,7 +48,6 @@ export class BrowserStateService secureStorageService, memoryStorageService, logService, - stateMigrationService, stateFactory, useAccountCache ); diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 7e4e1512844..d5a50687734 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -33,7 +33,6 @@ import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password. import { GeneratorComponent } from "../tools/popup/generator/generator.component"; import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/password-generator-history.component"; import { SendListComponent } from "../tools/popup/send/components/send-list.component"; -import { EffluxDatesComponent as SendEffluxDatesComponent } from "../tools/popup/send/efflux-dates.component"; import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.component"; import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component"; import { SendTypeComponent } from "../tools/popup/send/send-type.component"; @@ -134,7 +133,6 @@ import "../platform/popup/locales"; PrivateModeWarningComponent, RegisterComponent, SendAddEditComponent, - SendEffluxDatesComponent, SendGroupingsComponent, SendListComponent, SendTypeComponent, diff --git a/apps/browser/src/popup/images/flag-eu.svg b/apps/browser/src/popup/images/flag-eu.svg deleted file mode 100644 index bbfefd6b47a..00000000000 --- a/apps/browser/src/popup/images/flag-eu.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/browser/src/popup/images/flag-us.svg b/apps/browser/src/popup/images/flag-us.svg deleted file mode 100644 index 615946d4b59..00000000000 --- a/apps/browser/src/popup/images/flag-us.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/apps/browser/src/popup/scss/environment.scss b/apps/browser/src/popup/scss/environment.scss index 19d39655f2a..1ac0f4240bd 100644 --- a/apps/browser/src/popup/scss/environment.scss +++ b/apps/browser/src/popup/scss/environment.scss @@ -90,19 +90,6 @@ html.browser_safari { background-color: themed("listItemBackgroundHoverColor") !important; } } - - img { - margin-bottom: -2px; - height: 14px; - } - - .img-us { - content: url("../images/flag-us.svg"); - } - - .img-eu { - content: url("../images/flag-eu.svg"); - } } .environment-selector-padding { diff --git a/apps/browser/src/popup/scss/variables.scss b/apps/browser/src/popup/scss/variables.scss index 05843a9b351..d8891cf620b 100644 --- a/apps/browser/src/popup/scss/variables.scss +++ b/apps/browser/src/popup/scss/variables.scss @@ -24,10 +24,10 @@ $gray-light: #777; $text-muted: $gray-light; $brand-primary: #175ddc; -$brand-danger: #dd4b39; -$brand-success: #00a65a; +$brand-danger: #c83522; +$brand-success: #017e45; $brand-info: #555555; -$brand-warning: #bf7e16; +$brand-warning: #8b6609; $brand-primary-accent: #1252a3; $background-color: #f0f0f0; @@ -43,6 +43,10 @@ $button-color: lighten($text-color, 40%); $button-color-primary: darken($brand-primary, 8%); $button-color-danger: darken($brand-danger, 10%); +$code-color: #c01176; +$code-color-dark: #f08dc7; +$code-color-nord: #dbb1d5; + $solarizedDarkBase03: #002b36; $solarizedDarkBase02: #073642; $solarizedDarkBase01: #586e75; @@ -122,7 +126,7 @@ $themes: ( // light has no hover so use same color webkitCalendarPickerHoverFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg) brightness(85%) contrast(103%), - codeColor: #e83e8c, + codeColor: $code-color, ), dark: ( textColor: #ffffff, @@ -184,7 +188,7 @@ $themes: ( hue-rotate(184deg) brightness(87%) contrast(93%), webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(100%) sepia(0%) saturate(0%) hue-rotate(93deg) brightness(103%) contrast(103%), - codeColor: #e83e8c, + codeColor: $code-color-dark, ), nord: ( textColor: $nord5, @@ -237,7 +241,7 @@ $themes: ( passwordCountText: $nord5, calloutBorderColor: $nord0, calloutBackgroundColor: $nord2, - toastTextColor: #ffffff, + toastTextColor: #000000, svgSuffix: "-dark.svg", transparentColor: rgba(0, 0, 0, 0), dateInputColorScheme: dark, @@ -246,7 +250,7 @@ $themes: ( // has no hover so use same color webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(94%) sepia(5%) saturate(454%) hue-rotate(185deg) brightness(93%) contrast(96%), - codeColor: #e83e8c, + codeColor: $code-color-nord, ), solarizedDark: ( textColor: $solarizedDarkBase2, @@ -299,7 +303,7 @@ $themes: ( passwordCountText: $solarizedDarkBase2, calloutBorderColor: $solarizedDarkBase03, calloutBackgroundColor: $solarizedDarkBase01, - toastTextColor: #ffffff, + toastTextColor: #000000, svgSuffix: "-solarized.svg", transparentColor: rgba(0, 0, 0, 0), dateInputColorScheme: dark, @@ -307,7 +311,7 @@ $themes: ( hue-rotate(138deg) brightness(92%) contrast(90%), webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(94%) sepia(10%) saturate(462%) hue-rotate(345deg) brightness(103%) contrast(87%), - codeColor: #e83e8c, + codeColor: $code-color-dark, ), ); diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index 23ae6e8e892..c9a1ffb720e 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -4,6 +4,7 @@ import { AbstractThemingService } from "@bitwarden/angular/services/theming/them import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service"; @@ -17,7 +18,8 @@ export class InitService { private popupUtilsService: PopupUtilsService, private stateService: StateServiceAbstraction, private logService: LogServiceAbstraction, - private themingService: AbstractThemingService + private themingService: AbstractThemingService, + private configService: ConfigService ) {} init() { @@ -50,6 +52,8 @@ export class InitService { htmlEl.classList.add("force_redraw"); this.logService.info("Force redraw is on"); } + + this.configService.init(); }; } } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 191e2c78060..4e4f914f230 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -36,7 +36,6 @@ import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { LoginService } from "@bitwarden/common/auth/services/login.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; -import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -47,7 +46,6 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor import { LogService as LogServiceAbstraction } 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 { StateMigrationService } from "@bitwarden/common/platform/abstractions/state-migration.service"; import { StateService as BaseStateServiceAbstraction, StateService, @@ -58,6 +56,7 @@ import { } from "@bitwarden/common/platform/abstractions/storage.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; +import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { SearchService } from "@bitwarden/common/services/search.service"; @@ -442,36 +441,23 @@ function getBgService(service: keyof MainBackground) { provide: MEMORY_STORAGE, useFactory: getBgService("memoryStorageService"), }, - { - provide: StateMigrationService, - useFactory: getBgService("stateMigrationService"), - deps: [], - }, { provide: StateServiceAbstraction, useFactory: ( storageService: AbstractStorageService, secureStorageService: AbstractStorageService, memoryStorageService: AbstractMemoryStorageService, - logService: LogServiceAbstraction, - stateMigrationService: StateMigrationService + logService: LogServiceAbstraction ) => { return new BrowserStateService( storageService, secureStorageService, memoryStorageService, logService, - stateMigrationService, new StateFactory(GlobalState, Account) ); }, - deps: [ - AbstractStorageService, - SECURE_STORAGE, - MEMORY_STORAGE, - LogServiceAbstraction, - StateMigrationService, - ], + deps: [AbstractStorageService, SECURE_STORAGE, MEMORY_STORAGE, LogServiceAbstraction], }, { provide: UsernameGenerationServiceAbstraction, @@ -509,7 +495,7 @@ function getBgService(service: keyof MainBackground) { deps: [StateServiceAbstraction, PlatformUtilsService], }, { - provide: ConfigServiceAbstraction, + provide: ConfigService, useClass: BrowserConfigService, deps: [ StateServiceAbstraction, diff --git a/apps/browser/src/popup/settings/about.component.html b/apps/browser/src/popup/settings/about.component.html index c8840833cf7..24fea4eb9da 100644 --- a/apps/browser/src/popup/settings/about.component.html +++ b/apps/browser/src/popup/settings/about.component.html @@ -33,7 +33,7 @@

- {{ "serverVersion" | i18n }} ({{ "selfHosted" | i18n }}): + {{ "serverVersion" | i18n }} ({{ "selfHostedServer" | i18n }}): {{ this.serverConfig?.version }} ({{ "lastSeenOn" | i18n : (serverConfig.utcDate | date : "mediumDate") }}) diff --git a/apps/browser/src/popup/settings/premium.component.html b/apps/browser/src/popup/settings/premium.component.html index a9a569ec176..2727ee405b9 100644 --- a/apps/browser/src/popup/settings/premium.component.html +++ b/apps/browser/src/popup/settings/premium.component.html @@ -22,7 +22,7 @@

  • - {{ "ppremiumSignUpTwoStep" | i18n }} + {{ "premiumSignUpTwoStepOptions" | i18n }}
  • diff --git a/apps/browser/src/tools/popup/send/efflux-dates.component.html b/apps/browser/src/tools/popup/send/efflux-dates.component.html deleted file mode 100644 index 737fdae4aab..00000000000 --- a/apps/browser/src/tools/popup/send/efflux-dates.component.html +++ /dev/null @@ -1,217 +0,0 @@ - -
    -
    - -
    - - -
    -
    - -
    -
    -
    - - -
    -
    - -
    -
    -
    - -
    - - -
    -
    - -
    -
    -
    -
    - - -
    - -
    -
    - -
    - - - -
    - - -
    -
    - -
    - - -
    -
    - - - -
    -
    - - - -
    - - -
    -
    - -
    - - -
    -
    - - - -
    -
    -
    diff --git a/apps/browser/src/tools/popup/send/efflux-dates.component.ts b/apps/browser/src/tools/popup/send/efflux-dates.component.ts deleted file mode 100644 index 3d575b41fa7..00000000000 --- a/apps/browser/src/tools/popup/send/efflux-dates.component.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { DatePipe } from "@angular/common"; -import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { ControlContainer, NgForm } from "@angular/forms"; - -import { EffluxDatesComponent as BaseEffluxDatesComponent } from "@bitwarden/angular/tools/send/efflux-dates.component"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; - -@Component({ - selector: "app-send-efflux-dates", - templateUrl: "efflux-dates.component.html", - viewProviders: [{ provide: ControlContainer, useExisting: NgForm }], -}) -export class EffluxDatesComponent extends BaseEffluxDatesComponent { - @Input() readonly inPopout: boolean; - @Output() popOutWindow = new EventEmitter(); - - constructor( - protected i18nService: I18nService, - protected platformUtilsService: PlatformUtilsService, - protected datePipe: DatePipe - ) { - super(i18nService, platformUtilsService, datePipe); - } -} diff --git a/apps/browser/src/tools/popup/send/send-add-edit.component.html b/apps/browser/src/tools/popup/send/send-add-edit.component.html index db31ad808a5..707adaa7a55 100644 --- a/apps/browser/src/tools/popup/send/send-add-edit.component.html +++ b/apps/browser/src/tools/popup/send/send-add-edit.component.html @@ -1,4 +1,4 @@ -
    +
    @@ -7,7 +7,7 @@ {{ title }}
    - @@ -42,9 +42,8 @@
    @@ -66,12 +65,9 @@ >
  • -
    +
    @@ -93,9 +89,8 @@
    @@ -105,17 +100,15 @@
    -
    +
    @@ -125,13 +118,7 @@
    - +
    @@ -144,13 +131,7 @@
    - +
    @@ -170,15 +151,140 @@
    - - + +
    +
    + +
    + + +
    +
    + +
    +
    +
    + + +
    +
    + +
    + + +
    +
    + +
    + + +
    +
    + +
    +
    +
    +
    + + +
    + +
    +
    + +
    @@ -190,8 +296,7 @@ type="number" name="MaximumAccessCount" aria-describedby="maximumAccessCountHelp" - [(ngModel)]="send.maxAccessCount" - [readonly]="disableSend" + formControlName="maxAccessCount" />
    @@ -206,10 +311,9 @@
    @@ -227,9 +331,8 @@ name="Password" aria-describedby="passwordHelp" class="monospaced" - [(ngModel)]="password" + formControlName="password" appInputVerbatim - [readonly]="disableSend" />
    @@ -264,8 +367,7 @@ name="Notes" aria-describedby="notesHelp" rows="6" - [(ngModel)]="send.notes" - [readonly]="disableSend" + formControlName="notes" >
    @@ -278,13 +380,7 @@
    - +
    @@ -293,13 +389,7 @@
    - +
    diff --git a/apps/browser/src/tools/popup/send/send-add-edit.component.ts b/apps/browser/src/tools/popup/send/send-add-edit.component.ts index 1efd950c508..2d90957de7f 100644 --- a/apps/browser/src/tools/popup/send/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send/send-add-edit.component.ts @@ -1,5 +1,6 @@ import { DatePipe, Location } from "@angular/common"; import { Component } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { first } from "rxjs/operators"; @@ -47,7 +48,8 @@ export class SendAddEditComponent extends BaseAddEditComponent { private popupUtilsService: PopupUtilsService, logService: LogService, sendApiService: SendApiService, - dialogService: DialogService + dialogService: DialogService, + formBuilder: FormBuilder ) { super( i18nService, @@ -60,7 +62,8 @@ export class SendAddEditComponent extends BaseAddEditComponent { logService, stateService, sendApiService, - dialogService + dialogService, + formBuilder ); } diff --git a/apps/browser/src/vault/popup/components/vault/view.component.ts b/apps/browser/src/vault/popup/components/vault/view.component.ts index 57f9b676cd0..73381dce4d0 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view.component.ts @@ -168,8 +168,8 @@ export class ViewComponent extends BaseViewComponent { switch (this.loadAction) { case AUTOFILL_ID: - this.fillCipher(); - return; + await this.fillCipher(); + break; case COPY_USERNAME_ID: await this.copy(this.cipher.login.username, "username", "Username"); break; @@ -177,14 +177,14 @@ export class ViewComponent extends BaseViewComponent { await this.copy(this.cipher.login.password, "password", "Password"); break; case COPY_VERIFICATIONCODE_ID: - await this.copy(this.cipher.login.totp, "verificationCodeTotp", "TOTP"); + await this.copy(this.totpCode, "verificationCodeTotp", "TOTP"); break; default: break; } if (this.inPopout && this.loadAction) { - this.close(); + setTimeout(() => this.close(), 1000); } } @@ -236,10 +236,6 @@ export class ViewComponent extends BaseViewComponent { const didAutofill = await this.doAutofill(); if (didAutofill) { this.platformUtilsService.showToast("success", null, this.i18nService.t("autoFillSuccess")); - - if (this.inPopout) { - this.close(); - } } } @@ -304,11 +300,8 @@ export class ViewComponent extends BaseViewComponent { } close() { - if (this.senderTabId) { + if (this.inPopout && this.senderTabId) { BrowserApi.focusTab(this.senderTabId); - } - - if (this.inPopout) { window.close(); return; } diff --git a/apps/browser/store/locales/el/copy.resx b/apps/browser/store/locales/el/copy.resx index 496118ddf6e..01def6ea5af 100644 --- a/apps/browser/store/locales/el/copy.resx +++ b/apps/browser/store/locales/el/copy.resx @@ -155,7 +155,7 @@ Ένας ασφαλής και δωρεάν διαχειριστής κωδικών για όλες τις συσκευές σας - Συγχρονίστε και αποκτήστε πρόσβαση στο vault σας από πολλές συσκευές + Συγχρονίστε και αποκτήστε πρόσβαση στο θησαυροφυλάκιό σας από πολλαπλές συσκευές Διαχειριστείτε όλες τις συνδέσεις και τους κωδικούς σας με ασφάλεια diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts index f87fa9c2c12..6feb163e0a6 100644 --- a/apps/browser/test.setup.ts +++ b/apps/browser/test.setup.ts @@ -30,9 +30,25 @@ const contextMenus = { removeAll: jest.fn(), }; +const i18n = { + getMessage: jest.fn(), +}; + +const tabs = { + executeScript: jest.fn(), + sendMessage: jest.fn(), +}; + +const scripting = { + executeScript: jest.fn(), +}; + // set chrome global.chrome = { + i18n, storage, runtime, contextMenus, + tabs, + scripting, } as any; diff --git a/apps/browser/tsconfig.spec.json b/apps/browser/tsconfig.spec.json index de184bd7608..79b5f5bc4b6 100644 --- a/apps/browser/tsconfig.spec.json +++ b/apps/browser/tsconfig.spec.json @@ -1,4 +1,7 @@ { "extends": "./tsconfig.json", - "files": ["./test.setup.ts"] + "files": ["./test.setup.ts"], + "compilerOptions": { + "esModuleInterop": true + } } diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 711d0a7e7ba..63174af21e9 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -14,10 +14,9 @@ if (process.env.NODE_ENV == null) { } const ENV = (process.env.ENV = process.env.NODE_ENV); const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2; -const autofillVersion = process.env.AUTOFILL_VERSION == 2 ? 2 : 1; console.log(`Building Manifest Version ${manifestVersion} app`); -console.log(`Using Autofill v${autofillVersion}`); + const envConfig = configurator.load(ENV); configurator.log(envConfig); @@ -153,6 +152,10 @@ const mainConfig = { entry: { "popup/polyfills": "./src/popup/polyfills.ts", "popup/main": "./src/popup/main.ts", + "content/trigger-autofill-script-injection": + "./src/autofill/content/trigger-autofill-script-injection.ts", + "content/autofill": "./src/autofill/content/autofill.js", + "content/autofill-init": "./src/autofill/content/autofill-init.ts", "content/autofiller": "./src/autofill/content/autofiller.ts", "content/notificationBar": "./src/autofill/content/notification-bar.ts", "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", @@ -314,12 +317,4 @@ if (manifestVersion == 2) { configs.push(backgroundConfig); } -if (autofillVersion == 2) { - // Typescript refactors (WIP) - mainConfig.entry["content/autofill"] = "./src/autofill/content/autofillv2.ts"; -} else { - // Javascript (used in production) - mainConfig.entry["content/autofill"] = "./src/autofill/content/autofill.js"; -} - module.exports = configs; diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 1bcaa1a2acf..42ba158ee72 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -37,7 +37,6 @@ import { EnvironmentService } from "@bitwarden/common/platform/services/environm import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { NoopMessagingService } from "@bitwarden/common/platform/services/noop-messaging.service"; -import { StateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; import { StateService } from "@bitwarden/common/platform/services/state.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { OrganizationUserServiceImplementation } from "@bitwarden/common/services/organization-user/organization-user.service.implementation"; @@ -136,7 +135,6 @@ export class Main { keyConnectorService: KeyConnectorService; userVerificationService: UserVerificationService; stateService: StateService; - stateMigrationService: StateMigrationService; organizationService: OrganizationService; providerService: ProviderService; twoFactorService: TwoFactorService; @@ -188,18 +186,11 @@ export class Main { this.memoryStorageService = new MemoryStorageService(); - this.stateMigrationService = new StateMigrationService( - this.storageService, - this.secureStorageService, - new StateFactory(GlobalState, Account) - ); - this.stateService = new StateService( this.storageService, this.secureStorageService, this.memoryStorageService, this.logService, - this.stateMigrationService, new StateFactory(GlobalState, Account) ); diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 9eca236a3a0..8bca024b410 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -298,9 +298,12 @@ export class Program { .option("-p, --passphrase", "Generate a passphrase.") .option("--length ", "Length of the password.") .option("--words ", "Number of words.") + .option("--minNumber ", "Minimum number of numeric characters.") + .option("--minSpecial ", "Minimum number of special characters.") .option("--separator ", "Word separator.") .option("-c, --capitalize", "Title case passphrase.") .option("--includeNumber", "Passphrase includes number.") + .option("--ambiguous", "Avoid ambiguous characters.") .on("--help", () => { writeLn("\n Notes:"); writeLn(""); diff --git a/apps/cli/src/tools/generate.command.ts b/apps/cli/src/tools/generate.command.ts index bd9ad88a04f..30436e7db71 100644 --- a/apps/cli/src/tools/generate.command.ts +++ b/apps/cli/src/tools/generate.command.ts @@ -1,5 +1,6 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { PasswordGeneratorOptions } from "@bitwarden/common/tools/generator/password/password-generator-options"; import { Response } from "../models/response"; import { StringResponse } from "../models/response/string.response"; @@ -13,7 +14,7 @@ export class GenerateCommand { async run(cmdOptions: Record): Promise { const normalizedOptions = new Options(cmdOptions); - const options = { + const options: PasswordGeneratorOptions = { uppercase: normalizedOptions.uppercase, lowercase: normalizedOptions.lowercase, number: normalizedOptions.number, @@ -24,6 +25,9 @@ export class GenerateCommand { numWords: normalizedOptions.words, capitalize: normalizedOptions.capitalize, includeNumber: normalizedOptions.includeNumber, + minNumber: normalizedOptions.minNumber, + minSpecial: normalizedOptions.minSpecial, + ambiguous: normalizedOptions.ambiguous, }; const enforcedOptions = (await this.stateService.getIsAuthenticated()) @@ -47,6 +51,9 @@ class Options { words: number; capitalize: boolean; includeNumber: boolean; + minNumber: number; + minSpecial: number; + ambiguous: boolean; constructor(passedOptions: Record) { this.uppercase = CliUtils.convertBooleanOption(passedOptions?.uppercase); @@ -55,10 +62,13 @@ class Options { this.special = CliUtils.convertBooleanOption(passedOptions?.special); this.capitalize = CliUtils.convertBooleanOption(passedOptions?.capitalize); this.includeNumber = CliUtils.convertBooleanOption(passedOptions?.includeNumber); - this.length = passedOptions?.length != null ? parseInt(passedOptions?.length, null) : 14; + this.ambiguous = CliUtils.convertBooleanOption(passedOptions?.ambiguous); + this.length = CliUtils.convertNumberOption(passedOptions?.length, 14); this.type = passedOptions?.passphrase ? "passphrase" : "password"; - this.separator = passedOptions?.separator == null ? "-" : passedOptions.separator + ""; - this.words = passedOptions?.words != null ? parseInt(passedOptions.words, null) : 3; + this.separator = CliUtils.convertStringOption(passedOptions?.separator, "-"); + this.words = CliUtils.convertNumberOption(passedOptions?.words, 3); + this.minNumber = CliUtils.convertNumberOption(passedOptions?.minNumber, 1); + this.minSpecial = CliUtils.convertNumberOption(passedOptions?.minSpecial, 1); if (!this.uppercase && !this.lowercase && !this.special && !this.number) { this.lowercase = true; diff --git a/apps/cli/src/utils.ts b/apps/cli/src/utils.ts index f8780dbec63..5d77f6d3730 100644 --- a/apps/cli/src/utils.ts +++ b/apps/cli/src/utils.ts @@ -253,4 +253,20 @@ export class CliUtils { static convertBooleanOption(optionValue: any) { return optionValue || optionValue === "" ? true : false; } + + static convertNumberOption(optionValue: any, defaultValue: number) { + try { + if (optionValue != null) { + const numVal = parseInt(optionValue); + return !Number.isNaN(numVal) ? numVal : defaultValue; + } + return defaultValue; + } catch { + return defaultValue; + } + } + + static convertStringOption(optionValue: any, defaultValue: string) { + return optionValue != null ? String(optionValue) : defaultValue; + } } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 82c77156c12..6855485510a 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": "2023.8.3", + "version": "2023.8.4", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index b5321a2bc83..39e2d2f8b11 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -232,7 +232,7 @@ export class AppComponent implements OnInit, OnDestroy { break; case "syncCompleted": await this.updateAppMenu(); - await this.configService.fetchServerConfig(); + this.configService.triggerServerConfigFetch(); break; case "openSettings": await this.openModal(SettingsComponent, this.settingsRef); diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index 3ac2ca29756..d14c40854cc 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -53,7 +53,6 @@ import { ExportComponent } from "./tools/export/export.component"; import { GeneratorComponent } from "./tools/generator.component"; import { PasswordGeneratorHistoryComponent } from "./tools/password-generator-history.component"; import { AddEditComponent as SendAddEditComponent } from "./tools/send/add-edit.component"; -import { EffluxDatesComponent as SendEffluxDatesComponent } from "./tools/send/efflux-dates.component"; import { SendComponent } from "./tools/send/send.component"; @NgModule({ @@ -87,7 +86,6 @@ import { SendComponent } from "./tools/send/send.component"; SearchComponent, SendAddEditComponent, SendComponent, - SendEffluxDatesComponent, SetPasswordComponent, SetPinComponent, SettingsComponent, diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 34300aed931..0d60a1140f8 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -11,6 +11,7 @@ import { EnvironmentService as EnvironmentServiceAbstraction } from "@bitwarden/ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; +import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; @@ -35,7 +36,8 @@ export class InitService { private cryptoService: CryptoServiceAbstraction, private nativeMessagingService: NativeMessagingService, private themingService: AbstractThemingService, - private encryptService: EncryptService + private encryptService: EncryptService, + private configService: ConfigService ) {} init() { @@ -71,6 +73,8 @@ export class InitService { const containerService = new ContainerService(this.cryptoService, this.encryptService); containerService.attachToGlobal(this.win); + + this.configService.init(); }; } } diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index ded0366dc16..42208077c33 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -28,7 +28,6 @@ import { } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/platform/abstractions/state-migration.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service"; @@ -134,7 +133,6 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); SECURE_STORAGE, MEMORY_STORAGE, LogService, - StateMigrationServiceAbstraction, STATE_FACTORY, STATE_SERVICE_USE_CACHE, ], diff --git a/apps/desktop/src/app/tools/send/add-edit.component.html b/apps/desktop/src/app/tools/send/add-edit.component.html index e9e1b319adb..d9f280be598 100644 --- a/apps/desktop/src/app/tools/send/add-edit.component.html +++ b/apps/desktop/src/app/tools/send/add-edit.component.html @@ -1,4 +1,4 @@ - +
    @@ -16,14 +16,7 @@
    - +
    @@ -31,20 +24,16 @@
    -
    +
    -
    +
    {{ send.file.fileName }} ({{ send.file.sizeName }})
    -
    +
    @@ -83,13 +70,7 @@
    - +
    @@ -112,14 +93,82 @@
    - - +
    +
    +
    + + + {{ + "deletionDateDesc" | i18n + }} +
    +
    + + + {{ + "deletionDateDesc" | i18n + }} +
    +
    + + + {{ + "expirationDateDesc" | i18n + }} +
    +
    + + + {{ + "expirationDateDesc" | i18n + }} +
    +
    +
    @@ -129,8 +178,7 @@ type="number" name="maxAccessCount" aria-describedby="maxAccessCountHelp" - [(ngModel)]="send.maxAccessCount" - [readOnly]="disableSend" + formControlName="maxAccessCount" />
    @@ -154,8 +202,7 @@ name="password" aria-describedby="passwordHelp" type="{{ showPassword ? 'text' : 'password' }}" - [(ngModel)]="password" - [readOnly]="disableSend" + formControlName="password" appInputVerbatim />
    @@ -167,7 +214,6 @@ appA11yTitle="{{ 'toggleVisibility' | i18n }}" [attr.aria-pressed]="showPassword" (click)="togglePasswordVisible()" - [disabled]="disableSend" >
    @@ -206,13 +251,7 @@
    - +
    @@ -220,13 +259,7 @@
    - +
    @@ -238,17 +271,11 @@
    - +
    - +
    @@ -259,13 +286,12 @@ type="submit" class="primary btn-submit" appA11yTitle="{{ 'save' | i18n }}" - [disabled]="form.loading" *ngIf="!disableSend" > -
    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 de5d2a601ab..98764866a54 100644 --- a/apps/desktop/src/app/tools/send/add-edit.component.ts +++ b/apps/desktop/src/app/tools/send/add-edit.component.ts @@ -1,5 +1,6 @@ import { DatePipe } from "@angular/common"; import { Component } from "@angular/core"; +import { FormBuilder } from "@angular/forms"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -29,7 +30,8 @@ export class AddEditComponent extends BaseAddEditComponent { policyService: PolicyService, logService: LogService, sendApiService: SendApiService, - dialogService: DialogService + dialogService: DialogService, + formBuilder: FormBuilder ) { super( i18nService, @@ -42,7 +44,8 @@ export class AddEditComponent extends BaseAddEditComponent { logService, stateService, sendApiService, - dialogService + dialogService, + formBuilder ); } @@ -50,6 +53,7 @@ export class AddEditComponent extends BaseAddEditComponent { this.password = null; const send = await this.loadSend(); this.send = await send.decrypt(); + this.updateFormValues(); this.hasPassword = this.send.password != null && this.send.password.trim() !== ""; } @@ -65,4 +69,11 @@ export class AddEditComponent extends BaseAddEditComponent { this.i18nService.t("valueCopied", this.i18nService.t("sendLink")) ); } + + async resetAndLoad() { + this.sendId = null; + this.send = null; + await this.load(); + this.updateFormValues(); + } } diff --git a/apps/desktop/src/app/tools/send/efflux-dates.component.html b/apps/desktop/src/app/tools/send/efflux-dates.component.html deleted file mode 100644 index 156dfae9ddd..00000000000 --- a/apps/desktop/src/app/tools/send/efflux-dates.component.html +++ /dev/null @@ -1,62 +0,0 @@ - -
    -
    -
    - - - {{ "deletionDateDesc" | i18n }} -
    -
    - - - {{ - "deletionDateDesc" | i18n - }} -
    -
    - - - {{ "expirationDateDesc" | i18n }} -
    -
    - - - {{ - "expirationDateDesc" | i18n - }} -
    -
    -
    -
    diff --git a/apps/desktop/src/app/tools/send/efflux-dates.component.ts b/apps/desktop/src/app/tools/send/efflux-dates.component.ts deleted file mode 100644 index 40215348d55..00000000000 --- a/apps/desktop/src/app/tools/send/efflux-dates.component.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { DatePipe } from "@angular/common"; -import { Component, OnChanges } from "@angular/core"; -import { ControlContainer, NgForm } from "@angular/forms"; - -import { EffluxDatesComponent as BaseEffluxDatesComponent } from "@bitwarden/angular/tools/send/efflux-dates.component"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; - -@Component({ - selector: "app-send-efflux-dates", - templateUrl: "efflux-dates.component.html", - viewProviders: [{ provide: ControlContainer, useExisting: NgForm }], -}) -export class EffluxDatesComponent extends BaseEffluxDatesComponent implements OnChanges { - constructor( - protected i18nService: I18nService, - protected platformUtilsService: PlatformUtilsService, - protected datePipe: DatePipe - ) { - super(i18nService, platformUtilsService, datePipe); - } - - // We reuse the same form on desktop and just swap content, so need to watch these to maintin proper values. - ngOnChanges() { - this.selectedExpirationDatePreset.setValue(0); - this.selectedDeletionDatePreset.setValue(0); - this.defaultDeletionDateTime.setValue( - this.datePipe.transform(new Date(this.initialDeletionDate), "yyyy-MM-ddTHH:mm") - ); - if (this.initialExpirationDate) { - this.defaultExpirationDateTime.setValue( - this.datePipe.transform(new Date(this.initialExpirationDate), "yyyy-MM-ddTHH:mm") - ); - } else { - this.defaultExpirationDateTime.setValue(null); - } - } -} diff --git a/apps/desktop/src/app/tools/send/send.component.ts b/apps/desktop/src/app/tools/send/send.component.ts index 7ad98ceda1e..21b759e49bf 100644 --- a/apps/desktop/src/app/tools/send/send.component.ts +++ b/apps/desktop/src/app/tools/send/send.component.ts @@ -91,12 +91,10 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro this.searchBarService.setEnabled(false); } - addSend() { + async addSend() { this.action = Action.Add; if (this.addEditComponent != null) { - this.addEditComponent.sendId = null; - this.addEditComponent.send = null; - this.addEditComponent.load(); + await this.addEditComponent.resetAndLoad(); } } diff --git a/apps/desktop/src/auth/login/login.component.html b/apps/desktop/src/auth/login/login.component.html index 978f8df562f..3e02b49af2a 100644 --- a/apps/desktop/src/auth/login/login.component.html +++ b/apps/desktop/src/auth/login/login.component.html @@ -89,7 +89,11 @@
    - +
    - +
    diff --git a/apps/web/src/app/auth/register-form/register-form.component.html b/apps/web/src/app/auth/register-form/register-form.component.html index 9a5220c57d4..e53c963c938 100644 --- a/apps/web/src/app/auth/register-form/register-form.component.html +++ b/apps/web/src/app/auth/register-form/register-form.component.html @@ -89,7 +89,7 @@
    - +
    -

    {{ "twoStepLogin" | i18n }}

    -

    {{ "twoStepLoginEnforcement" | i18n }}

    +

    {{ "twoStepLogin" | i18n }}

    +

    {{ "twoStepLoginEnforcement" | i18n }}

    {{ "twoStepLoginDesc" | i18n }}

    - {{ "twoStepLoginOrganizationDescStart" | i18n }} - {{ "twoStepLoginPolicy" | i18n }}. -
    - {{ "twoStepLoginOrganizationDuoDesc" | i18n }} + + {{ "twoStepLoginEnterpriseDescStart" | i18n }} + {{ "twoStepLoginPolicy" | i18n }}. +
    + {{ "twoStepLoginOrganizationDuoDesc" | i18n }} +
    +
    +

    {{ "twoStepLoginOrganizationSsoDesc" | i18n }}

    +
    + + {{ "twoStepLoginTeamsDesc" | i18n }} +
    + {{ "twoStepLoginOrganizationDuoDesc" | i18n }} +

    -

    {{ "twoStepLoginOrganizationSsoDesc" | i18n }}

    {{ "twoStepLoginRecoveryWarning" | i18n }}

    diff --git a/apps/web/src/app/auth/settings/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor-setup.component.ts index 856801a176f..44955347b5c 100644 --- a/apps/web/src/app/auth/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor-setup.component.ts @@ -6,8 +6,10 @@ import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service"; +import { ProductType } from "@bitwarden/common/enums"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -36,6 +38,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { webAuthnModalRef: ViewContainerRef; organizationId: string; + organization: Organization; providers: any[] = []; canAccessPremium: boolean; showPolicyWarning = false; @@ -45,7 +48,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { tabbedHeader = true; - private destroy$ = new Subject(); + protected destroy$ = new Subject(); private twoFactorAuthPolicyAppliesToActiveUser: boolean; constructor( @@ -202,11 +205,15 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { this.evaluatePolicies(); } - private async evaluatePolicies() { + private evaluatePolicies() { if (this.organizationId == null && this.providers.filter((p) => p.enabled).length === 1) { this.showPolicyWarning = this.twoFactorAuthPolicyAppliesToActiveUser; } else { this.showPolicyWarning = false; } } + + get isEnterpriseOrg() { + return this.organization?.planProductType === ProductType.Enterprise; + } } diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html index b8127d00c1c..af8c255f632 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.html @@ -63,7 +63,6 @@ {{ "startYour7DayFreeTrialOfBitwardenFor" | i18n : org }}
    diff --git a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts index e18c7548e03..183b57a90c6 100644 --- a/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts +++ b/apps/web/src/app/auth/trial-initiation/trial-initiation.component.ts @@ -94,6 +94,13 @@ export class TrialInitiationComponent implements OnInit, OnDestroy { if (this.referenceData.id === "") { this.referenceData.id = null; + } else { + // Matches "_ga_QBRN562QQQ=value1.value2.session" and captures values and session. + const regex = /_ga_QBRN562QQQ=([^.]+)\.([^.]+)\.(\d+)/; + const match = document.cookie.match(regex); + if (match) { + this.referenceData.session = match[3]; + } } } diff --git a/apps/web/src/app/auth/two-factor.component.html b/apps/web/src/app/auth/two-factor.component.html index 2545e61e1a3..e3617a16589 100644 --- a/apps/web/src/app/auth/two-factor.component.html +++ b/apps/web/src/app/auth/two-factor.component.html @@ -80,7 +80,7 @@
    - +
    - +

    - +
    diff --git a/apps/web/src/app/components/environment-selector/environment-selector.component.html b/apps/web/src/app/components/environment-selector/environment-selector.component.html index 2984d6a6a3b..d17a9c2b43c 100644 --- a/apps/web/src/app/components/environment-selector/environment-selector.component.html +++ b/apps/web/src/app/components/environment-selector/environment-selector.component.html @@ -2,7 +2,9 @@ - {{ 'usFlag' | i18n }} {{ "usDomain" | i18n }} @@ -28,32 +27,10 @@ aria-hidden="true" [style.visibility]="isEuServer ? 'visible' : 'hidden'" > - {{ 'euFlag' | i18n }} {{ "euDomain" | i18n }} - - - - {{ 'selectedRegionFlag' | i18n }} - - -
    +
    {{ "server" | i18n }}: {{ isEuServer ? ("euDomain" | i18n) : ("usDomain" | i18n) }}( FeatureFlag.DisplayEuEnvironmentFlag ); const domain = Utils.getDomain(window.location.href); this.isEuServer = domain.includes(RegionDomain.EU); this.isUsServer = domain.includes(RegionDomain.US) || domain.includes(RegionDomain.USQA); - this.selectedRegionImageName = this.getRegionImage(); this.showRegionSelector = !this.platformUtilsService.isSelfHost(); - } - - getRegionImage(): string { - if (this.isEuServer) { - return "flag-eu"; - } else { - return "flag-us"; - } + this.routeAndParams = `/#${this.router.url}`; } } diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 03f20ad2955..b2e44d7e3db 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -17,7 +17,6 @@ import { FileDownloadService } from "@bitwarden/common/platform/abstractions/fil import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/platform/abstractions/state-migration.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; @@ -27,7 +26,6 @@ import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@ import { PolicyListService } from "../admin-console/core/policy-list.service"; import { HtmlStorageService } from "../core/html-storage.service"; import { I18nService } from "../core/i18n.service"; -import { StateMigrationService } from "../core/state-migration.service"; import { CollectionAdminService } from "../vault/core/collection-admin.service"; import { PasswordRepromptService } from "../vault/core/password-reprompt.service"; @@ -84,11 +82,6 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service"; }, { provide: MessagingServiceAbstraction, useClass: BroadcasterMessagingService }, { provide: ModalServiceAbstraction, useClass: ModalService }, - { - provide: StateMigrationServiceAbstraction, - useClass: StateMigrationService, - deps: [AbstractStorageService, SECURE_STORAGE, STATE_FACTORY], - }, StateService, { provide: BaseStateServiceAbstraction, diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index 3437c4f3e93..f171217d3cd 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -13,6 +13,7 @@ import { } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; +import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; @@ -32,7 +33,8 @@ export class InitService { private stateService: StateServiceAbstraction, private cryptoService: CryptoServiceAbstraction, private themingService: AbstractThemingService, - private encryptService: EncryptService + private encryptService: EncryptService, + private configService: ConfigService ) {} init() { @@ -57,6 +59,8 @@ export class InitService { await this.themingService.monitorThemeChanges(); const containerService = new ContainerService(this.cryptoService, this.encryptService); containerService.attachToGlobal(this.win); + + this.configService.init(); }; } } diff --git a/apps/web/src/app/core/state-migration.service.ts b/apps/web/src/app/core/state-migration.service.ts deleted file mode 100644 index c1d6e2ded5d..00000000000 --- a/apps/web/src/app/core/state-migration.service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { StateMigrationService as BaseStateMigrationService } from "@bitwarden/common/platform/services/state-migration.service"; - -import { Account } from "./state/account"; -import { GlobalState } from "./state/global-state"; - -export class StateMigrationService extends BaseStateMigrationService { - protected async migrationStateFrom1To2(): Promise { - await super.migrateStateFrom1To2(); - const globals = (await this.get("global")) ?? this.stateFactory.createGlobal(null); - globals.rememberEmail = (await this.get("rememberEmail")) ?? globals.rememberEmail; - await this.set("global", globals); - } -} diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts index 60f09ceae36..c95077bfbcc 100644 --- a/apps/web/src/app/core/state/state.service.ts +++ b/apps/web/src/app/core/state/state.service.ts @@ -7,7 +7,6 @@ import { STATE_SERVICE_USE_CACHE, } from "@bitwarden/angular/services/injection-tokens"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { StateMigrationService } from "@bitwarden/common/platform/abstractions/state-migration.service"; import { AbstractMemoryStorageService, AbstractStorageService, @@ -30,7 +29,6 @@ export class StateService extends BaseStateService { @Inject(SECURE_STORAGE) secureStorageService: AbstractStorageService, @Inject(MEMORY_STORAGE) memoryStorageService: AbstractMemoryStorageService, logService: LogService, - stateMigrationService: StateMigrationService, @Inject(STATE_FACTORY) stateFactory: StateFactory, @Inject(STATE_SERVICE_USE_CACHE) useAccountCache = true ) { @@ -39,7 +37,6 @@ export class StateService extends BaseStateService { secureStorageService, memoryStorageService, logService, - stateMigrationService, stateFactory, useAccountCache ); diff --git a/apps/web/src/app/layouts/frontend-layout.component.html b/apps/web/src/app/layouts/frontend-layout.component.html index 531e49dad4a..a6988baf289 100644 --- a/apps/web/src/app/layouts/frontend-layout.component.html +++ b/apps/web/src/app/layouts/frontend-layout.component.html @@ -1,6 +1,6 @@
    - + © {{ year }} Bitwarden Inc.
    {{ "versionNumber" | i18n : version }}
    diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 62702349f95..4552162cc8d 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -93,7 +93,6 @@ import { GeneratorComponent } from "../tools/generator.component"; import { PasswordGeneratorHistoryComponent } from "../tools/password-generator-history.component"; import { AccessComponent } from "../tools/send/access.component"; import { AddEditComponent as SendAddEditComponent } from "../tools/send/add-edit.component"; -import { EffluxDatesComponent as SendEffluxDatesComponent } from "../tools/send/efflux-dates.component"; import { ToolsComponent } from "../tools/tools.component"; import { PasswordRepromptComponent } from "../vault/components/password-reprompt.component"; import { PremiumBadgeComponent } from "../vault/components/premium-badge.component"; @@ -198,7 +197,6 @@ import { SharedModule } from "./shared.module"; SecurityKeysComponent, SelectableAvatarComponent, SendAddEditComponent, - SendEffluxDatesComponent, SetPasswordComponent, SettingsComponent, ShareComponent, @@ -302,7 +300,6 @@ import { SharedModule } from "./shared.module"; SecurityKeysComponent, SelectableAvatarComponent, SendAddEditComponent, - SendEffluxDatesComponent, SetPasswordComponent, SettingsComponent, ShareComponent, diff --git a/apps/web/src/app/tools/send/add-edit.component.html b/apps/web/src/app/tools/send/add-edit.component.html index 9cd90840b63..319d988f210 100644 --- a/apps/web/src/app/tools/send/add-edit.component.html +++ b/apps/web/src/app/tools/send/add-edit.component.html @@ -1,303 +1,276 @@ -