diff --git a/.eslintrc.json b/.eslintrc.json index 5b431ff2740..44b4e09adf2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -20,7 +20,8 @@ "plugin:import/recommended", "plugin:import/typescript", "prettier", - "plugin:rxjs/recommended" + "plugin:rxjs/recommended", + "plugin:storybook/recommended" ], "settings": { "import/parsers": { @@ -33,20 +34,14 @@ } }, "rules": { - "@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled - "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], "@typescript-eslint/explicit-member-accessibility": [ "error", - { - "accessibility": "no-public" - } - ], - "@typescript-eslint/no-this-alias": [ - "error", - { - "allowedNames": ["self"] - } + { "accessibility": "no-public" } ], + "@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled + "@typescript-eslint/no-misused-promises": ["error", { "checksVoidReturn": false }], + "@typescript-eslint/no-this-alias": ["error", { "allowedNames": ["self"] }], + "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], "no-console": "error", "import/no-unresolved": "off", // TODO: Look into turning off once each package is an actual package. "import/order": [ @@ -90,22 +85,21 @@ "error", { "zones": [ - // Do not allow angular/node code to be imported into common { + // avoid specific frameworks or large dependencies in common "target": "./libs/common/**/*", - "from": "./libs/angular/**/*" - }, - { - "target": "./libs/common/**/*", - "from": "./libs/node/**/*" - }, - { - "target": "./libs/common/**/*", - "from": "./libs/importer/**/*" - }, - { - "target": "./libs/common/**/*", - "from": "./libs/exporter/**/*" + "from": [ + // Angular + "./libs/angular/**/*", + "./node_modules/@angular*/**/*", + + // Node + "./libs/node/**/*", + + // Import/export + "./libs/importer/**/*", + "./libs/exporter/**/*" + ] } ] } @@ -135,6 +129,18 @@ "tailwindcss/no-contradicting-classname": "error" } }, + { + "files": ["libs/angular/src/**/*.ts"], + "rules": { + "no-restricted-imports": ["error", { "patterns": ["@bitwarden/angular/*", "src/**/*"] }] + } + }, + { + "files": ["libs/auth/src/**/*.ts"], + "rules": { + "no-restricted-imports": ["error", { "patterns": ["@bitwarden/auth/*", "src/**/*"] }] + } + }, { "files": ["libs/common/src/**/*.ts"], "rules": { @@ -144,13 +150,22 @@ { "files": ["libs/components/src/**/*.ts"], "rules": { - "no-restricted-imports": ["error", { "patterns": ["@bitwarden/components/*", "src/**/*"] }] + "no-restricted-imports": [ + "error", + { "patterns": ["@bitwarden/components/*", "src/**/*", "@bitwarden/angular/*"] } + ] } }, { - "files": ["libs/angular/src/**/*.ts"], + "files": ["libs/exporter/src/**/*.ts"], "rules": { - "no-restricted-imports": ["error", { "patterns": ["@bitwarden/angular/*", "src/**/*"] }] + "no-restricted-imports": ["error", { "patterns": ["@bitwarden/exporter/*", "src/**/*"] }] + } + }, + { + "files": ["libs/importer/src/**/*.ts"], + "rules": { + "no-restricted-imports": ["error", { "patterns": ["@bitwarden/importer/*", "src/**/*"] }] } }, { @@ -160,15 +175,26 @@ } }, { - "files": ["libs/importer/src/**/*.ts"], + "files": ["libs/vault/src/**/*.ts"], "rules": { - "no-restricted-imports": ["error", { "patterns": ["@bitwarden/importer/*", "src/**/*"] }] + "no-restricted-imports": ["error", { "patterns": ["@bitwarden/vault/*", "src/**/*"] }] } }, { - "files": ["libs/exporter/src/**/*.ts"], + "files": ["apps/browser/src/**/*.ts", "libs/**/*.ts"], + "excludedFiles": "apps/browser/src/autofill/{content,notification}/**/*.ts", "rules": { - "no-restricted-imports": ["error", { "patterns": ["@bitwarden/exporter/*", "src/**/*"] }] + "no-restricted-syntax": [ + "error", + { + "message": "Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi.messageListener` instead", + "selector": "CallExpression > [object.object.object.name='chrome'][object.object.property.name='runtime'][object.property.name='onMessage'][property.name='addListener']" + }, + { + "message": "Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi.storageChangeListener` instead", + "selector": "CallExpression > [object.object.object.name='chrome'][object.object.property.name='storage'][object.property.name='onChanged'][property.name='addListener']" + } + ] } } ] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2dc459d6ad2..a70774b5e1d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,7 +4,7 @@ # The following owners will be the default owners for everything in the repo. # Unless a later match takes precedence -# @bitwarden/team-leads +* @bitwarden/tech-leads ## Secrets Manager team files ## bitwarden_license/bit-web/src/app/secrets-manager @bitwarden/team-secrets-manager-dev @@ -13,9 +13,11 @@ bitwarden_license/bit-web/src/app/secrets-manager @bitwarden/team-secrets-manage apps/browser/src/auth @bitwarden/team-auth-dev apps/cli/src/auth @bitwarden/team-auth-dev apps/desktop/src/auth @bitwarden/team-auth-dev -apps/web/src/auth @bitwarden/team-auth-dev +apps/web/src/app/auth @bitwarden/team-auth-dev +libs/auth @bitwarden/team-auth-dev # web connectors used for auth apps/web/src/connectors @bitwarden/team-auth-dev +bitwarden_license/bit-web/src/app/auth @bitwarden/team-auth-dev libs/angular/src/auth @bitwarden/team-auth-dev libs/common/src/auth @bitwarden/team-auth-dev @@ -31,13 +33,13 @@ libs/exporter @bitwarden/team-tools-dev libs/importer @bitwarden/team-tools-dev ## Vault team files ## -apps/browser/src/autofill @bitwarden/team-vault-dev apps/browser/src/vault @bitwarden/team-vault-dev apps/cli/src/vault @bitwarden/team-vault-dev apps/desktop/src/vault @bitwarden/team-vault-dev apps/web/src/app/vault @bitwarden/team-vault-dev libs/angular/src/vault @bitwarden/team-vault-dev libs/common/src/vault @bitwarden/team-vault-dev +libs/vault @bitwarden/team-vault-dev ## Admin Console team files ## apps/browser/src/admin-console @bitwarden/team-admin-console-dev @@ -52,3 +54,54 @@ libs/common/src/admin-console @bitwarden/team-admin-console-dev apps/web/src/app/billing @bitwarden/team-billing-dev libs/angular/src/billing @bitwarden/team-billing-dev libs/common/src/billing @bitwarden/team-billing-dev + +## Platform team files ## +apps/browser/src/platform @bitwarden/team-platform-dev +apps/cli/src/platform @bitwarden/team-platform-dev +apps/desktop/src/platform @bitwarden/team-platform-dev +apps/web/src/app/platform @bitwarden/team-platform-dev +libs/angular/src/platform @bitwarden/team-platform-dev +libs/common/src/platform @bitwarden/team-platform-dev +# Node-specifc platform files +libs/node @bitwarden/team-platform-dev +# Web utils used across app and connectors +apps/web/src/utils/ @bitwarden/team-platform-dev +# Web core and shared files +apps/web/src/app/core @bitwarden/team-platform-dev +apps/web/src/app/shared @bitwarden/team-platform-dev +apps/web/src/translation-constants.ts @bitwarden/team-platform-dev + +## Autofill team files ## +apps/browser/src/autofill @bitwarden/team-autofill-dev + +## Component Library ## +.storybook @bitwarden/team-component-library +libs/components @bitwarden/team-component-library + +## Desktop native module ## +apps/desktop/desktop_native @bitwarden/team-platform-dev + +## Multiple file owners ## +apps/browser/package.json +apps/browser/src/manifest.json +apps/browser/src/manifest.v3.json + +apps/cli/package.json + +apps/desktop/package.json +apps/desktop/src/package-lock.json +apps/desktop/src/package.json + +/apps/web/config +/apps/web/package.json + +package-lock.json + +## Locales ## +apps/browser/src/_locales/en/messages.json +apps/cli/src/locales/en/messages.json +apps/desktop/src/locales/en/messages.json +apps/web/src/locales/en/messages.json + +## DevOps team files ## +/.github/workflows @bitwarden/dept-devops diff --git a/.github/whitelist-capital-letters.txt b/.github/whitelist-capital-letters.txt index 1fbed379d64..f5a60f817ce 100644 --- a/.github/whitelist-capital-letters.txt +++ b/.github/whitelist-capital-letters.txt @@ -2,12 +2,7 @@ ./apps/browser/src/safari/desktop/Assets.xcassets/AccentColor.colorset ./apps/browser/src/safari/desktop/Assets.xcassets/AppIcon.appiconset ./apps/browser/src/safari/desktop/Base.lproj -./apps/browser/src/services/vaultTimeout ./apps/browser/store/windows/Assets -./libs/common/src/abstractions/fileDownload -./libs/common/src/abstractions/userVerification -./libs/common/src/abstractions/vaultTimeout -./libs/common/src/services/vaultTimeout ./bitwarden_license/README.md ./libs/angular/src/directives/cipherListVirtualScroll.directive.ts ./libs/angular/src/scss/webfonts/Open_Sans-italic-700.woff @@ -23,40 +18,14 @@ ./libs/angular/src/validators/inputsFieldMatch.validator.ts ./libs/angular/src/validators/notAllowedValueAsync.validator.ts ./libs/angular/src/services/theming/themeBuilder.ts -./libs/angular/src/interfaces/selectOptions.ts ./libs/common/src/misc/nodeUtils.ts ./libs/common/src/misc/linkedFieldOption.decorator.ts ./libs/common/src/misc/serviceUtils.ts ./libs/common/src/misc/serviceUtils.spec.ts -./libs/common/src/factories/accountFactory.ts -./libs/common/src/factories/globalStateFactory.ts -./libs/common/src/factories/stateFactory.ts -./libs/common/src/abstractions/userVerification/userVerification.service.abstraction.ts -./libs/common/src/abstractions/userVerification/userVerification-api.service.abstraction.ts -./libs/common/src/abstractions/platformUtils.service.ts -./libs/common/src/abstractions/stateMigration.service.ts -./libs/common/src/abstractions/fileDownload/fileDownloadBuilder.ts -./libs/common/src/abstractions/fileDownload/fileDownload.service.ts -./libs/common/src/abstractions/fileDownload/fileDownloadRequest.ts -./libs/common/src/abstractions/formValidationErrors.service.ts -./libs/common/src/abstractions/vaultTimeout/vaultTimeoutSettings.service.ts -./libs/common/src/abstractions/vaultTimeout/vaultTimeout.service.ts -./libs/common/src/abstractions/cryptoFunction.service.ts ./libs/common/src/abstractions/anonymousHub.service.ts -./libs/common/src/abstractions/appId.service.ts -./libs/common/src/services/azureFileUpload.service.ts -./libs/common/src/services/stateMigration.service.ts -./libs/common/src/services/consoleLog.service.ts -./libs/common/src/services/formValidationErrors.service.ts -./libs/common/src/services/vaultTimeout/vaultTimeoutSettings.service.ts -./libs/common/src/services/vaultTimeout/vaultTimeout.service.ts ./libs/common/src/services/anonymousHub.service.ts -./libs/common/src/services/appId.service.ts -./libs/common/src/services/noopMessaging.service.ts -./libs/common/src/services/memoryStorage.service.ts -./libs/common/src/services/bitwardenFileUpload.service.ts -./libs/common/src/services/webCryptoFunction.service.ts -./libs/common/src/interfaces/IEncrypted.ts +./libs/auth/README.md +./libs/vault/README.md ./README.md ./LICENSE_BITWARDEN.txt ./CONTRIBUTING.md @@ -93,7 +62,6 @@ ./apps/browser/src/models/browserGroupingsComponentState.ts ./apps/browser/src/models/biometricErrors.ts ./apps/browser/src/browser/safariApp.ts -./apps/browser/src/browser/browserApi.ts ./apps/browser/src/safari/desktop/ViewController.swift ./apps/browser/src/safari/desktop/Assets.xcassets/AppIcon.appiconset/Contents.json ./apps/browser/src/safari/desktop/Assets.xcassets/AccentColor.colorset/Contents.json @@ -104,21 +72,4 @@ ./apps/browser/src/safari/safari/SafariWebExtensionHandler.swift ./apps/browser/src/safari/safari/Info.plist ./apps/browser/src/safari/desktop.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist -./apps/browser/src/listeners/onCommandListener.ts -./apps/browser/src/listeners/onInstallListener.ts -./apps/browser/src/services/browserFileDownloadService.ts -./apps/browser/src/services/localBackedSessionStorage.service.spec.ts -./apps/browser/src/services/browserMessagingPrivateModeBackground.service.ts -./apps/browser/src/services/browserPlatformUtils.service.spec.ts -./apps/browser/src/services/browserMemoryStorage.service.ts -./apps/browser/src/services/vaultTimeout/vaultTimeout.service.ts -./apps/browser/src/services/browserCrypto.service.ts -./apps/browser/src/services/browserPlatformUtils.service.ts -./apps/browser/src/services/abstractions/abstractKeyGeneration.service.ts -./apps/browser/src/services/browserLocalStorage.service.ts -./apps/browser/src/services/localBackedSessionStorage.service.ts -./apps/browser/src/services/browserMessagingPrivateModePopup.service.ts -./apps/browser/src/services/browserMessaging.service.ts -./apps/browser/src/services/keyGeneration.service.ts -./apps/browser/src/services/abstractChromeStorageApi.service.ts ./SECURITY.md diff --git a/.github/workflows/auto-branch-updater.yml b/.github/workflows/auto-branch-updater.yml new file mode 100644 index 00000000000..fee2ad958f2 --- /dev/null +++ b/.github/workflows/auto-branch-updater.yml @@ -0,0 +1,42 @@ +--- +name: Auto Update Branch + +on: + push: + branches: + - 'master' + - 'rc' + paths: + - 'apps/web/**' + - 'libs/**' + - '*' + - '!*.md' + - '!*.txt' + - '.github/workflows/build-web.yml' + workflow_dispatch: + inputs: {} + +jobs: + update: + name: Update Branch + runs-on: ubuntu-22.04 + env: + _BOT_EMAIL: 106330231+bitwarden-devops-bot@users.noreply.github.com + _BOT_NAME: bitwarden-devops-bot + steps: + - name: Setup + id: setup + run: echo "branch=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT + + - name: Checkout repo + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + with: + ref: 'eu-web-${{ steps.setup.outputs.branch }}' + fetch-depth: 0 + + - name: Merge ${{ steps.setup.outputs.branch }} + run: | + git config --local user.email "${{ env._BOT_EMAIL }}" + git config --local user.name "${{ env._BOT_NAME }}" + git merge origin/${{ steps.setup.outputs.branch }} + git push diff --git a/.github/workflows/brew-bump-cli.yml b/.github/workflows/brew-bump-cli.yml index c5f7b126fe2..8273ab00e80 100644 --- a/.github/workflows/brew-bump-cli.yml +++ b/.github/workflows/brew-bump-cli.yml @@ -23,7 +23,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "brew-bump-workflow-pat" @@ -35,8 +35,7 @@ jobs: token: ${{ steps.retrieve-secrets.outputs.brew-bump-workflow-pat }} org: bitwarden tap: Homebrew/homebrew-core - cask: bitwarden-cli + formula: bitwarden-cli tag: ${{ github.ref }} revision: ${{ github.sha }} - force: false - dryrun: true + force: true diff --git a/.github/workflows/brew-bump-desktop.yml b/.github/workflows/brew-bump-desktop.yml index 876180931c6..4032f5883a0 100644 --- a/.github/workflows/brew-bump-desktop.yml +++ b/.github/workflows/brew-bump-desktop.yml @@ -23,7 +23,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "brew-bump-workflow-pat" @@ -38,5 +38,5 @@ jobs: cask: bitwarden tag: ${{ github.ref }} revision: ${{ github.sha }} - force: false + force: true dryrun: true diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index a48d8b7880b..f2441c79536 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -38,10 +38,10 @@ defaults: jobs: cloc: name: CLOC - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up cloc run: | @@ -54,11 +54,15 @@ 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 }} + node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: + - name: Checkout repo + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + - name: Get Package Version id: gen_vars run: | @@ -68,10 +72,18 @@ jobs: echo "repo_url=$repo_url" >> $GITHUB_OUTPUT echo "adj_build_number=$adj_build_num" >> $GITHUB_OUTPUT + - name: Get Node Version + id: retrieve-node-version + working-directory: ./ + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + locales-test: name: Locales Test - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - setup defaults: @@ -79,7 +91,7 @@ jobs: working-directory: apps/browser steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Testing locales - extName length run: | @@ -108,25 +120,23 @@ jobs: build: name: Build - runs-on: windows-2019 + runs-on: ubuntu-22.04 needs: - setup - locales-test env: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} - defaults: - run: - working-directory: apps/browser + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up Node uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Install node-gyp run: | @@ -137,89 +147,99 @@ jobs: run: | node --version npm --version + node-gyp --version + + - name: Build sources for reviewers + run: | + # Include hidden files in glob copy + shopt -s dotglob + + # Remove ".git" directory + rm -r .git + + # Copy root level files to source directory + mkdir browser-source + FILES=$(find . -maxdepth 1 -type f) + for FILE in $FILES; do cp "$FILE" browser-source/; done + + # Copy patches to the Browser source directory + mkdir -p browser-source/patches + cp -r patches/* browser-source/patches + + # Copy apps/browser to the Browser source directory + mkdir -p browser-source/apps/browser + cp -r apps/browser/* browser-source/apps/browser + + # Copy libs to Browser source directory + mkdir browser-source/libs + cp -r libs/* browser-source/libs + + zip -r browser-source.zip browser-source - name: NPM setup run: npm ci - working-directory: ./ + working-directory: browser-source/ - name: Build run: npm run dist + working-directory: browser-source/apps/browser - - name: Build Manifest v3 - run: npm run dist:mv3 + # - name: Build Manifest v3 + # run: npm run dist:mv3 + # working-directory: browser-source/apps/browser - name: Gulp run: gulp ci - - - name: Build sources for reviewers - shell: cmd - run: | - REM Remove ".git" directory - rmdir /S /Q ".git" - - REM Copy root level files to source directory - mkdir browser-source - copy * browser-source - - REM Copy apps\browser to Browser source directory - mkdir browser-source\apps\browser - xcopy apps\browser\* browser-source\apps\browser /E - - REM Copy libs to Browser source directory - mkdir browser-source\libs - xcopy libs\* browser-source\libs /E - - call 7z a browser-source.zip "browser-source\*" - working-directory: ./ + working-directory: browser-source/apps/browser - name: Upload Opera artifact uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 with: name: dist-opera-${{ env._BUILD_NUMBER }}.zip - path: apps/browser/dist/dist-opera.zip + path: browser-source/apps/browser/dist/dist-opera.zip if-no-files-found: error - - name: Upload Opera MV3 artifact - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 - with: - name: dist-opera-MV3-${{ env._BUILD_NUMBER }}.zip - path: apps/browser/dist/dist-opera-mv3.zip - if-no-files-found: error + # - name: Upload Opera MV3 artifact + # uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + # with: + # name: dist-opera-MV3-${{ env._BUILD_NUMBER }}.zip + # path: browser-source/apps/browser/dist/dist-opera-mv3.zip + # if-no-files-found: error - name: Upload Chrome artifact uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 with: name: dist-chrome-${{ env._BUILD_NUMBER }}.zip - path: apps/browser/dist/dist-chrome.zip + path: browser-source/apps/browser/dist/dist-chrome.zip if-no-files-found: error - - name: Upload Chrome MV3 artifact - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 - with: - name: dist-chrome-MV3-${{ env._BUILD_NUMBER }}.zip - path: apps/browser/dist/dist-chrome-mv3.zip - if-no-files-found: error + # - name: Upload Chrome MV3 artifact + # uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + # with: + # name: dist-chrome-MV3-${{ env._BUILD_NUMBER }}.zip + # path: browser-source/apps/browser/dist/dist-chrome-mv3.zip + # if-no-files-found: error - name: Upload Firefox artifact uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 with: name: dist-firefox-${{ env._BUILD_NUMBER }}.zip - path: apps/browser/dist/dist-firefox.zip + path: browser-source/apps/browser/dist/dist-firefox.zip if-no-files-found: error - name: Upload Edge artifact uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 with: name: dist-edge-${{ env._BUILD_NUMBER }}.zip - path: apps/browser/dist/dist-edge.zip + path: browser-source/apps/browser/dist/dist-edge.zip if-no-files-found: error - - name: Upload Edge MV3 artifact - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 - with: - name: dist-edge-MV3-${{ env._BUILD_NUMBER }}.zip - path: apps/browser/dist/dist-edge-mv3.zip - if-no-files-found: error + # - name: Upload Edge MV3 artifact + # uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + # with: + # name: dist-edge-MV3-${{ env._BUILD_NUMBER }}.zip + # path: browser-source/apps/browser/dist/dist-edge-mv3.zip + # if-no-files-found: error - name: Upload browser source uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 @@ -233,7 +253,7 @@ jobs: uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 with: name: coverage-${{ env._BUILD_NUMBER }}.zip - path: apps/browser/coverage/coverage-${{ env._BUILD_NUMBER }}.zip + path: browser-source/apps/browser/coverage/coverage-${{ env._BUILD_NUMBER }}.zip if-no-files-found: error build-safari: @@ -244,16 +264,17 @@ jobs: - locales-test env: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up Node uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Print environment run: | @@ -339,13 +360,13 @@ 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 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Login to Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 @@ -354,13 +375,13 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" - name: Upload Sources - uses: crowdin/github-action@102b5aa21783a64027193ef802a616140a1ca102 # v1.8.1 + uses: crowdin/github-action@ee4ab4ea2feadc0fdc3b200729c7b1c4cf4b38f3 # v1.11.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} @@ -374,7 +395,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 +437,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 4e9ce9e2bb6..ffffdccb8a7 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -35,10 +35,10 @@ defaults: jobs: cloc: name: CLOC - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up cloc run: | @@ -51,35 +51,45 @@ jobs: setup: name: Setup - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: - package_version: ${{ steps.retrieve-version.outputs.package_version }} + package_version: ${{ steps.retrieve-package-version.outputs.package_version }} + node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Get Package Version - id: retrieve-version + id: retrieve-package-version run: | PKG_VERSION=$(jq -r .version package.json) echo "package_version=$PKG_VERSION" >> $GITHUB_OUTPUT + - name: Get Node Version + id: retrieve-node-version + working-directory: ./ + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + cli: name: Build CLI ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04, macos-11] + os: [ubuntu-22.04, macos-11] runs-on: ${{ matrix.os }} needs: - setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} _WIN_PKG_FETCH_VERSION: 18.5.0 _WIN_PKG_VERSION: 3.4 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Unix Vars run: | @@ -92,7 +102,7 @@ jobs: with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Install node-gyp run: | @@ -149,11 +159,12 @@ jobs: - setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} _WIN_PKG_FETCH_VERSION: 18.5.0 _WIN_PKG_VERSION: 3.4 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup Windows builder run: | @@ -166,7 +177,7 @@ jobs: with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Install node-gyp run: | @@ -293,13 +304,13 @@ jobs: snap: name: Build Snap - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: [setup, cli] env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Print environment run: | @@ -322,7 +333,7 @@ jobs: ls -alth - name: Build snap - uses: snapcore/action-build@3457752ec9b1c79a8290b5167fce2d14df0997c1 # v1.1.2 + uses: snapcore/action-build@2ee46bc29d163c9c836f2820cc46b39664bf0de2 # v1.1.3 with: path: apps/cli/dist/snap @@ -368,7 +379,7 @@ jobs: check-failures: name: Check for failures if: always() - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - cloc - setup @@ -404,7 +415,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index b1989252dc8..8b0482c3671 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up cloc run: | @@ -55,7 +55,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Verify run: | @@ -78,12 +78,13 @@ jobs: build_number: ${{ steps.increment-version.outputs.build_number }} rc_branch_exists: ${{ steps.branch-check.outputs.rc_branch_exists }} hotfix_branch_exists: ${{ steps.branch-check.outputs.hotfix_branch_exists }} + node_version: ${{ steps.retrieve-node-version.outputs.node_version }} defaults: run: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Get Package Version id: retrieve-version @@ -130,6 +131,13 @@ jobs: echo "hotfix_branch_exists=0" >> $GITHUB_OUTPUT fi + - name: Get Node Version + id: retrieve-node-version + working-directory: ./ + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT linux: name: Linux Build @@ -138,19 +146,20 @@ jobs: - setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} defaults: run: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up Node uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Set Node options run: echo "NODE_OPTIONS=--max_old_space_size=4096" >> $GITHUB_ENV @@ -257,16 +266,17 @@ jobs: working-directory: apps/desktop env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up Node uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Set Node options run: echo "NODE_OPTIONS=--max_old_space_size=4096" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append @@ -277,7 +287,7 @@ jobs: node-gyp install $(node -v) - name: Install AST - uses: bitwarden/gh-actions/install-ast@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/install-ast@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 - name: Set up environmentF run: choco install checksum --no-progress @@ -302,7 +312,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "code-signing-vault-url, @@ -467,19 +477,20 @@ jobs: - setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} defaults: run: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up Node uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Set Node options run: echo "NODE_OPTIONS=--max_old_space_size=4096" >> $GITHUB_ENV @@ -619,19 +630,20 @@ jobs: - setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} defaults: run: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up Node uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Set Node options run: echo "NODE_OPTIONS=--max_old_space_size=4096" >> $GITHUB_ENV @@ -816,19 +828,20 @@ jobs: - setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} defaults: run: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up Node uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Set Node options run: echo "NODE_OPTIONS=--max_old_space_size=4096" >> $GITHUB_ENV @@ -1005,19 +1018,20 @@ jobs: - setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} defaults: run: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up Node uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Set Node options run: echo "NODE_OPTIONS=--max_old_space_size=4096" >> $GITHUB_ENV @@ -1181,7 +1195,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Login to Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 @@ -1190,13 +1204,13 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" - name: Upload Sources - uses: crowdin/github-action@102b5aa21783a64027193ef802a616140a1ca102 # v1.8.1 + uses: crowdin/github-action@ee4ab4ea2feadc0fdc3b200729c7b1c4cf4b38f3 # v1.11.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} @@ -1269,7 +1283,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 325d3f54df4..d0360b4a4ed 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -31,13 +31,16 @@ on: description: "Custom image tag extension" required: false +env: + _AZ_REGISTRY: bitwardenprod.azurecr.io + jobs: cloc: name: CLOC runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up cloc run: | @@ -54,21 +57,29 @@ jobs: runs-on: ubuntu-22.04 outputs: version: ${{ steps.version.outputs.value }} + node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Get GitHub sha as version id: version run: echo "value=${GITHUB_SHA:0:7}" >> $GITHUB_OUTPUT + - name: Get Node Version + id: retrieve-node-version + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + build-artifacts: name: Build artifacts runs-on: ubuntu-22.04 - needs: - - setup + needs: setup env: _VERSION: ${{ needs.setup.outputs.version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} strategy: matrix: include: @@ -80,21 +91,23 @@ jobs: npm_command: "dist:bit:selfhost" - name: "cloud-QA" npm_command: "build:bit:qa" - - name: "cloud-POC2" - npm_command: "build:bit:poc" - name: "ee" npm_command: "build:bit:ee" + - name: "cloud-euprd" + npm_command: "build:bit:euprd" + - name: "cloud-euqa" + npm_command: "build:bit:euqa" steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up Node uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: "16" + node-version: ${{ env._NODE_VERSION }} - name: Print environment run: | @@ -144,19 +157,16 @@ jobs: matrix: include: - artifact_name: cloud-QA - registries: [bitwardenprod.azurecr.io, bitwardenqa.azurecr.io] image_name: web-qa-cloud - artifact_name: ee - registries: [bitwardenprod.azurecr.io, bitwardenqa.azurecr.io] image_name: web-ee - artifact_name: selfhosted-COMMERCIAL - registries: [bitwarden, bitwardenprod.azurecr.io, bitwardenqa.azurecr.io] image_name: web env: _VERSION: ${{ needs.setup.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Check Branch to Publish env: @@ -172,21 +182,25 @@ jobs: fi ########## ACRs ########## - - name: Login to Azure - QA + - name: Login to Prod Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: - creds: ${{ secrets.AZURE_QA_KV_CREDENTIALS }} + creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} - - name: Log into QA container registry - run: az acr login -n bitwardenqa + - name: Log into Prod container registry + run: az acr login -n bitwardenprod - - name: Login to Azure - Prod + - name: Login to Azure - CI Subscription uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 with: - creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - name: Log into Prod container registry - run: az acr login -n bitwardenprod + - name: Retrieve github PAT secrets + id: retrieve-secret-pat + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + with: + keyvault: "bitwarden-ci" + secrets: "github-pat-bitwarden-devops-bot-repo-scope" - name: Download ${{ matrix.artifact_name }} artifact uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 @@ -216,60 +230,29 @@ jobs: echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT - - name: Generate tag list - id: tag-list - env: - IMAGE_TAG: ${{ steps.tag.outputs.image_tag }} - PROJECT_NAME: ${{ matrix.image_name }} - run: echo "tags=bitwardenqa.azurecr.io/${PROJECT_NAME}:${IMAGE_TAG},bitwardenprod.azurecr.io/${PROJECT_NAME}:${IMAGE_TAG}" >> $GITHUB_OUTPUT - ########## Build Image ########## - name: Extract artifact working-directory: apps/web run: unzip web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip - - name: Login to Azure - uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 - with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - - name: Retrieve github PAT secrets - id: retrieve-secret-pat - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b - with: - keyvault: "bitwarden-ci" - secrets: "github-pat-bitwarden-devops-bot-repo-scope" - - - name: Setup DCT - if: ${{ env.is_publish_branch == 'true' }} - id: setup-dct - uses: bitwarden/gh-actions/setup-docker-trust@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b - with: - azure-creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - azure-keyvault-name: "bitwarden-ci" + - name: Generate image full name + id: image-name + env: + IMAGE_TAG: ${{ steps.tag.outputs.image_tag }} + PROJECT_NAME: ${{ matrix.image_name }} + run: echo "name=$_AZ_REGISTRY/${PROJECT_NAME}:${IMAGE_TAG}" >> $GITHUB_OUTPUT - name: Build Docker image - uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 # v4.0.0 + uses: docker/build-push-action@2eb1c1961a95fc15694676618e422e8ba1d63825 # v4.1.1 with: context: apps/web file: apps/web/Dockerfile platforms: linux/amd64 push: true - tags: ${{ steps.tag-list.outputs.tags }} + tags: ${{ steps.image-name.outputs.name }} secrets: | "GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}" - - name: Push to DockerHub - if: contains(matrix.registries, 'bitwarden') && env.is_publish_branch == 'true' - env: - IMAGE_TAG: ${{ steps.tag.outputs.image_tag }} - PROJECT_NAME: ${{ matrix.image_name }} - DOCKER_CONTENT_TRUST: 1 - DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE: ${{ steps.setup-dct.outputs.dct-delegate-repo-passphrase }} - run: | - docker tag bitwardenprod.azurecr.io/$PROJECT_NAME:$IMAGE_TAG bitwarden/$PROJECT_NAME:$IMAGE_TAG - docker push bitwarden/$PROJECT_NAME:$IMAGE_TAG - - name: Log out of Docker run: docker logout @@ -277,12 +260,11 @@ jobs: crowdin-push: name: Crowdin Push if: github.ref == 'refs/heads/master' - needs: - - build-artifacts + needs: build-artifacts runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Login to Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 @@ -291,13 +273,13 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" - name: Upload Sources - uses: crowdin/github-action@102b5aa21783a64027193ef802a616140a1ca102 # v1.8.1 + uses: crowdin/github-action@ee4ab4ea2feadc0fdc3b200729c7b1c4cf4b38f3 # v1.11.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} @@ -352,7 +334,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 212a80de745..ccaf9a1a6a0 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -12,16 +12,23 @@ jobs: runs-on: ubuntu-20.04 steps: - - name: Set up Node - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 - with: - node-version: "16" - - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: fetch-depth: 0 + - name: Get Node Version + id: retrieve-node-version + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + + - name: Set up Node + uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 + with: + node-version: ${{ steps.retrieve-node-version.outputs.node_version }} + - name: Cache npm id: npm-cache uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 @@ -31,17 +38,17 @@ jobs: - name: Install Node dependencies run: npm ci - + # Manual build the storybook to resolve a chromatic/storybook bug related to TurboSnap - name: Build Storybook run: npm run build-storybook:ci - name: Publish to Chromatic - uses: chromaui/action@a89b674adf766dbde41ad9ea2b2b60b91188a0f0 + uses: chromaui/action@a45a922b9a7522a4cbb59a7bb7b288a768968924 with: token: ${{ secrets.GITHUB_TOKEN }} projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} storybookBuildDir: ./storybook-static exitOnceUploaded: true onlyChanged: true - externals: "[\"libs/components/**/*.scss\", \"libs/components/tailwind.config*.js\"]" + externals: "[\"libs/components/**/*.scss\", \"libs/components/**/*.css\", \"libs/components/tailwind.config*.js\"]" diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index 35c31dc72ff..0ff79d68296 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -23,7 +23,7 @@ jobs: crowdin_project_id: "308189" steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Login to Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 @@ -32,13 +32,13 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase" - name: Download translations - uses: bitwarden/gh-actions/crowdin@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/crowdin@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/deploy-eu-prod-web.yml b/.github/workflows/deploy-eu-prod-web.yml new file mode 100644 index 00000000000..aeb5d2c0197 --- /dev/null +++ b/.github/workflows/deploy-eu-prod-web.yml @@ -0,0 +1,60 @@ +--- +name: Deploy Web to EU-PRD Cloud + +on: + workflow_dispatch: + inputs: + tag: + description: "Branch name to deploy (examples: 'master', 'feature/sm')" + required: true + type: string + default: master + +jobs: + azure-deploy: + name: Deploy to Azure + runs-on: ubuntu-22.04 + env: + _WEB_ARTIFACT: "web-*-cloud-euprd.zip" + steps: + - name: Login to Azure - EU Subscription + uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 + with: + creds: ${{ secrets.AZURE_KV_EU_PRD_SERVICE_PRINCIPAL }} + + - name: Retrieve Storage Account connection string + id: retrieve-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + with: + keyvault: webvault-westeurope-prod + secrets: "sa-bitwarden-web-vault-dev-key-temp" + + - name: Download latest cloud asset + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + with: + workflow: build-web.yml + path: apps/web + workflow_conclusion: success + branch: ${{ github.event.inputs.tag }} + artifacts: ${{ env._WEB_ARTIFACT }} + + - name: Unzip build asset + working-directory: apps/web + run: unzip ${{ env._WEB_ARTIFACT }} + + - name: Empty container in Storage Account + run: | + az storage blob delete-batch \ + --source '$web' \ + --pattern '*' \ + --connection-string "${{ steps.retrieve-secrets.outputs.sa-bitwarden-web-vault-dev-key-temp }}" + + - name: Deploy to Azure Storage Account + working-directory: apps/web + run: | + az storage blob upload-batch \ + --source "./build" \ + --destination '$web' \ + --connection-string "${{ steps.retrieve-secrets.outputs.sa-bitwarden-web-vault-dev-key-temp }}" \ + --overwrite \ + --no-progress diff --git a/.github/workflows/deploy-eu-qa-web.yml b/.github/workflows/deploy-eu-qa-web.yml new file mode 100644 index 00000000000..0b71fbc9981 --- /dev/null +++ b/.github/workflows/deploy-eu-qa-web.yml @@ -0,0 +1,60 @@ +--- +name: Deploy Web to EU-QA Cloud + +on: + workflow_dispatch: + inputs: + tag: + description: "Branch name to deploy (examples: 'master', 'feature/sm')" + required: true + type: string + default: master + +jobs: + azure-deploy: + name: Deploy to Azure + runs-on: ubuntu-22.04 + env: + _WEB_ARTIFACT: "web-*-cloud-euqa.zip" + steps: + - name: Login to Azure - EU Subscription + uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 + with: + creds: ${{ secrets.AZURE_KV_EU_QA_SERVICE_PRINCIPAL }} + + - name: Retrieve Storage Account connection string + id: retrieve-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + with: + keyvault: webvaulteu-westeurope-qa + secrets: "sa-bitwarden-web-vault-dev-key-temp" + + - name: Download latest cloud asset + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 + with: + workflow: build-web.yml + path: apps/web + workflow_conclusion: success + branch: ${{ github.event.inputs.tag }} + artifacts: ${{ env._WEB_ARTIFACT }} + + - name: Unzip build asset + working-directory: apps/web + run: unzip ${{ env._WEB_ARTIFACT }} + + - name: Empty container in Storage Account + run: | + az storage blob delete-batch \ + --source '$web' \ + --pattern '*' \ + --connection-string "${{ steps.retrieve-secrets.outputs.sa-bitwarden-web-vault-dev-key-temp }}" + + - name: Deploy to Azure Storage Account + working-directory: apps/web + run: | + az storage blob upload-batch \ + --source "./build" \ + --destination '$web' \ + --connection-string "${{ steps.retrieve-secrets.outputs.sa-bitwarden-web-vault-dev-key-temp }}" \ + --overwrite \ + --no-progress diff --git a/.github/workflows/deploy-non-prod-web.yml b/.github/workflows/deploy-non-prod-web.yml index 45f74ff52be..8e5b8f5c9f3 100644 --- a/.github/workflows/deploy-non-prod-web.yml +++ b/.github/workflows/deploy-non-prod-web.yml @@ -7,13 +7,17 @@ on: inputs: environment: description: 'Environment' - required: true default: 'QA' type: choice options: - QA - - POC2 + workflow_call: + inputs: + environment: + description: 'Environment' + default: 'QA' + type: string jobs: setup: @@ -60,10 +64,10 @@ jobs: description: 'Deployment from branch ${{ github.ref_name }}' - name: Checkout Repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Download latest cloud asset - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-web.yml path: apps/web @@ -76,7 +80,7 @@ jobs: run: unzip ${{ env._ENVIRONMENT_ARTIFACT }} - name: Checkout Repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ env._ENVIRONMENT_BRANCH }} path: deployment diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2cc004987a1..0e8de8f1962 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-20.04 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Lint filenames (no capital characters) run: | @@ -38,12 +38,19 @@ jobs: > tmp.txt diff <(sort .github/whitelist-capital-letters.txt) <(sort tmp.txt) + - name: Get Node Version + id: retrieve-node-version + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + - name: Set up Node uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ steps.retrieve-node-version.outputs.node_version }} - name: Run linter run: | diff --git a/.github/workflows/release-browser.yml b/.github/workflows/release-browser.yml index 4a65b0d412c..32cf8d18546 100644 --- a/.github/workflows/release-browser.yml +++ b/.github/workflows/release-browser.yml @@ -22,12 +22,12 @@ defaults: jobs: setup: name: Setup - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: release-version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Branch check if: ${{ github.event.inputs.release_type != 'Dry Run' }} @@ -41,7 +41,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/release-version-check@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: release-type: ${{ github.event.inputs.release_type }} project-type: ts @@ -52,11 +52,11 @@ jobs: locales-test: name: Locales Test - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: setup steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Testing locales - extName length run: | @@ -86,7 +86,7 @@ jobs: release: name: Create GitHub Release - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - setup - locales-test @@ -103,7 +103,7 @@ jobs: - name: Download latest Release build artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-browser.yml workflow_conclusion: success @@ -116,7 +116,7 @@ jobs: - name: Dry Run - Download latest master build artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-browser.yml workflow_conclusion: success diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 5901d81740b..04a8a04f4f5 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -43,7 +43,7 @@ jobs: release-version: ${{ steps.version.outputs.version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Branch check if: ${{ github.event.inputs.release_type != 'Dry Run' }} @@ -57,7 +57,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/release-version-check@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: release-type: ${{ github.event.inputs.release_type }} project-type: ts @@ -78,7 +78,7 @@ jobs: - name: Download all Release artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-cli.yml path: apps/cli @@ -87,7 +87,7 @@ jobs: - name: Dry Run - Download all artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-cli.yml path: apps/cli @@ -141,7 +141,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Login to Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 @@ -150,19 +150,17 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "snapcraft-store-token" - name: Install Snap - uses: samuelmeuli/action-snapcraft@10d7d0a84d9d86098b19f872257df314b0bd8e2d # v1.2.0 - with: - snapcraft_token: ${{ steps.retrieve-secrets.outputs.snapcraft-store-token }} + uses: samuelmeuli/action-snapcraft@d33c176a9b784876d966f80fb1b461808edc0641 # v2.1.1 - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-cli.yml path: apps/cli @@ -172,7 +170,7 @@ jobs: - name: Dry Run - Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-cli.yml path: apps/cli @@ -182,8 +180,10 @@ jobs: - name: Publish Snap & logout if: ${{ github.event.inputs.release_type != 'Dry Run' }} + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ steps.retrieve-secrets.outputs.snapcraft-store-token }} run: | - snapcraft push bw_${{ env._PKG_VERSION }}_amd64.snap --release stable + snapcraft upload bw_${{ env._PKG_VERSION }}_amd64.snap --release stable snapcraft logout choco: @@ -195,7 +195,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Login to Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 @@ -204,7 +204,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "cli-choco-api-key" @@ -220,7 +220,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-cli.yml path: apps/cli/dist @@ -230,7 +230,7 @@ jobs: - name: Dry Run - Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-cli.yml path: apps/cli/dist @@ -243,7 +243,7 @@ jobs: shell: pwsh run: | cd dist - choco push + choco push --source=https://push.chocolatey.org/ npm: name: Publish NPM @@ -254,7 +254,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Login to Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 @@ -263,14 +263,14 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "npm-api-key" - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-cli.yml path: apps/cli/build @@ -280,7 +280,7 @@ jobs: - name: Dry Run - Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-cli.yml path: apps/cli/build diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index 74d02aefc79..b2e568632b5 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -21,9 +21,10 @@ jobs: release-channel: ${{ steps.release-channel.outputs.channel }} branch-name: ${{ steps.branch.outputs.branch-name }} build_number: ${{ steps.increment-version.outputs.build_number }} + node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Branch check run: | @@ -47,7 +48,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/release-version-check@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: release-type: 'Initial Release' project-type: ts @@ -104,18 +105,26 @@ jobs: echo "branch-name=$branch_name" >> $GITHUB_OUTPUT + - name: Get Node Version + id: retrieve-node-version + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT + linux: name: Linux Build runs-on: ubuntu-20.04 needs: setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} defaults: run: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ needs.setup.outputs.branch-name }} @@ -124,7 +133,7 @@ jobs: with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Set Node options run: echo "NODE_OPTIONS=--max_old_space_size=4096" >> $GITHUB_ENV @@ -209,9 +218,10 @@ jobs: working-directory: apps/desktop env: _PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ needs.setup.outputs.branch-name }} @@ -220,7 +230,7 @@ jobs: with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Set Node options run: echo "NODE_OPTIONS=--max_old_space_size=4096" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append @@ -231,7 +241,7 @@ jobs: node-gyp install $(node -v) - name: Install AST - uses: bitwarden/gh-actions/install-ast@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/install-ast@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 - name: Set up environment run: choco install checksum --no-progress @@ -249,7 +259,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "code-signing-vault-url, @@ -401,12 +411,13 @@ jobs: needs: setup env: _PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} defaults: run: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ needs.setup.outputs.branch-name }} @@ -415,7 +426,7 @@ jobs: with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Set Node options run: echo "NODE_OPTIONS=--max_old_space_size=4096" >> $GITHUB_ENV @@ -530,12 +541,13 @@ jobs: - macos-build env: _PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} defaults: run: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ needs.setup.outputs.branch-name }} @@ -544,7 +556,7 @@ jobs: with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Set Node options run: echo "NODE_OPTIONS=--max_old_space_size=4096" >> $GITHUB_ENV @@ -732,12 +744,13 @@ jobs: - macos-build env: _PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} defaults: run: working-directory: apps/desktop steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: ref: ${{ needs.setup.outputs.branch-name }} @@ -746,7 +759,7 @@ jobs: with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ env._NODE_VERSION }} - name: Set Node options run: echo "NODE_OPTIONS=--max_old_space_size=4096" >> $GITHUB_ENV @@ -932,7 +945,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "aws-electron-access-id, @@ -1011,7 +1024,7 @@ jobs: - release steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Setup git config run: | diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 8eaf148be98..b9ffa80e512 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -47,13 +47,13 @@ defaults: jobs: setup: name: Setup - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: release-version: ${{ steps.version.outputs.version }} release-channel: ${{ steps.release-channel.outputs.channel }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Branch check if: ${{ github.event.inputs.release_type != 'Dry Run' }} @@ -67,9 +67,9 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/release-version-check@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: - release-type: ${{ github.event.inputs.release_type }} + release-type: ${{ inputs.release_type }} project-type: ts file: apps/desktop/src/package.json monorepo: true @@ -110,7 +110,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "aws-electron-access-id, @@ -123,7 +123,7 @@ jobs: - name: Download all artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-desktop.yml workflow_conclusion: success @@ -132,7 +132,7 @@ jobs: - name: Dry Run - Download all artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-desktop.yml workflow_conclusion: success @@ -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: ${{ github.event.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: ${{ github.event.inputs.release_type != 'Dry Run' && github.event.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: ${{ github.event.inputs.release_type != 'Dry Run' && github.event.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 }} @@ -185,14 +185,14 @@ jobs: --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com - name: Get checksum files - uses: bitwarden/gh-actions/get-checksum@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-checksum@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: packages_dir: "apps/desktop/artifacts" file_path: "apps/desktop/artifacts/sha256-checksums.txt" - 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' && github.event.inputs.github_release }} + if: ${{ steps.release-channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' && github.event.inputs.github_release == 'true' }} env: PKG_VERSION: ${{ steps.version.outputs.version }} RELEASE_CHANNEL: ${{ steps.release-channel.outputs.channel }} @@ -247,14 +247,14 @@ jobs: snap: name: Deploy Snap - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: setup - if: inputs.snap_publish + if: ${{ github.event.inputs.snap_publish == 'true' }} env: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: - name: Checkout Repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Login to Azure uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 @@ -263,15 +263,13 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "snapcraft-store-token" - name: Install Snap uses: samuelmeuli/action-snapcraft@d33c176a9b784876d966f80fb1b461808edc0641 # v2.1.1 - with: - snapcraft_token: ${{ steps.retrieve-secrets.outputs.snapcraft-store-token }} - name: Setup run: mkdir dist @@ -279,7 +277,7 @@ jobs: - name: Download Snap artifact if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-desktop.yml workflow_conclusion: success @@ -289,7 +287,7 @@ jobs: - name: Dry Run - Download Snap artifact if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-desktop.yml workflow_conclusion: success @@ -299,6 +297,8 @@ jobs: - name: Deploy to Snap Store if: ${{ github.event.inputs.release_type != 'Dry Run' }} + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ steps.retrieve-secrets.outputs.snapcraft-store-token }} run: | snapcraft upload bitwarden_${{ env._PKG_VERSION }}_amd64.snap --release stable snapcraft logout @@ -308,12 +308,12 @@ jobs: name: Deploy Choco runs-on: windows-2019 needs: setup - if: inputs.choco_publish + if: ${{ github.event.inputs.choco_publish == 'true' }} env: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: - name: Checkout Repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Print Environment run: | @@ -327,7 +327,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "cli-choco-api-key" @@ -345,7 +345,7 @@ jobs: - name: Download choco artifact if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-desktop.yml workflow_conclusion: success @@ -355,7 +355,7 @@ jobs: - name: Dry Run - Download choco artifact if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-desktop.yml workflow_conclusion: success @@ -366,5 +366,5 @@ jobs: - name: Push to Chocolatey if: ${{ github.event.inputs.release_type != 'Dry Run' }} shell: pwsh - run: choco push + run: choco push --source=https://push.chocolatey.org/ working-directory: apps/desktop/dist diff --git a/.github/workflows/release-qa-web.yml b/.github/workflows/release-qa-web.yml deleted file mode 100644 index b50e48753c4..00000000000 --- a/.github/workflows/release-qa-web.yml +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: QA - Web Release - -on: - workflow_dispatch: {} - -jobs: - cfpages-deploy: - name: Deploy Web Vault to QA CloudFlare Pages branch - runs-on: ubuntu-20.04 - steps: - - name: Create GitHub deployment - uses: chrnorm/deployment-action@d42cde7132fcec920de534fffc3be83794335c00 # v2.0.5 - id: deployment - with: - token: '${{ secrets.GITHUB_TOKEN }}' - initial-status: 'in_progress' - environment-url: http://vault.qa.bitwarden.pw - environment: 'Web Vault - QA' - description: 'Deployment from branch ${{ github.ref_name }}' - - - name: Checkout Repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - - - name: Download latest cloud asset - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b - with: - workflow: build-web.yml - path: apps/web - workflow_conclusion: success - branch: ${{ github.ref_name }} - artifacts: web-*-cloud-QA.zip - - - name: Unzip cloud asset - working-directory: apps/web - run: unzip web-*-cloud-QA.zip - - - name: Checkout Repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - with: - ref: cf-pages-qa - path: deployment - - - name: Setup git config - run: | - git config --global user.name "GitHub Action Bot" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global url."https://github.com/".insteadOf ssh://git@github.com/ - git config --global url."https://".insteadOf ssh:// - - - name: Deploy CloudFlare Pages - run: | - rm -rf ./* - cp -R ../apps/web/build/* . - working-directory: deployment - - - name: Push new ver to cf-pages-qa - run: | - if [ -n "$(git status --porcelain)" ]; then - git add . - git commit -m "Deploy ${{ github.ref_name }} to QA Cloudflare pages" - git push -u origin cf-pages-qa - else - echo "No changes to commit!"; - fi - working-directory: deployment - - - name: Update deployment status to Success - if: ${{ success() }} - uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - environment-url: http://vault.qa.bitwarden.pw - state: 'success' - deployment-id: ${{ steps.deployment.outputs.deployment_id }} - - - name: Update deployment status to Failure - if: ${{ failure() }} - uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - environment-url: http://vault.qa.bitwarden.pw - state: 'failure' - deployment-id: ${{ steps.deployment.outputs.deployment_id }} diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index e5aea6b3778..cbf83dc9cdf 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -15,16 +15,19 @@ on: - Redeploy - Dry Run +env: + _AZ_REGISTRY: bitwardenprod.azurecr.io + jobs: setup: name: Setup - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: release_version: ${{ steps.version.outputs.version }} tag_version: ${{ steps.version.outputs.tag }} steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Branch check if: ${{ github.event.inputs.release_type != 'Dry Run' }} @@ -38,7 +41,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/release-version-check@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: release-type: ${{ github.event.inputs.release_type }} project-type: ts @@ -46,10 +49,9 @@ jobs: monorepo: true monorepo-project: web - self-host: name: Release self-host docker - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: setup env: _BRANCH_NAME: ${{ github.ref_name }} @@ -65,43 +67,7 @@ jobs: echo "Github Release Option: $_RELEASE_OPTION" - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - - ########## DockerHub ########## - - name: Setup DCT - id: setup-dct - uses: bitwarden/gh-actions/setup-docker-trust@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b - with: - azure-creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - azure-keyvault-name: "bitwarden-ci" - - - name: Pull branch image - run: | - if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then - docker pull bitwarden/web:latest - else - docker pull bitwarden/web:$_BRANCH_NAME - fi - - - name: Docker Tag version - run: | - if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then - docker tag bitwarden/web:latest bitwarden/web:$_RELEASE_VERSION - else - docker tag bitwarden/web:$_BRANCH_NAME bitwarden/web:$_RELEASE_VERSION - fi - - - name: Docker Push version - if: ${{ github.event.inputs.release_type != 'Dry Run' }} - env: - DOCKER_CONTENT_TRUST: 1 - DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE: ${{ steps.setup-dct.outputs.dct-delegate-repo-passphrase }} - run: docker push bitwarden/web:$_RELEASE_VERSION - - - name: Log out of Docker and disable Docker Notary - run: | - docker logout - echo "DOCKER_CONTENT_TRUST=0" >> $GITHUB_ENV + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 ########## ACR ########## - name: Login to Azure - PROD Subscription @@ -112,38 +78,46 @@ jobs: - name: Login to Azure ACR run: az acr login -n bitwardenprod - - name: Tag version - env: - REGISTRY: bitwardenprod.azurecr.io + - name: Pull branch image run: | if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then - docker tag bitwarden/web:latest $REGISTRY/web:$_RELEASE_VERSION - - docker tag bitwarden/web:latest $REGISTRY/web-sh:$_RELEASE_VERSION + docker pull $_AZ_REGISTRY/web:latest else - docker tag bitwarden/web:$_BRANCH_NAME $REGISTRY/web:$_RELEASE_VERSION + docker pull $_AZ_REGISTRY/web:$_BRANCH_NAME + fi - docker tag bitwarden/web:$_BRANCH_NAME $REGISTRY/web-sh:$_RELEASE_VERSION + - name: Tag version + run: | + if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then + docker tag $_AZ_REGISTRY/web:latest $_AZ_REGISTRY/web:dryrun + docker tag $_AZ_REGISTRY/web:latest $_AZ_REGISTRY/web-sh:dryrun + else + docker tag $_AZ_REGISTRY/web:$_BRANCH_NAME $_AZ_REGISTRY/web:$_RELEASE_VERSION + docker tag $_AZ_REGISTRY/web:$_BRANCH_NAME $_AZ_REGISTRY/web-sh:$_RELEASE_VERSION + docker tag $_AZ_REGISTRY/web:$_BRANCH_NAME $_AZ_REGISTRY/web:latest + docker tag $_AZ_REGISTRY/web:$_BRANCH_NAME $_AZ_REGISTRY/web-sh:latest fi - name: Push version - if: ${{ github.event.inputs.release_type != 'Dry Run' }} - env: - REGISTRY: bitwardenprod.azurecr.io run: | - docker push $REGISTRY/web:$_RELEASE_VERSION - - docker push $REGISTRY/web-sh:$_RELEASE_VERSION + if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then + docker push $_AZ_REGISTRY/web:dryrun + docker push $_AZ_REGISTRY/web-sh:dryrun + else + docker push $_AZ_REGISTRY/web:$_RELEASE_VERSION + docker push $_AZ_REGISTRY/web-sh:$_RELEASE_VERSION + docker push $_AZ_REGISTRY/web:latest + docker push $_AZ_REGISTRY/web-sh:latest + fi - name: Log out of Docker run: docker logout ghpages-deploy: - name: Deploy to GitHub Pages - runs-on: ubuntu-20.04 - needs: - - setup + name: Create Deploy PR for GitHub Pages + runs-on: ubuntu-22.04 + needs: setup env: _RELEASE_VERSION: ${{ needs.setup.outputs.release_version }} _TAG_VERSION: ${{ needs.setup.outputs.tag_version }} @@ -156,13 +130,13 @@ jobs: - name: Retrieve bot secrets id: retrieve-bot-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: bitwarden-ci secrets: "github-pat-bitwarden-devops-bot-repo-scope" - name: Checkout GH pages repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 with: repository: bitwarden/web-vault-pages path: ghpages-deployment @@ -170,7 +144,7 @@ jobs: - name: Download latest cloud asset if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-web.yml path: assets @@ -180,7 +154,7 @@ jobs: - name: Dry Run - Download latest cloud asset if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-web.yml path: assets @@ -191,7 +165,7 @@ jobs: - name: Unzip build asset working-directory: assets run: unzip web-*-cloud-COMMERCIAL.zip - + - name: Create new branch run: | cd ${{ github.workspace }}/ghpages-deployment @@ -200,12 +174,12 @@ jobs: git config --global url."https://github.com/".insteadOf ssh://git@github.com/ git config --global url."https://".insteadOf ssh:// git checkout -b ${_BRANCH} - + - name: Copy build files run: | rm -rf ${{ github.workspace }}/ghpages-deployment/* cp -Rf ${{ github.workspace }}/assets/build/* ghpages-deployment/ - + - name: Commit and push changes working-directory: ghpages-deployment run: | @@ -233,7 +207,7 @@ jobs: release: name: Create GitHub Release - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - setup - self-host @@ -253,7 +227,7 @@ jobs: - name: Download latest build artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-web.yml path: apps/web/artifacts @@ -264,7 +238,7 @@ jobs: - name: Dry Run - Download latest build artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/download-artifacts@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: workflow: build-web.yml path: apps/web/artifacts diff --git a/.github/workflows/staged-rollout-desktop.yml b/.github/workflows/staged-rollout-desktop.yml index bd27e05cc69..a3eacb868fd 100644 --- a/.github/workflows/staged-rollout-desktop.yml +++ b/.github/workflows/staged-rollout-desktop.yml @@ -26,7 +26,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "aws-electron-access-id, diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 870b5a6d980..59f52bd1964 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,17 +22,24 @@ defaults: jobs: test: name: Run tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + + - name: Get Node Version + id: retrieve-node-version + run: | + NODE_NVMRC=$(cat .nvmrc) + NODE_VERSION=${NODE_NVMRC/v/''} + echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' - node-version: '18' + node-version: ${{ steps.retrieve-node-version.outputs.node_version }} - name: Print environment run: | @@ -72,6 +79,9 @@ jobs: - windows-latest steps: + - name: Rust version check + run: rustup --version + - name: Install gnome-keyring if: ${{ matrix.os=='ubuntu-latest' }} run: | @@ -79,14 +89,7 @@ jobs: sudo apt-get install -y gnome-keyring dbus-x11 - name: Checkout repo - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 - - - name: Install rust - uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1.0.7 - with: - toolchain: stable - profile: minimal - override: true + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Build working-directory: ./apps/desktop/desktop_native diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index 991b25eef94..7b1a787d946 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -18,7 +18,7 @@ jobs: version_number: ${{ steps.version.outputs.new-version }} steps: - name: Checkout Branch - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Calculate bumped version id: version @@ -39,15 +39,10 @@ jobs: echo "new-version=$NEW_VER" >> $GITHUB_OUTPUT trigger_version_bump: - name: "Trigger desktop version bump workflow" - runs-on: ubuntu-22.04 - needs: - - setup - steps: - - name: Bump version to ${{ needs.setup.outputs.version_number }} - uses: ./.github/workflows/version-bump.yml - secrets: - AZURE_PROD_KV_CREDENTIALS: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - with: - version_number: ${{ needs.setup.outputs.version_number }} - client: "Desktop" + name: Bump version to ${{ needs.setup.outputs.version_number }} + needs: setup + uses: ./.github/workflows/version-bump.yml + with: + version_number: ${{ needs.setup.outputs.version_number }} + bump_desktop: true + secrets: inherit diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 752e4baba2c..14755097b0e 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -1,19 +1,26 @@ --- name: Version Bump +run-name: Version Bump - ${{ github.ref_name }} 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,12 +30,10 @@ on: version_number: required: true type: string - client: - required: true - type: string - secrets: - AZURE_PROD_KV_CREDENTIALS: - required: true + bump_desktop: + description: "Desktop Project Version Bump" + type: boolean + default: false defaults: run: @@ -36,20 +41,20 @@ 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@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - 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 - uses: bitwarden/gh-actions/get-keyvault-secrets@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/get-keyvault-secrets@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 with: keyvault: "bitwarden-ci" secrets: "github-gpg-private-key, github-gpg-private-key-passphrase" @@ -65,72 +70,166 @@ 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 # ######################## ### Browser + - name: Browser - Verify input version + if: ${{ inputs.bump_browser == true }} + env: + NEW_VERSION: ${{ inputs.version_number }} + run: | + CURRENT_VERSION=$(cat package.json | jq -r '.version') + + # Error if version has not changed. + if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then + echo "Version has not changed." + exit 1 + fi + + # Check if version is newer. + printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V + if [ $? -eq 0 ]; then + echo "Version check successful." + fi + working-directory: apps/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' }} - uses: bitwarden/gh-actions/version-bump@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + if: ${{ inputs.bump_browser == true }} + uses: bitwarden/gh-actions/version-bump@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 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' }} - uses: bitwarden/gh-actions/version-bump@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + if: ${{ inputs.bump_browser == true }} + uses: bitwarden/gh-actions/version-bump@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 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 prettier --write apps/browser/src/manifest.v3.json ### CLI + - name: CLI - Verify input version + if: ${{ inputs.bump_cli == true }} + env: + NEW_VERSION: ${{ inputs.version_number }} + run: | + CURRENT_VERSION=$(cat package.json | jq -r '.version') + + # Error if version has not changed. + if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then + echo "Version has not changed." + exit 1 + fi + + # Check if version is newer. + printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V + if [ $? -eq 0 ]; then + echo "Version check successful." + fi + working-directory: apps/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: Desktop - Verify input version + if: ${{ inputs.bump_desktop == true }} + env: + NEW_VERSION: ${{ inputs.version_number }} + run: | + CURRENT_VERSION=$(cat package.json | jq -r '.version') + + # Error if version has not changed. + if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then + echo "Version has not changed." + exit 1 + fi + + # Check if version is newer. + printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V + if [ $? -eq 0 ]; then + echo "Version check successful." + fi + working-directory: apps/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: Web - Verify input version + if: ${{ inputs.bump_web == true }} + env: + NEW_VERSION: ${{ inputs.version_number }} + run: | + CURRENT_VERSION=$(cat package.json | jq -r '.version') + + # Error if version has not changed. + if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then + echo "Version has not changed." + exit 1 + fi + + # Check if version is newer. + printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V + if [ $? -eq 0 ]; then + echo "Version check successful." + fi + working-directory: apps/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} ######################## @@ -154,27 +253,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' }} + if: ${{ (github.ref == 'refs/heads/master') && (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' }} + if: ${{ (github.ref == 'refs/heads/master') && (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 " @@ -186,5 +284,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/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index 39f2436b722..49388c11f82 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -8,4 +8,4 @@ on: jobs: call-workflow: - uses: bitwarden/gh-actions/.github/workflows/workflow-linter.yml@c86ced0dc8c9daeecf057a6333e6f318db9c5a2b + uses: bitwarden/gh-actions/.github/workflows/workflow-linter.yml@f1125802b1ccae8c601d7c4f61ce39ea254b10c8 diff --git a/.storybook/main.ts b/.storybook/main.ts index a7f12f469ba..37339b7343c 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -4,6 +4,7 @@ import remarkGfm from "remark-gfm"; const config: StorybookConfig = { stories: [ + "../libs/auth/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/components/src/**/*.mdx", "../libs/components/src/**/*.stories.@(js|jsx|ts|tsx)", "../apps/web/src/**/*.mdx", @@ -15,6 +16,7 @@ const config: StorybookConfig = { "@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-a11y", + "@storybook/addon-designs", { name: "@storybook/addon-docs", options: { diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 86ab6ab3748..0bc9fc5bacb 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -6,19 +6,91 @@ import docJson from "../documentation.json"; setCompodocJson(docJson); const decorator = componentWrapperDecorator( - (story) => ` - -
${story}
-
- -
${story}
-
- - ` + (story) => { + return ` + +
+ ${story} +
+
+ +
+ ${story} +
+
+ +
+ ${story} +
+
+ +
+ ${story} +
+
+ + + + + + `; + }, + ({ globals }) => { + return { theme: `${globals["theme"]}` }; + } ); const preview: Preview = { decorators: [decorator], + globalTypes: { + theme: { + description: "Global theme for components", + defaultValue: "both", + toolbar: { + title: "Theme", + icon: "circlehollow", + items: [ + { + title: "Light & Dark", + value: "both", + icon: "sidebyside", + }, + { + title: "Light", + value: "light", + icon: "sun", + }, + { + title: "Dark", + value: "dark", + icon: "moon", + }, + { + title: "Nord", + value: "nord", + left: "⛰", + }, + { + title: "Solarized", + value: "solarized", + left: "☯", + }, + ], + dynamicTitle: true, + }, + }, + }, parameters: { actions: { argTypesRegex: "^on[A-Z].*" }, controls: { @@ -29,6 +101,7 @@ const preview: Preview = { }, options: { storySort: { + method: "alphabetical", order: ["Documentation", ["Introduction", "Colors", "Icons"], "Component Library"], }, }, diff --git a/.vscode/settings.json b/.vscode/settings.json index 48fd373db46..27e3a9b293a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,9 @@ { - "cSpell.words": ["Csprng", "Popout", "Reprompt", "takeuntil"] + "cSpell.words": ["Csprng", "decryptable", "Popout", "Reprompt", "takeuntil"], + "search.exclude": { + "**/locales/[^e]*/messages.json": true, + "**/locales/*[^n]/messages.json": true, + "**/_locales/[^e]*/messages.json": true, + "**/_locales/*[^n]/messages.json": true + } } diff --git a/apps/browser/config/base.json b/apps/browser/config/base.json index 81b11cd38b7..348f00d1f36 100644 --- a/apps/browser/config/base.json +++ b/apps/browser/config/base.json @@ -1,6 +1,7 @@ { "dev_flags": {}, "flags": { - "showPasswordless": true + "showPasswordless": true, + "enableCipherKeyEncryption": false } } diff --git a/apps/browser/config/development.json b/apps/browser/config/development.json index 972812a9c59..eafd0ffd878 100644 --- a/apps/browser/config/development.json +++ b/apps/browser/config/development.json @@ -6,6 +6,7 @@ } }, "flags": { - "showPasswordless": true + "showPasswordless": true, + "enableCipherKeyEncryption": false } } diff --git a/apps/browser/config/production.json b/apps/browser/config/production.json index b04d1531a2f..f57c3d9bc38 100644 --- a/apps/browser/config/production.json +++ b/apps/browser/config/production.json @@ -1,3 +1,5 @@ { - "flags": {} + "flags": { + "enableCipherKeyEncryption": false + } } diff --git a/apps/browser/package.json b/apps/browser/package.json index b29ab9c27b3..4e17fd4288d 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,12 +1,11 @@ { "name": "@bitwarden/browser", - "version": "2023.4.0", + "version": "2023.9.2", "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/postcss.config.js b/apps/browser/postcss.config.js new file mode 100644 index 00000000000..c4513687e89 --- /dev/null +++ b/apps/browser/postcss.config.js @@ -0,0 +1,4 @@ +/* eslint-disable no-undef */ +module.exports = { + plugins: [require("tailwindcss"), require("autoprefixer"), require("postcss-nested")], +}; diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 71cd3d1a66b..c3ba5c4a950 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "التعبئة التلقائية" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "إنشاء كلمة مرور (تم النسخ)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "لا توجد تسجيلات دخول مطابقة." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "افتح خزنتك" }, @@ -338,6 +362,9 @@ "other": { "message": "الأخرى" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "قيِّم هذه الإضافة" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "تحديث" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "إظهار خيارات قائمة السياق" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "الميزة غير متوفرة" }, - "updateKey": { - "message": "لا يمكنك استخدام هذه المِيزة حتى تحديث مفتاح التشفير الخاص بك." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "العضوية المميزة" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "1 جيغابايت وحدة تخزين مشفرة لمرفقات الملفات." }, - "ppremiumSignUpTwoStep": { - "message": "خيارات تسجيل الدخول الإضافية من خطوتين مثل YubiKey و FIDO U2F و Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "نظافة كلمة المرور، صحة الحساب، وتقارير خرق البيانات للحفاظ على سلامة خزنتك." @@ -1412,13 +1445,13 @@ "message": "استنساخ" }, "passwordGeneratorPolicyInEffect": { - "message": "One or more organization policies are affecting your generator settings." + "message": "واحدة أو أكثر من سياسات المؤسسة تؤثر على إعدادات المولدات الخاصة بك." }, "vaultTimeoutAction": { - "message": "Vault timeout action" + "message": "إجراء مهلة المخزن" }, "lock": { - "message": "Lock", + "message": "قفل", "description": "Verb form: to make secure or inaccesible by" }, "trash": { @@ -1426,7 +1459,7 @@ "description": "Noun: a special folder to hold deleted items" }, "searchTrash": { - "message": "Search trash" + "message": "البحث عن سلة المهملات" }, "permanentlyDeleteItem": { "message": "حذف العنصر بشكل دائم" @@ -1435,43 +1468,40 @@ "message": "هل أنت متأكد من أنك تريد حذف هذا العنصر بشكل دائم؟" }, "permanentlyDeletedItem": { - "message": "Item permanently deleted" + "message": "تم حذف العنصر بشكل دائم" }, "restoreItem": { "message": "استعادة العنصر" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { - "message": "Item restored" + "message": "تم استعادة العنصر" }, "vaultTimeoutLogOutConfirmation": { - "message": "Logging out will remove all access to your vault and requires online authentication after the timeout period. Are you sure you want to use this setting?" + "message": "سيؤدي تسجيل الخروج إلى إزالة جميع إمكانية الوصول إلى خزنتك ويتطلب المصادقة عبر الإنترنت بعد انتهاء المهلة. هل أنت متأكد من أنك تريد استخدام هذا الإعداد؟" }, "vaultTimeoutLogOutConfirmationTitle": { - "message": "Timeout action confirmation" + "message": "تأكيد إجراء المهلة" }, "autoFillAndSave": { - "message": "Auto-fill and save" + "message": "التعبئة التلقائية والحفظ" }, "autoFillSuccessAndSavedUri": { - "message": "Item auto-filled and URI saved" + "message": "تم تعبئة العنصر تلقائياً وحفظ عنوان URI" }, "autoFillSuccess": { - "message": "Item auto-filled " + "message": "ملء العنصر تلقائياً " }, "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": "تحذير: هذه صفحة HTTP غير آمنة، وأي معلومات تقدمها يمكن رؤيتها وتغييرها من قبل الآخرين. تم حفظ تسجيل الدخول هذا في الأصل على صفحة آمنة (HTTPS)." }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "هل مازلت ترغب في ملء هذا الدخول؟" }, "autofillIframeWarning": { - "message": "The form is hosted by a different domain than the URI of your saved login. Choose OK to auto-fill anyway, or Cancel to stop." + "message": "يتم استضافة النموذج من قبل نطاق مختلف عن عنوان URI الخاص بتسجيل الدخول المحفوظ. اختر موافق للملء التلقائي على أي حال، أو ألغ للتوقف." }, "autofillIframeWarningTip": { - "message": "To prevent this warning in the future, save this URI, $HOSTNAME$, to your Bitwarden login item for this site.", + "message": "لمنع هذا التحذير في المستقبل، حفظ هذا الرابط، $HOSTNAME$ إلى عنصر تسجيل الدخول الخاص بك Bitwarden لهذا الموقع.", "placeholders": { "hostname": { "content": "$1", @@ -1480,22 +1510,22 @@ } }, "setMasterPassword": { - "message": "Set master password" + "message": "تعيين كلمة مرور رئيسية" }, "currentMasterPass": { - "message": "Current master password" + "message": "كلمة المرور الرئيسية الحالية" }, "newMasterPass": { - "message": "New master password" + "message": "كلمة مرور رئيسية جديدة" }, "confirmNewMasterPass": { - "message": "Confirm new master password" + "message": "تأكيد كلمة المرور الرئيسية الجديدة" }, "masterPasswordPolicyInEffect": { - "message": "One or more organization policies require your master password to meet the following requirements:" + "message": "1 - تتطلب سياسة واحدة أو أكثر من سياسات المؤسسة كلمة مرورك الرئيسية لتلبية المتطلبات التالية:" }, "policyInEffectMinComplexity": { - "message": "Minimum complexity score of $SCORE$", + "message": "الحد الأدنى لدرجة التعقيد $SCORE$", "placeholders": { "score": { "content": "$1", @@ -1504,7 +1534,7 @@ } }, "policyInEffectMinLength": { - "message": "Minimum length of $LENGTH$", + "message": "الحد الأدنى لطول $LENGTH$", "placeholders": { "length": { "content": "$1", @@ -1513,16 +1543,16 @@ } }, "policyInEffectUppercase": { - "message": "Contain one or more uppercase characters" + "message": "يحتوي على حرف كبير واحد أو أكثر" }, "policyInEffectLowercase": { - "message": "Contain one or more lowercase characters" + "message": "يحتوي على واحد أو أكثر من الأحرف الصغيرة" }, "policyInEffectNumbers": { - "message": "Contain one or more numbers" + "message": "يحتوي على رقم واحد أو أكثر" }, "policyInEffectSpecial": { - "message": "Contain one or more of the following special characters $CHARS$", + "message": "يحتوي على واحد أو أكثر من الأحرف الخاصة التالية $CHARS$", "placeholders": { "chars": { "content": "$1", @@ -1534,7 +1564,7 @@ "message": "كلمة المرور الرئيسية الجديدة لا تفي بمتطلبات السياسة العامة." }, "acceptPolicies": { - "message": "By checking this box you agree to the following:" + "message": "من خلال تحديد هذا المربع فإنك توافق على ما يلي:" }, "acceptPoliciesRequired": { "message": "Terms of Service and Privacy Policy have not been acknowledged." @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1618,13 +1654,13 @@ "message": "An organization policy is affecting your ownership options." }, "excludedDomains": { - "message": "Excluded domains" + "message": "النطاقات المستبعدة" }, "excludedDomainsDesc": { - "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." + "message": "Bitwarden لن يطلب حفظ تفاصيل تسجيل الدخول لهذه النطاقات. يجب عليك تحديث الصفحة حتى تصبح التغييرات سارية المفعول." }, "excludedDomainsInvalidDomain": { - "message": "$DOMAIN$ is not a valid domain", + "message": "$DOMAIN$ نطاق غير صالح", "placeholders": { "domain": { "content": "$1", @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "جاري تصدير الخزنة الشخصية" }, - "exportingPersonalVaultDescription": { - "message": "سيتم تصدير فقط عناصر الخزنة الشخصية المرتبطة بـ $EMAIL$. لن يتم إدراج عناصر خزنة المؤسسة.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "إصدار الخادم" }, - "selfHosted": { - "message": "استضافة ذاتية" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 0c12f845d51..483dc2c14f1 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Avto-doldurma" }, + "autoFillLogin": { + "message": "Giriş avto-doldurma" + }, + "autoFillCard": { + "message": "Kart avto-doldurma" + }, + "autoFillIdentity": { + "message": "Kimlik avto-doldurma" + }, "generatePasswordCopied": { "message": "Parol yarat (kopyalandı)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Uyğun gələn hesab yoxdur." }, + "noCards": { + "message": "Kart yoxdur" + }, + "noIdentities": { + "message": "Kimlik yoxdur" + }, + "addLoginMenu": { + "message": "Giriş əlavə et" + }, + "addCardMenu": { + "message": "Kart əlavə et" + }, + "addIdentityMenu": { + "message": "Kimlik əlavə et" + }, "unlockVaultMenu": { "message": "Anbarın kilidini açın" }, @@ -338,6 +362,9 @@ "other": { "message": "Digər" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Anbar vaxt bitməsi əməliyyatınızı dəyişdirmək üçün bir kilid açma üsulu quraşdırın." + }, "rateExtension": { "message": "Genişləndirməni qiymətləndir" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Güncəllə" }, + "notificationUnlockDesc": { + "message": "Avto-doldurma tələblərini tamamlamaq üçün Bitwarden anbarınızın kilidini açın." + }, + "notificationUnlock": { + "message": "Kilidi aç" + }, "enableContextMenuItem": { "message": "Konteks menyu seçimlərini göstər" }, @@ -662,7 +695,7 @@ "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, "exportVault": { - "message": "Anbarı ixrac et" + "message": "Anbarı xaricə köçür" }, "fileFormat": { "message": "Fayl formatı" @@ -672,19 +705,19 @@ "description": "WARNING (should stay in capitalized letters if the language permits)" }, "confirmVaultExport": { - "message": "Anbarın ixracını təsdiqləyin" + "message": "Anbarın xaricə köçürülməsini təsdiqləyin" }, "exportWarningDesc": { - "message": "Bu ixrac faylındakı anbar verilənləriniz şifrələnməmiş formatdadır. İxrac edilən faylı, güvənli olmayan kanallar üzərində saxlamamalı və ya göndərməməlisiniz (e-poçt kimi). Bu faylı işiniz bitdikdən sonra dərhal silin." + "message": "Xaricə köçürdüyünüz bu fayldakı datanız şifrələnməmiş formatdadır. Bu faylı güvənli olmayan kanallar (e-poçt kimi) üzərində saxlamamalı və ya göndərməməlisiniz. İşiniz bitdikdən sonra faylı dərhal silin." }, "encExportKeyWarningDesc": { - "message": "Bu ixrac faylı, hesabınızın şifrələmə açarını istifadə edərək verilənlərinizi şifrələyir. Hesabınızın şifrələmə açarını döndərsəniz, bu ixrac faylının şifrəsini aça bilməyəcəyiniz üçün yenidən ixrac etməli olacaqsınız." + "message": "Xaricə köçürdüyünüz bu fayldakı data, hesabınızın şifrələmə açarı istifadə edilərək şifrələnir. Hesabınızın şifrələmə açarını dəyişdirsəniz, bu faylın şifrəsini aça bilməyəcəksiniz və onu yenidən xaricə köçürməli olacaqsınız." }, "encExportAccountWarningDesc": { "message": "Hesab şifrələmə açarları, hər Bitwarden istifadəçi hesabı üçün unikaldır, buna görə də şifrələnmiş bir ixracı, fərqli bir hesaba idxal edə bilməzsiniz." }, "exportMasterPassword": { - "message": "Anbar verilənlərinizi ixrac etmək üçün ana parolunuzu daxil edin." + "message": "Anbar datanızı xaricə köçürmək üçün ana parolunuzu daxil edin." }, "shared": { "message": "Paylaşılan" @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Özəllik əlçatmazdır" }, - "updateKey": { - "message": "Şifrələmə açarınızı güncəlləyənə qədər bu özəlliyi istifadə edə bilməzsiniz." + "encryptionKeyMigrationRequired": { + "message": "Şifrələmə açarının daşınması tələb olunur. Şifrələmə açarınızı güncəlləmək üçün zəhmət olmasa veb anbar üzərindən giriş edin." }, "premiumMembership": { "message": "Premium üzvlük" @@ -786,11 +819,11 @@ "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ı." + "message": "Anbarınızın təhlükəsizliyini təmin etmək üçün parol gigiyenası, hesab sağlamlığı və data pozuntusu hesabatları." }, "ppremiumSignUpTotp": { "message": "Anbarınızdakı hesablar üçün TOTP təsdiqləmə kodu (2FA) yaradıcısı." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Elementi bərpa et" }, - "restoreItemConfirmation": { - "message": "Elementi bərpa etmək istədiyinizə əminsiniz?" - }, "restoredItem": { "message": "Element bərpa edildi" }, @@ -1462,16 +1492,16 @@ "message": "Element avto-dolduruldu" }, "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": "Xəbərdarlıq: Bu, güvənli olmayan bir HTTP səhifəsidir və göndərdiyiniz istənilən məlumat başqaları tərəfindən görünə və dəyişdirilə bilər. Bu Giriş, orijinal olaraq güvənli (HTTPS) bir səhifədə saxlanılmışdır." }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "Hələ də bu girişi doldurmaq istəyirsiniz?" }, "autofillIframeWarning": { - "message": "The form is hosted by a different domain than the URI of your saved login. Choose OK to auto-fill anyway, or Cancel to stop." + "message": "Form sahibliyi, saxlanılmış girişinizin URI-ından fərqli bir domen tərəfindən həyata keçirilir. Yenə də avto-doldurmaq üçün \"Oldu\"ya, dayandırmaq üçün \"İmtina\"ya basın." }, "autofillIframeWarningTip": { - "message": "To prevent this warning in the future, save this URI, $HOSTNAME$, to your Bitwarden login item for this site.", + "message": "Gələcəkdə bu xəbərdarlığın qarşısını almaq üçün, $HOSTNAME$ URI-nı bu sayt üçün Bitwarden giriş elementinizdə saxlayın.", "placeholders": { "hostname": { "content": "$1", @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Brauzer biometrikləri bu cihazda dəstəklənmir." }, + "biometricsFailedTitle": { + "message": "Biometrik uğursuzdur" + }, + "biometricsFailedDesc": { + "message": "Biometriklər tamamlana bilmir, ana parol istifadə etməyi düşünün və ya çıxış edin. Bu problem davam edərsə, zəhmət olmasa Bitwarden dəstəyi ilə əlaqə saxlayın." + }, "nativeMessaginPermissionErrorTitle": { "message": "İcazə verilmədi" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Şəxsi anbarın ixracı" }, - "exportingPersonalVaultDescription": { - "message": "Yalnız $EMAIL$ ilə əlaqəli şəxsi anbar elementləri ixrac ediləcək. Təşkilat anbar elementləri daxil edilmir.", + "exportingIndividualVaultDescription": { + "message": "Yalnız $EMAIL$ ilə əlaqələndirilmiş fərdi anbar elementləri xaricə köçürüləcək. Təşkilat anbar elementləri daxil edilməyəcək. Yalnız anbar element məlumatları xaricə köçürüləcək və əlaqələndirilmiş qoşmalar daxil edilməyəcək.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server Versiyası" }, - "selfHosted": { - "message": "Öz-özünə sahiblik edən" + "selfHostedServer": { + "message": "öz-özünə sahiblik edən" }, "thirdParty": { "message": "Üçüncü tərəf" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "Cihazınıza bir bildiriş göndərildi." }, - "logInInitiated": { - "message": "Giriş etmə başladıldı" + "loginInitiated": { + "message": "Giriş başladıldı" }, "exposedMasterPassword": { "message": "İfşa olunmuş ana parol" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Bölgə" + "loggingInOn": { + "message": "Giriş edilir" }, "opensInANewWindow": { "message": "Yeni bir pəncərədə açılır" }, + "deviceApprovalRequired": { + "message": "Cihaz təsdiqi tələb olunur. Aşağıdan bir təsdiq variantı seçin:" + }, + "rememberThisDevice": { + "message": "Bu cihazı xatırla" + }, + "uncheckIfPublicDevice": { + "message": "Hər kəsə açıq bir cihaz istifadə edirsinizsə işarəni götürün" + }, + "approveFromYourOtherDevice": { + "message": "Digər cihazınızdan təsdiqləyin" + }, + "requestAdminApproval": { + "message": "Admin təsdiqini tələb et" + }, + "approveWithMasterPassword": { + "message": "Ana parolla təsdiqlə" + }, + "ssoIdentifierRequired": { + "message": "Təşkilat SSO identifikatoru tələb olunur." + }, "eu": { "message": "AB", "description": "European Union" }, - "us": { - "message": "ABŞ", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Müraciət rədd edildi. Bu səhifəyə baxmaq üçün icazəniz yoxdur." + }, + "general": { + "message": "Ümumi" + }, + "display": { + "message": "Ekran" + }, + "accountSuccessfullyCreated": { + "message": "Hesab uğurla yaradıldı!" + }, + "adminApprovalRequested": { + "message": "Admin təsdiqi tələb olunur" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Tələbiniz admininizə göndərildi." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Təsdiqləndikdən sonra məlumatlandırılacaqsınız." + }, + "troubleLoggingIn": { + "message": "Girişdə problem var?" + }, + "loginApproved": { + "message": "Giriş təsdiqləndi" + }, + "userEmailMissing": { + "message": "İstifadəçi e-poçtu əskikdir" + }, + "deviceTrusted": { + "message": "Cihaz güvənlidir" + }, + "inputRequired": { + "message": "Giriş lazımdır." + }, + "required": { + "message": "tələb olunur" + }, + "search": { + "message": "Axtar" + }, + "inputMinLength": { + "message": "Giriş, ən azı $COUNT$ simvol uzunluğunda olmalıdır.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Giriş uzunluğu $COUNT$ simvolu aşmamalıdır.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Aşağıdakı simvollara icazə verilmir: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Giriş dəyəri ən azı $MIN$ olmalıdır.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Giriş dəyəri $MAX$ dəyərini aşmamalıdır.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 və ya daha çox e-poçt yararsızdır" + }, + "inputTrimValidator": { + "message": "Giriş, yalnız boşluq ehtiva etməməlidir.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Giriş, bir e-poçt ünvanı deyil." + }, + "fieldsNeedAttention": { + "message": "Yuxarıdakı $COUNT$ sahənin diqqətinizə ehtiyacı var.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Seç --" + }, + "multiSelectPlaceholder": { + "message": "-- Filtrləmək üçün yazın --" + }, + "multiSelectLoading": { + "message": "Seçimlər alınır..." + }, + "multiSelectNotFound": { + "message": "Heç bir element tapılmadı" + }, + "multiSelectClearAll": { + "message": "Hamısını təmizlə" + }, + "plusNMore": { + "message": "daha $QUANTITY$ ədəd", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Alt menyu" + }, + "toggleCollapse": { + "message": "Yığcamlaşdırmanı aç/bağla", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Domen ləqəbi" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "\"Ana parolu təkrar soruş\" özəlliyi olan elementlər səhifə yüklənəndə avto-doldurulmur. \"Səhifə yüklənəndə avto-doldurma\" özəlliyi söndürülüb.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "\"Səhifə yüklənəndə avto-doldurma\" özəlliyi ilkin tənzimləməni istifadə etmək üzrə tənzimləndi.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Bu sahəyə düzəliş etmək üçün \"Ana parolu təkrar soruş\"u söndürün", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 80800f4aa38..d0c1bac2bb7 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Аўтазапаўненне" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Генерыраваць пароль (з капіяваннем)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Няма адпаведных лагінаў." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Разблакіраваць сховішча" }, @@ -338,6 +362,9 @@ "other": { "message": "Iншае" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Ацаніць пашырэнне" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Абнавіць" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Паказваць параметры кантэкстнага меню" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Функцыя недаступна" }, - "updateKey": { - "message": "Вы не зможаце выкарыстоўваць гэту функцыю, пакуль не абнавіце свой ключ шыфравання." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Прэміяльны статус" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "1 ГБ зашыфраванага сховішча для далучаных файлаў." }, - "ppremiumSignUpTwoStep": { - "message": "Дадатковыя варыянты двухэтапнага ўваходу, такія як YubiKey, FIDO U2F і Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "Гігіена пароляў, здароўе ўліковага запісу і справаздачы аб уцечках даных для забеспячэння бяспекі вашага сховішча." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Аднавіць элемент" }, - "restoreItemConfirmation": { - "message": "Вы сапраўды хочаце аднавіць гэты элемент?" - }, "restoredItem": { "message": "Элемент адноўлены" }, @@ -1462,16 +1492,16 @@ "message": "Аўтазапоўнены элемент" }, "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": "Папярэджанне: гэта старонка HTTP не абаронена. Любая інфармацыя, якую вы адпраўляеце тэарэтычна можа перахоплена і зменена любым карыстальнікам. Гэты лагін першапачаткова захаваны на абароненай старонцы (HTTPS)." }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "Вы ўсё яшчэ хочаце запоўніць гэты лагін?" }, "autofillIframeWarning": { - "message": "The form is hosted by a different domain than the URI of your saved login. Choose OK to auto-fill anyway, or Cancel to stop." + "message": "Форма размешчана на іншым дамене, які адрозніваецца ад URI вашага захаванага лагіна. Націсніце \"Добра\", каб усё роўна запоўніць або \"Скасаваць\" для спынення." }, "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", @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Біяметрыя ў браўзеры не падтрымліваецца на гэтай прыладзе." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Дазволы не прадастаўлены" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Экспартаванне асабістага сховішча" }, - "exportingPersonalVaultDescription": { - "message": "Будуць экспартаваны толькі асабістыя элементы сховішча, якія звязаны з $EMAIL$. Элементы сховішча арганізацыі не будуць уключаны.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Версія сервера" }, - "selfHosted": { - "message": "Уласнае размяшчэнне" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Іншы пастаўшчык" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "Апавяшчэнне было адпраўлена на вашу прыладу." }, - "logInInitiated": { - "message": "Ініцыяваны ўваход" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Скампраметаваны асноўны пароль" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Адкрываць у новым акне" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Доступ забаронены. У вас не дастаткова правоў для прагляду гэтай старонкі." + }, + "general": { + "message": "Асноўныя" + }, + "display": { + "message": "Адлюстраванне" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index b17692bc83b..c96fadbd422 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -14,7 +14,7 @@ "message": "Впишете се или създайте нов абонамент, за да достъпите защитен трезор." }, "createAccount": { - "message": "Създаване на абонамент" + "message": "Създаване на акаунт" }, "login": { "message": "Вписване" @@ -91,6 +91,15 @@ "autoFill": { "message": "Автоматично дописване" }, + "autoFillLogin": { + "message": "Авт. попълване на данни за вход" + }, + "autoFillCard": { + "message": "Самопопълваща се карта" + }, + "autoFillIdentity": { + "message": "Самопопълваща се самоличност" + }, "generatePasswordCopied": { "message": "Генериране на парола (копирана)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Няма съвпадащи записи." }, + "noCards": { + "message": "Няма карти" + }, + "noIdentities": { + "message": "Няма самоличности" + }, + "addLoginMenu": { + "message": "Добавяне на запис за вход" + }, + "addCardMenu": { + "message": "Добавяне на карта" + }, + "addIdentityMenu": { + "message": "Добавяне на самоличност" + }, "unlockVaultMenu": { "message": "Отключете трезора си" }, @@ -338,6 +362,9 @@ "other": { "message": "Други" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Задайте метод за отключване, за да може да промените действието при изтичане на времето за достъп до трезора." + }, "rateExtension": { "message": "Оценяване на разширението" }, @@ -464,7 +491,7 @@ "message": "Грешен код за потвърждаване" }, "valueCopied": { - "message": "$VALUE$ — копирано", + "message": "Копирано е $VALUE$", "description": "Value has been copied to the clipboard.", "placeholders": { "value": { @@ -534,7 +561,7 @@ "message": "Копирана парола" }, "uri": { - "message": "Адрес" + "message": "Унифициран идентификатор на ресурс" }, "uriPosition": { "message": "Адрес $POSITION$", @@ -547,7 +574,7 @@ } }, "newUri": { - "message": "Нов адрес" + "message": "Нов унифициран идентификатор на ресурс" }, "addedItem": { "message": "Елементът е добавен" @@ -628,7 +655,13 @@ "message": "Да се обнови ли паролата в Bitwarden?" }, "notificationChangeSave": { - "message": "Да, нека се обнови сега" + "message": "Осъвременяване" + }, + "notificationUnlockDesc": { + "message": "Отключете трезора си в Битуорден, за да завършите заявката за автоматично попълване." + }, + "notificationUnlock": { + "message": "Отключване" }, "enableContextMenuItem": { "message": "Показване на опции в контекстното меню" @@ -675,7 +708,7 @@ "message": "Потвърждаване на изнасянето на трезора" }, "exportWarningDesc": { - "message": "Данните от трезора ви ще се изнесат в незащитен формат. Не го пращайте по незащитени канали като е-поща. Изтрийте файла незабавно след като свършите работата си с него." + "message": "Този износ съдържа данни на трезора ви в некриптиран формат. Не трябва да съхранявате или изпращате износния файл през незащитени канали (като имейл). Изтрийте файла моментално след като свършите работата си с него." }, "encExportKeyWarningDesc": { "message": "При изнасяне данните се шифрират с ключа ви. Ако го смените, ще трябва наново да ги изнесете, защото няма да може да дешифрирате настоящия файл." @@ -690,10 +723,10 @@ "message": "Споделено" }, "learnOrg": { - "message": "Разберете повече за организациите" + "message": "Научете за организациите" }, "learnOrgConfirmation": { - "message": "Битуорден позволява да споделяте части от трезора си чрез използването на организация. Искате ли да научите повече от сайта bitwarden.com?" + "message": "Битуорден позволява да споделяте елементи от трезора си а други, използвайки организация. Бихте ли посетили сайта bitwarden.com, за да научите повече?" }, "moveToOrganization": { "message": "Преместване в организация" @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Функцията е недостъпна" }, - "updateKey": { - "message": "Трябва да обновите шифриращия си ключ, за да използвате тази възможност." + "encryptionKeyMigrationRequired": { + "message": "Необходима е промяна на шифриращия ключ. Впишете се в трезора си по уеб, за да обновите своя шифриращ ключ." }, "premiumMembership": { "message": "Платен абонамент" @@ -772,7 +805,7 @@ "message": "Управление на абонамента" }, "premiumManageAlert": { - "message": "Можете да управлявате абонамента си през сайта bitwarden.com. Искате ли да го посетите сега?" + "message": "Може да управлявате членството си в мрежата на трезора в bitwarden.com. Искате ли да посетите уебсайта сега?" }, "premiumRefresh": { "message": "Опресняване на абонамента" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "1 GB пространство за файлове, които се шифрират." }, - "ppremiumSignUpTwoStep": { - "message": "Двустепенно удостоверяване чрез YubiKey, FIDO U2F и Duo." + "premiumSignUpTwoStepOptions": { + "message": "Частно двустепенно удостоверяване чрез YubiKey и Duo." }, "ppremiumSignUpReports": { "message": "Проверки в списъците с публикувани пароли, проверка на регистрациите и доклади за пробивите в сигурността, което спомага трезорът ви да е допълнително защитен." @@ -892,7 +925,7 @@ "message": "Регистрацията е защитена с двустепенно удостоверяване, но никой от настроените доставчици на удостоверяване не се поддържа от този браузър." }, "noTwoStepProviders2": { - "message": "Пробвайте с поддържан уеб браузър (като Chrome или Firefox) и други доставчици на удостоверяване, които се поддържат от браузърите (като специални програми за удостоверяване)." + "message": "Употребявайте поддържан браузър (като Chrome, Firefox) и/или добавете други доставчици на удостоверяване, които се поддържат по-добре от браузърите (като специални програми за удостоверяване)." }, "twoStepOptions": { "message": "Настройки на двустепенното удостоверяване" @@ -911,10 +944,10 @@ "description": "'Authy' and 'Google Authenticator' are product names and should not be translated." }, "yubiKeyTitle": { - "message": "Устройство YubiKey OTP" + "message": "Ключ за сигурност YubiKey OTP" }, "yubiKeyDesc": { - "message": "Използвайте устройство на YubiKey, за да влезете в абонамента си. Поддържат се моделите YubiKey 4, 4 Nano, 4C и NEO." + "message": "Използвайте ключа за сигурност YubiKey, за да влезете в акаунта си. Работи с устройствата YubiKey 4, 4 Nano, 4C и NEO." }, "duoDesc": { "message": "Удостоверяване чрез Duo Security, с ползване на приложението Duo Mobile, SMS, телефонен разговор или устройство U2F.", @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Възстановяване на запис" }, - "restoreItemConfirmation": { - "message": "Сигурни ли сте, че искате да възстановите записа?" - }, "restoredItem": { "message": "Записът е възстановен" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Устройството не поддържа потвърждаване с биометрични данни." }, + "biometricsFailedTitle": { + "message": "Неуспешно удостоверяване чрез биометрични данни" + }, + "biometricsFailedDesc": { + "message": "Удостоверяването чрез биометрични данни не може да бъде завършено. Опитайте да използвате главната си парола или се отпишете. Ако този проблем продължи да се случва, свържете се с поддръжката на Битуорден." + }, "nativeMessaginPermissionErrorTitle": { "message": "Правото не е дадено" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Изнасяне на личния трезор" }, - "exportingPersonalVaultDescription": { - "message": "Ще бъдат изнесени само записите от личния трезор свързан с $EMAIL$. Записите в трезора на организацията няма да бъдат включени.", + "exportingIndividualVaultDescription": { + "message": "Ще бъдат изнесени само отделните записи в трезора, които са свързани с $EMAIL$. Записите от трезора на организацията няма да бъдат включени. Ще бъде изнесена само информацията за записите от трезора, а свързаните прикачени елементи няма да бъдат включени.", "placeholders": { "email": { "content": "$1", @@ -2080,11 +2116,11 @@ "serverVersion": { "message": "Версия на сървъра" }, - "selfHosted": { - "message": "Собствен хостинг" + "selfHostedServer": { + "message": "собствен хостинг" }, "thirdParty": { - "message": "Third-party" + "message": "Трета страна" }, "thirdPartyServerMessage": { "message": "Connected to third-party server implementation, $SERVERNAME$. Please verify bugs using the official server, or report them to the third-party server.", @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Към устройството Ви е изпратено известие." }, - "logInInitiated": { + "loginInitiated": { "message": "Вписването е стартирано" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Регион" + "loggingInOn": { + "message": "Вписване в" }, "opensInANewWindow": { "message": "Отваря се в нов прозорец" }, + "deviceApprovalRequired": { + "message": "Изисква се одобрение на устройството. Изберете начин за одобрение по-долу:" + }, + "rememberThisDevice": { + "message": "Запомняне на това устройство" + }, + "uncheckIfPublicDevice": { + "message": "Махнете отметката, ако използвате публично устройство" + }, + "approveFromYourOtherDevice": { + "message": "Одобряване с другото Ви устройство" + }, + "requestAdminApproval": { + "message": "Подаване на заявка за одобрение от администратор" + }, + "approveWithMasterPassword": { + "message": "Одобряване с главната парола" + }, + "ssoIdentifierRequired": { + "message": "Идентификаторът за еднократна идентификация на организация е задължителен." + }, "eu": { "message": "ЕС", "description": "European Union" }, - "us": { - "message": "САЩ", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Отказан достъп. Нямате право за преглед на страницата." + }, + "general": { + "message": "Общи" + }, + "display": { + "message": "Външен вид" + }, + "accountSuccessfullyCreated": { + "message": "Регистрацията е създадена успешно!" + }, + "adminApprovalRequested": { + "message": "Заявено е одобрение от администратор" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Вашата заявка беше изпратена до администратора Ви." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Ще получите известие, когато тя бъде одобрена." + }, + "troubleLoggingIn": { + "message": "Имате проблем с вписването?" + }, + "loginApproved": { + "message": "Вписването е одобрено" + }, + "userEmailMissing": { + "message": "Липсва е-поща на потребителя" + }, + "deviceTrusted": { + "message": "Устройството е доверено" + }, + "inputRequired": { + "message": "Полето е задължтелно да бъде попълнено." + }, + "required": { + "message": "задължително" + }, + "search": { + "message": "Търсене" + }, + "inputMinLength": { + "message": "Въведеният в полето текст трябва да бъде с дължина поне $COUNT$ знака.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Полето не може да съдържа повече от $COUNT$ знака.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Следните знаци не са позволени: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Въведената стойност трябва да бъде поне $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Въведената стойност не трябва да бъде по-голяма от $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 или повече е-пощи са неправилни" + }, + "inputTrimValidator": { + "message": "Въведеното не може да съдържа само интервали.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Въведеният в полето текст не е адрес на е-поща." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ поле(та) по-горе се нуждае/ят от вниманието Ви.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Изберете --" + }, + "multiSelectPlaceholder": { + "message": "-- Пишете тук за филтриране --" + }, + "multiSelectLoading": { + "message": "Зареждане на опциите…" + }, + "multiSelectNotFound": { + "message": "Няма намерени елементи" + }, + "multiSelectClearAll": { + "message": "Изчистване на всичко" + }, + "plusNMore": { + "message": "+ още $QUANTITY$", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Подменю" + }, + "toggleCollapse": { + "message": "Превключване на свиването", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Псевдонимен домейн" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Записите с включено изискване за повторно въвеждане на главната парола не могат да бъдат попълвани автоматично при зареждане на страницата. Автоматичното попълване при зареждане на страницата е изключено.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Автоматичното попълване при зареждане на страницата използва настройката си по подразбиране.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Изключете повторното въвеждане на главната парола, за да редактирате това поле", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 76143e7619a..f8c5fb8c85b 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "স্বতঃপূরণ" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "পাসওয়ার্ড তৈরি করুন (অনুলিপিকৃত)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "কোনও মিলত লগইন নেই।" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "অন্যান্য" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "এক্সটেনশনটি মূল্যায়ন করুন" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "হ্যাঁ, এখনই হালনাগাদ করুন" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "বৈশিষ্ট্য অনুপলব্ধ" }, - "updateKey": { - "message": "আপনি আপনার এনক্রিপশন কী হালনাগাদ না করা পর্যন্ত এই বৈশিষ্ট্যটি ব্যবহার করতে পারবেন না।" + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "প্রিমিয়াম সদস্য" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "ফাইল সংযুক্তির জন্য ১ জিবি এনক্রিপ্টেড স্থান।" }, - "ppremiumSignUpTwoStep": { - "message": "YubiKey, FIDO U2F, ও Duo এর মতো অতিরিক্ত দ্বি-পদক্ষেপ লগইন বিকল্পগুলি।" + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "আপনার ভল্টটি সুরক্ষিত রাখতে পাসওয়ার্ড স্বাস্থ্যকরন, অ্যাকাউন্ট স্বাস্থ্য এবং ডেটা লঙ্ঘনের প্রতিবেদন।" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "বস্তু পুনরুদ্ধার" }, - "restoreItemConfirmation": { - "message": "আপনি কি নিশ্চিত যে আপনি এই বস্তুটি পুনরুদ্ধার করতে চান?" - }, "restoredItem": { "message": "বস্তু পুনরুদ্ধারকৃত" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "ব্রাউজার বায়োমেট্রিক্স এই ডিভাইসে সমর্থিত নয়।" }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "অনুমতি দেওয়া হয়নি" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index b3047a98789..d5d1bbb16a6 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API server URL" }, "webVaultUrl": { "message": "Web vault server URL" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Item restored" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 91bbfdb48b4..af85e586c80 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Emplenament automàtic" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Genera contrasenya (copiada)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No hi ha inicis de sessió coincidents." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "1. Desbloquegeu la caixa forta." }, @@ -338,6 +362,9 @@ "other": { "message": "Altres" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Configura un mètode de desbloqueig per canviar l'acció del temps d'espera de la caixa forta." + }, "rateExtension": { "message": "Valora aquesta extensió" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Actualitza" }, + "notificationUnlockDesc": { + "message": "Desbloquegeu la vostra caixa forta de Bitwarden per completar la sol·licitud d'emplenament automàtic." + }, + "notificationUnlock": { + "message": "Desbloqueja" + }, "enableContextMenuItem": { "message": "Mostra les opcions del menú contextual" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Característica no disponible" }, - "updateKey": { - "message": "No podeu utilitzar aquesta característica fins que actualitzeu la vostra clau de xifratge." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Subscripció Premium" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restaura l'element" }, - "restoreItemConfirmation": { - "message": "Esteu segur que voleu restaurar aquest element?" - }, "restoredItem": { "message": "Element restaurat" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "La biometria del navegador no és compatible amb aquest dispositiu." }, + "biometricsFailedTitle": { + "message": "La biometria ha fallat" + }, + "biometricsFailedDesc": { + "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" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "S'està exportant la caixa forta personal" }, - "exportingPersonalVaultDescription": { - "message": "Només s'exportaran els elements personals de la caixa forta associats a $EMAIL$. Els elements de la caixa forta de l'organització no s'inclouran.", + "exportingIndividualVaultDescription": { + "message": "Només s'exportaran els elements de la caixa forta individuals associats a $EMAIL$. Els elements de la caixa de l'organització no s'inclouran. Només s'exportarà la informació de l'element de la caixa forta i no inclourà els fitxers adjunts associats.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Versió del servidor" }, - "selfHosted": { - "message": "Autoallotjat" + "selfHostedServer": { + "message": "autoallotjat" }, "thirdParty": { "message": "Tercers" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "S'ha enviat una notificació al vostre dispositiu." }, - "logInInitiated": { + "loginInitiated": { "message": "S'ha iniciat la sessió" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Regió" + "loggingInOn": { + "message": "Inici de sessió en" }, "opensInANewWindow": { "message": "S'obri en una finestra nova" }, + "deviceApprovalRequired": { + "message": "Cal l'aprovació del dispositiu. Seleccioneu una opció d'aprovació a continuació:" + }, + "rememberThisDevice": { + "message": "Recorda aquest dispositiu" + }, + "uncheckIfPublicDevice": { + "message": "Desmarqueu si utilitzeu un dispositiu públic" + }, + "approveFromYourOtherDevice": { + "message": "Aproveu des d'un altre dispositiu vostre" + }, + "requestAdminApproval": { + "message": "Sol·liciteu l'aprovació de l'administrador" + }, + "approveWithMasterPassword": { + "message": "Aprova amb contrasenya mestra" + }, + "ssoIdentifierRequired": { + "message": "Es requereix un identificador SSO de l'organització." + }, "eu": { "message": "UE", "description": "European Union" }, - "us": { - "message": "EUA", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Accés denegat. No teniu permís per veure aquesta pàgina." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Mostra" + }, + "accountSuccessfullyCreated": { + "message": "Compte creat correctament!" + }, + "adminApprovalRequested": { + "message": "S'ha sol·licitat l'aprovació de l'administrador" + }, + "adminApprovalRequestSentToAdmins": { + "message": "La vostra sol·licitud s'ha enviat a l'administrador." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Se us notificarà una vegada aprovat." + }, + "troubleLoggingIn": { + "message": "Teniu problemes per iniciar la sessió?" + }, + "loginApproved": { + "message": "S'ha aprovat l'inici de sessió" + }, + "userEmailMissing": { + "message": "Falta el correu electrònic de l'usuari" + }, + "deviceTrusted": { + "message": "Dispositiu de confiança" + }, + "inputRequired": { + "message": "L'entrada és obligatòria." + }, + "required": { + "message": "obligatori" + }, + "search": { + "message": "Cerca" + }, + "inputMinLength": { + "message": "L'entrada ha de tenir com a mínim $COUNT$ caràcters.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "L'entrada no ha de superar $COUNT$ caràcters de longitud.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Els següents caràcters no estan permesos:\n$CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "El valor d'entrada ha de ser com a mínim $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "El valor d'entrada no ha de ser superior a $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 o més correus no són vàlids" + }, + "inputTrimValidator": { + "message": "L'entrada no ha de contenir només espais en blanc.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "L'entrada no és una adreça de correu electrònic." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ camp(s) de dalt necessiten la vostra atenció.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Selecciona --" + }, + "multiSelectPlaceholder": { + "message": "-- Escriviu per filtrar --" + }, + "multiSelectLoading": { + "message": "Obtenint opcions..." + }, + "multiSelectNotFound": { + "message": "No s'ha trobat cap element" + }, + "multiSelectClearAll": { + "message": "Esborra-ho tot" + }, + "plusNMore": { + "message": "+ $QUANTITY$ més", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenú" + }, + "toggleCollapse": { + "message": "Redueix/Amplia", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index d83282e08a4..e9b3bf997cd 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Automatické vyplňování" }, + "autoFillLogin": { + "message": "Automaticky vyplnit přihlášení" + }, + "autoFillCard": { + "message": "Automaticky vyplnit kartu" + }, + "autoFillIdentity": { + "message": "Automaticky vyplnit identitu" + }, "generatePasswordCopied": { "message": "Vygenerovat heslo a zkopírovat do schránky" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Žádné odpovídající přihlašovací údaje" }, + "noCards": { + "message": "Žádné karty" + }, + "noIdentities": { + "message": "Žádné identity" + }, + "addLoginMenu": { + "message": "Přidat přihlašovací údaje" + }, + "addCardMenu": { + "message": "Přidat kartu" + }, + "addIdentityMenu": { + "message": "Přidat identitu" + }, "unlockVaultMenu": { "message": "Odemknout Váš trezor" }, @@ -338,6 +362,9 @@ "other": { "message": "Ostatní" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Nastavte metodu odemknutí, abyste změnili časový limit Vašeho trezoru." + }, "rateExtension": { "message": "Ohodnotit rozšíření" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Aktualizovat" }, + "notificationUnlockDesc": { + "message": "Pro dokončení požadavku na automatické vyplnění odemkněte Váš trezor na Bitwardenu." + }, + "notificationUnlock": { + "message": "Odemknout" + }, "enableContextMenuItem": { "message": "Zobrazit volby v kontextovém menu" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Funkce je nedostupná" }, - "updateKey": { - "message": "Dokud neaktualizujete svůj šifrovací klíč, nemůžete tuto funkci použít." + "encryptionKeyMigrationRequired": { + "message": "Vyžaduje se migrace šifrovacího klíče. Pro aktualizaci šifrovacího klíče se přihlaste přes webový trezor." }, "premiumMembership": { "message": "Prémiové členství" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Obnovit položku" }, - "restoreItemConfirmation": { - "message": "Opravdu chcete tuto položku obnovit?" - }, "restoredItem": { "message": "Položka byla obnovena" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Biometrie v prohlížeči není na tomto zařízení podporována." }, + "biometricsFailedTitle": { + "message": "Biometrika selhala" + }, + "biometricsFailedDesc": { + "message": "Biometriku nelze dokončit, zvažte použití hlavního hesla nebo odhlášení. Pokud to přetrvává, kontaktujte podporu Bitwardenu." + }, "nativeMessaginPermissionErrorTitle": { "message": "Oprávnění nebylo uděleno" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exportování osobního trezoru" }, - "exportingPersonalVaultDescription": { - "message": "Budou exportovány jen osobní položky trezoru spojené s účtem $EMAIL$. Nebudou zahrnuty položky trezoru v organizaci.", + "exportingIndividualVaultDescription": { + "message": "Budou exportovány jen osobní položky trezoru spojené s $EMAIL$. Položky trezoru organizace nebudou zahrnuty. Budou exportovány jen informace o položkách trezoru a nebudou zahrnuty související přílohy.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Verze serveru" }, - "selfHosted": { - "message": "Vlastní hosting" + "selfHostedServer": { + "message": "vlastní hosting" }, "thirdParty": { "message": "Tretí strana" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Na Vaše zařízení bylo odesláno oznámení." }, - "logInInitiated": { + "loginInitiated": { "message": "Bylo zahájeno přihlášení" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Přihlašování na" }, "opensInANewWindow": { "message": "Otevře se v novém okně" }, + "deviceApprovalRequired": { + "message": "Vyžaduje se schválení zařízení. Vyberte možnost schválení níže:" + }, + "rememberThisDevice": { + "message": "Zapamatovat toto zařízení" + }, + "uncheckIfPublicDevice": { + "message": "Odškrtněte, pokud používáte veřejné zařízení" + }, + "approveFromYourOtherDevice": { + "message": "Schválit s mým dalším zařízením" + }, + "requestAdminApproval": { + "message": "Žádost o schválení správcem" + }, + "approveWithMasterPassword": { + "message": "Schválit hlavním heslem" + }, + "ssoIdentifierRequired": { + "message": "Je vyžadován SSO identifikátor organizace." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Přístup byl odepřen. Nemáte oprávnění k zobrazení této stránky." + }, + "general": { + "message": "Obecné" + }, + "display": { + "message": "Zobrazení" + }, + "accountSuccessfullyCreated": { + "message": "Účet byl úspěšně vytvořen!" + }, + "adminApprovalRequested": { + "message": "Bylo vyžádáno schválení správcem" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Váš požadavek byl odeslán Vašemu správci." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Po schválení budete upozorněni." + }, + "troubleLoggingIn": { + "message": "Potíže s přihlášením?" + }, + "loginApproved": { + "message": "Přihlášení bylo schváleno" + }, + "userEmailMissing": { + "message": "Chybí e-mail uživatele" + }, + "deviceTrusted": { + "message": "Zařízení zařazeno mezi důvěryhodné" + }, + "inputRequired": { + "message": "Je vyžadován vstup." + }, + "required": { + "message": "vyžadováno" + }, + "search": { + "message": "Hledat" + }, + "inputMinLength": { + "message": "Vstup musí mít alespoň $COUNT$ znaků.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Vstup nesmí být delší než $COUNT$ znaků.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Následující znaky nejsou povoleny: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Vstupní hodnota musí být alespoň $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Vstupní hodnota nesmí přesáhnout $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 nebo více e-mailů jsou neplatné" + }, + "inputTrimValidator": { + "message": "Vstup nesmí obsahovat jen mezery.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Vstup není e-mailová adresa." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ polí výše vyžaduje Vaši pozornost.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Vybrat --" + }, + "multiSelectPlaceholder": { + "message": "-- Pište pro filtrování --" + }, + "multiSelectLoading": { + "message": "Načítání voleb..." + }, + "multiSelectNotFound": { + "message": "Nebyly nalezeny žádné položky" + }, + "multiSelectClearAll": { + "message": "Vymazat vše" + }, + "plusNMore": { + "message": "+ $QUANTITY$ dalších", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Podmenu" + }, + "toggleCollapse": { + "message": "Přepnout sbalení", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Doména aliasu" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Položky se žádostí o změnu hlavního hesla nemohou být automaticky vyplněny při načítání stránky. Automatické vyplnění při načítání stránky je vypnuto.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Automatické vyplnění při načítání stránky bylo nastaveno na výchozí nastavení.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Pro úpravu tohoto pole vypněte požadavek na hlavní heslo", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 69d26333a84..ff3ccc684f7 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -3,21 +3,21 @@ "message": "Bitwarden" }, "extName": { - "message": "Bitwarden - Free Password Manager", + "message": "Bitwarden - Rheolydd cyfineiriau am ddim", "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": { - "message": "Log in or create a new account to access your secure vault." + "message": "Mewngofnodwch neu crëwch gyfrif newydd i gael mynediad i'ch cell ddiogel." }, "createAccount": { - "message": "Create account" + "message": "Creu cyfrif" }, "login": { - "message": "Log in" + "message": "Mewngofnodi" }, "enterpriseSingleSignOn": { "message": "Enterprise single sign-on" @@ -26,16 +26,16 @@ "message": "Cancel" }, "close": { - "message": "Close" + "message": "Cau" }, "submit": { - "message": "Submit" + "message": "Cyflwyno" }, "emailAddress": { - "message": "Email address" + "message": "Cyfeiriad ebost" }, "masterPass": { - "message": "Master password" + "message": "Prif gyfrinair" }, "masterPassDesc": { "message": "The master password is the password you use to access your vault. It is very important that you do not forget your master password. There is no way to recover the password in the event that you forget it." @@ -53,46 +53,55 @@ "message": "Tab" }, "vault": { - "message": "Vault" + "message": "Cell" }, "myVault": { - "message": "My vault" + "message": "Fy nghell" }, "allVaults": { - "message": "All vaults" + "message": "Pob cell" }, "tools": { - "message": "Tools" + "message": "Offer" }, "settings": { - "message": "Settings" + "message": "Gosodiadau" }, "currentTab": { - "message": "Current tab" + "message": "Y tab cyfredol" }, "copyPassword": { - "message": "Copy password" + "message": "Copïo cyfrinair" }, "copyNote": { "message": "Copy note" }, "copyUri": { - "message": "Copy URI" + "message": "Copïo URI" }, "copyUsername": { - "message": "Copy username" + "message": "Copïo enw defnyddiwr" }, "copyNumber": { - "message": "Copy number" + "message": "Copïo rhif" }, "copySecurityCode": { "message": "Copy security code" }, "autoFill": { - "message": "Auto-fill" + "message": "Llenwi'n awtomatig" + }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" }, "generatePasswordCopied": { - "message": "Generate password (copied)" + "message": "Cynhyrchu cyfrinair (wedi'i gopïo)" }, "copyElementIdentifier": { "message": "Copy custom field name" @@ -100,20 +109,35 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { - "message": "Unlock your vault" + "message": "Datgloi'ch cell" }, "loginToVaultMenu": { - "message": "Log in to your vault" + "message": "Mewngofnodi i'ch cell" }, "autoFillInfo": { "message": "There are no logins available to auto-fill for the current browser tab." }, "addLogin": { - "message": "Add a login" + "message": "Ychwanegu manylion mewngofnodi" }, "addItem": { - "message": "Add item" + "message": "Ychwanegu eitem" }, "passwordHint": { "message": "Password hint" @@ -125,25 +149,25 @@ "message": "Get master password hint" }, "continue": { - "message": "Continue" + "message": "Parhau" }, "sendVerificationCode": { "message": "Send a verification code to your email" }, "sendCode": { - "message": "Send code" + "message": "Anfod cod" }, "codeSent": { - "message": "Code sent" + "message": "Cod wedi'i anfon" }, "verificationCode": { - "message": "Verification code" + "message": "Cod dilysu" }, "confirmIdentity": { - "message": "Confirm your identity to continue." + "message": "Cadarnhewch eich hunaniaeth i barhau." }, "account": { - "message": "Account" + "message": "Cyfrif" }, "changeMasterPassword": { "message": "Change master password" @@ -160,43 +184,43 @@ "message": "Two-step login" }, "logOut": { - "message": "Log out" + "message": "Allgofnodi" }, "about": { - "message": "About" + "message": "Ynghylch" }, "version": { - "message": "Version" + "message": "Fersiwn" }, "save": { - "message": "Save" + "message": "Cadw" }, "move": { - "message": "Move" + "message": "Symud" }, "addFolder": { - "message": "Add folder" + "message": "Ychwanegu ffolder" }, "name": { - "message": "Name" + "message": "Enw" }, "editFolder": { - "message": "Edit folder" + "message": "Golygu ffolder" }, "deleteFolder": { - "message": "Delete folder" + "message": "Dileu'r ffolder" }, "folders": { - "message": "Folders" + "message": "Ffolderi" }, "noFolders": { - "message": "There are no folders to list." + "message": "Does dim ffolderi i'w rhestru." }, "helpFeedback": { - "message": "Help & feedback" + "message": "Cymorth ac adborth" }, "helpCenter": { - "message": "Bitwarden Help center" + "message": "Canolfan gymorth Bitwarden" }, "communityForums": { "message": "Explore Bitwarden community forums" @@ -205,69 +229,69 @@ "message": "Contact Bitwarden support" }, "sync": { - "message": "Sync" + "message": "Cysoni" }, "syncVaultNow": { - "message": "Sync vault now" + "message": "Cysoni'r gell nawr" }, "lastSync": { - "message": "Last sync:" + "message": "Wedi'i chysoni ddiwethaf:" }, "passGen": { - "message": "Password generator" + "message": "Cynhyrchydd cyfrineiriau" }, "generator": { - "message": "Generator", + "message": "Cynhyrchydd", "description": "Short for 'Password Generator'." }, "passGenInfo": { - "message": "Automatically generate strong, unique passwords for your logins." + "message": "Cynhyrchu cyfrineiriau cryf ac unigryw ar gyfer eich cyfrifon yn awtomatig." }, "bitWebVault": { - "message": "Bitwarden web vault" + "message": "Cell we Bitwarden" }, "importItems": { - "message": "Import items" + "message": "Mewnforio eitemau" }, "select": { - "message": "Select" + "message": "Dewis" }, "generatePassword": { - "message": "Generate password" + "message": "Cynhyrchu cyfrinair" }, "regeneratePassword": { - "message": "Regenerate password" + "message": "Ailgynhyrchu cyfrinair" }, "options": { - "message": "Options" + "message": "Dewisiadau" }, "length": { - "message": "Length" + "message": "Hyd" }, "uppercase": { - "message": "Uppercase (A-Z)" + "message": "Priflythrennau (A-Z)" }, "lowercase": { - "message": "Lowercase (a-z)" + "message": "Llythrennau bach (a-z)" }, "numbers": { - "message": "Numbers (0-9)" + "message": "Rhifau (0-9)" }, "specialCharacters": { - "message": "Special characters (!@#$%^&*)" + "message": "Nodau arbennig (!@#$%^&*)" }, "numWords": { - "message": "Number of words" + "message": "Nifer o eiriau" }, "wordSeparator": { - "message": "Word separator" + "message": "Gwahanydd geiriau" }, "capitalize": { - "message": "Capitalize", + "message": "Priflythrennu", "description": "Make the first letter of a work uppercase." }, "includeNumber": { - "message": "Include number" + "message": "Cynnwys rhif" }, "minNumbers": { "message": "Minimum numbers" @@ -279,43 +303,43 @@ "message": "Avoid ambiguous characters" }, "searchVault": { - "message": "Search vault" + "message": "Chwilio'r gell" }, "edit": { - "message": "Edit" + "message": "Golygu" }, "view": { "message": "View" }, "noItemsInList": { - "message": "There are no items to list." + "message": "Does dim eitemau i'w rhestru." }, "itemInformation": { "message": "Item information" }, "username": { - "message": "Username" + "message": "Enw defnyddiwr" }, "password": { - "message": "Password" + "message": "Cyfrinair" }, "passphrase": { - "message": "Passphrase" + "message": "Cyfrinymadrodd" }, "favorite": { - "message": "Favorite" + "message": "Ffefrynnu" }, "notes": { - "message": "Notes" + "message": "Nodiadau" }, "note": { - "message": "Note" + "message": "Nodyn" }, "editItem": { "message": "Edit item" }, "folder": { - "message": "Folder" + "message": "Ffolder" }, "deleteItem": { "message": "Delete item" @@ -324,40 +348,43 @@ "message": "View item" }, "launch": { - "message": "Launch" + "message": "Lansio" }, "website": { - "message": "Website" + "message": "Gwefan" }, "toggleVisibility": { "message": "Toggle visibility" }, "manage": { - "message": "Manage" + "message": "Rheoli" }, "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "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." }, "verifyIdentity": { - "message": "Verify identity" + "message": "Gwirio'ch hunaniaeth" }, "yourVaultIsLocked": { - "message": "Your vault is locked. Verify your identity to continue." + "message": "Mae eich cell dan glo. Gwiriwch eich hunaniaeth i barhau." }, "unlock": { - "message": "Unlock" + "message": "Datgloi" }, "loggedInAsOn": { - "message": "Logged in as $EMAIL$ on $HOSTNAME$.", + "message": "Wedi mewngofnodi gyda $EMAIL$ ar $HOSTNAME$.", "placeholders": { "email": { "content": "$1", @@ -370,46 +397,46 @@ } }, "invalidMasterPassword": { - "message": "Invalid master password" + "message": "Prif gyfrinair annilys" }, "vaultTimeout": { - "message": "Vault timeout" + "message": "Cloi'r gell" }, "lockNow": { - "message": "Lock now" + "message": "Cloi nawr" }, "immediately": { - "message": "Immediately" + "message": "ar unwaith" }, "tenSeconds": { - "message": "10 seconds" + "message": "ar ôl 10 eiliad" }, "twentySeconds": { - "message": "20 seconds" + "message": "ar ôl 20 eiliad" }, "thirtySeconds": { - "message": "30 seconds" + "message": "ar ôl 30 eiliad" }, "oneMinute": { - "message": "1 minute" + "message": "ar ôl munud" }, "twoMinutes": { - "message": "2 minutes" + "message": "ar ôl 2 funud" }, "fiveMinutes": { - "message": "5 minutes" + "message": "ar ôl 5 munud" }, "fifteenMinutes": { - "message": "15 minutes" + "message": "ar ôl chwarter awr" }, "thirtyMinutes": { - "message": "30 minutes" + "message": "ar ôl hanner awr" }, "oneHour": { - "message": "1 hour" + "message": "ar ôl awr" }, "fourHours": { - "message": "4 hours" + "message": "ar ôl 4 awr" }, "onLocked": { "message": "On system lock" @@ -418,28 +445,28 @@ "message": "On browser restart" }, "never": { - "message": "Never" + "message": "byth" }, "security": { - "message": "Security" + "message": "Diogelwch" }, "errorOccurred": { - "message": "An error has occurred" + "message": "Bu gwall" }, "emailRequired": { - "message": "Email address is required." + "message": "Mae angen cyfeiriad ebost." }, "invalidEmail": { - "message": "Invalid email address." + "message": "Cyfeiriad ebost annilys." }, "masterPasswordRequired": { - "message": "Master password is required." + "message": "Mae angen prif gyfrinair." }, "confirmMasterPasswordRequired": { - "message": "Master password retype is required." + "message": "Mae angen aildeipio'r prif gyfrinair." }, "masterPasswordMinlength": { - "message": "Master password must be at least $VALUE$ characters long.", + "message": "Rhaid i'r prif gyfrinair gynnwys o leiaf $VALUE$ nod.", "description": "The Master Password must be at least a specific number of characters long.", "placeholders": { "value": { @@ -452,16 +479,16 @@ "message": "Master password confirmation does not match." }, "newAccountCreated": { - "message": "Your new account has been created! You may now log in." + "message": "Mae eich cyfrif newydd wedi cael ei greu! Gallwch bellach fewngofnodi." }, "masterPassSent": { - "message": "We've sent you an email with your master password hint." + "message": "Rydym ni wedi anfon ebost atoch gydag awgrym ar gyfer eich prif gyfrinair." }, "verificationCodeRequired": { - "message": "Verification code is required." + "message": "Mae angen cod dilysu." }, "invalidVerificationCode": { - "message": "Invalid verification code" + "message": "Cod dilysu annilys" }, "valueCopied": { "message": "$VALUE$ copied", @@ -480,10 +507,10 @@ "message": "Logged out" }, "loginExpired": { - "message": "Your login session has expired." + "message": "Mae eich sesiwn wedi dod i ben." }, "logOutConfirmation": { - "message": "Are you sure you want to log out?" + "message": "Ydych chi'n siŵr eich bod am allgofnodi?" }, "yes": { "message": "Yes" @@ -495,7 +522,7 @@ "message": "An unexpected error has occurred." }, "nameRequired": { - "message": "Name is required." + "message": "Mae angen enw." }, "addedFolder": { "message": "Folder added" @@ -510,13 +537,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" @@ -531,7 +558,7 @@ "message": "Syncing failed" }, "passwordCopied": { - "message": "Password copied" + "message": "Cyfrinair wedi'i gopïo" }, "uri": { "message": "URI" @@ -547,28 +574,28 @@ } }, "newUri": { - "message": "New URI" + "message": "URI newydd" }, "addedItem": { - "message": "Item added" + "message": "Eitem wedi'i hychwanegu" }, "editedItem": { - "message": "Item saved" + "message": "Eitem wedi'i chadw" }, "deleteItemConfirmation": { - "message": "Do you really want to send to the trash?" + "message": "Ydych chi wir eisiau anfon i'r sbwriel?" }, "deletedItem": { - "message": "Item sent to trash" + "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?" @@ -583,7 +610,7 @@ "message": "Search type" }, "noneFolder": { - "message": "No folder", + "message": "Dim ffolder", "description": "This is the folder for uncategorized items" }, "enableAddLoginNotification": { @@ -605,7 +632,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": { @@ -616,7 +643,7 @@ "message": "Should Bitwarden remember this password for you?" }, "notificationAddSave": { - "message": "Save" + "message": "Cadw" }, "enableChangedPasswordNotification": { "message": "Ask to update existing login" @@ -628,7 +655,13 @@ "message": "Do you want to update this password in Bitwarden?" }, "notificationChangeSave": { - "message": "Update" + "message": "Diweddaru" + }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Datgloi" }, "enableContextMenuItem": { "message": "Show context menu options" @@ -644,17 +677,17 @@ "message": "Choose the default way that URI match detection is handled for logins when performing actions such as auto-fill." }, "theme": { - "message": "Theme" + "message": "Thema" }, "themeDesc": { "message": "Change the application's color theme." }, "dark": { - "message": "Dark", + "message": "Tywyll", "description": "Dark color" }, "light": { - "message": "Light", + "message": "Golau", "description": "Light color" }, "solarizedDark": { @@ -662,13 +695,13 @@ "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, "exportVault": { - "message": "Export vault" + "message": "Allforio'r gell" }, "fileFormat": { - "message": "File format" + "message": "Fformat y ffeil" }, "warning": { - "message": "WARNING", + "message": "RHYBUDD", "description": "WARNING (should stay in capitalized letters if the language permits)" }, "confirmVaultExport": { @@ -699,10 +732,10 @@ "message": "Move to organization" }, "share": { - "message": "Share" + "message": "Rhannu" }, "movedItemToOrg": { - "message": "$ITEMNAME$ moved to $ORGNAME$", + "message": "Symudwyd $ITEMNAME$ i $ORGNAME$", "placeholders": { "itemname": { "content": "$1", @@ -718,7 +751,7 @@ "message": "Choose an organization that you wish to move this item to. Moving to an organization transfers ownership of the item to that organization. You will no longer be the direct owner of this item once it has been moved." }, "learnMore": { - "message": "Learn more" + "message": "Dysgu mwy" }, "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" @@ -730,7 +763,7 @@ "message": "Copy verification code" }, "attachments": { - "message": "Attachments" + "message": "Atodiadau" }, "deleteAttachment": { "message": "Delete attachment" @@ -742,19 +775,19 @@ "message": "Attachment deleted" }, "newAttachment": { - "message": "Add new attachment" + "message": "Ychwanegu atodiad newydd" }, "noAttachments": { - "message": "No attachments." + "message": "Dim atodiadau." }, "attachmentSaved": { "message": "Attachment saved" }, "file": { - "message": "File" + "message": "Ffeil" }, "selectFile": { - "message": "Select a file" + "message": "Dewis ffeil" }, "maxFileSize": { "message": "Maximum file size is 500 MB." @@ -762,59 +795,59 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { - "message": "Premium membership" + "message": "Aelodaeth uwch" }, "premiumManage": { - "message": "Manage membership" + "message": "Rheoli'ch aelodaeth" }, "premiumManageAlert": { "message": "You can manage your membership on the bitwarden.com web vault. Do you want to visit the website now?" }, "premiumRefresh": { - "message": "Refresh membership" + "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": "All for just $PRICE$ /year!", + "message": "Hyn oll am $PRICE$ y flwyddyn!", "placeholders": { "price": { "content": "$1", @@ -835,10 +868,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." @@ -862,7 +895,7 @@ } }, "rememberMe": { - "message": "Remember me" + "message": "Fy nghofio i" }, "sendVerificationCodeEmailAgain": { "message": "Send verification code email again" @@ -880,7 +913,7 @@ "message": "To start the WebAuthn 2FA verification. Click the button below to open a new tab and follow the instructions provided in the new tab." }, "webAuthnNewTabOpen": { - "message": "Open new tab" + "message": "Agor tab newydd" }, "webAuthnAuthenticate": { "message": "Authenticate WebAuthn" @@ -895,13 +928,13 @@ "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." }, "recoveryCodeTitle": { - "message": "Recovery code" + "message": "Cod adfer" }, "authenticatorAppTitle": { "message": "Authenticator app" @@ -931,7 +964,7 @@ "message": "Use any WebAuthn compatible security key to access your account." }, "emailTitle": { - "message": "Email" + "message": "Ebost" }, "emailDesc": { "message": "Verification codes will be emailed to you." @@ -943,7 +976,7 @@ "message": "Specify the base URL of your on-premises hosted Bitwarden installation." }, "customEnvironment": { - "message": "Custom environment" + "message": "Amgylchedd addasedig" }, "customEnvironmentFooter": { "message": "For advanced users. You can specify the base URL of each service independently." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API server URL" }, "webVaultUrl": { "message": "Web vault server URL" @@ -973,13 +1006,13 @@ "message": "Auto-fill on page load" }, "enableAutoFillOnPageLoadDesc": { - "message": "If a login form is detected, auto-fill when the web page loads." + "message": "Llenwi'n awtomatig wrth i dudalen lwytho os canfyddir ffurflen mewngofnodi." }, "experimentalFeature": { "message": "Compromised or untrusted websites can exploit auto-fill on page load." }, "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "message": "Dysgu mwy am lenwi'n awtomatig" }, "defaultAutoFillOnPageLoad": { "message": "Default autofill setting for login items" @@ -991,7 +1024,7 @@ "message": "Auto-fill on page load (if set up in Options)" }, "autoFillOnPageLoadUseDefault": { - "message": "Use default setting" + "message": "Defnyddio'r gosodiad rhagosodedig" }, "autoFillOnPageLoadYes": { "message": "Auto-fill on page load" @@ -1012,34 +1045,34 @@ "message": "Generate and copy a new random password to the clipboard" }, "commandLockVaultDesc": { - "message": "Lock the vault" + "message": "Cloi'r gell" }, "privateModeWarning": { "message": "Private mode support is experimental and some features are limited." }, "customFields": { - "message": "Custom fields" + "message": "Meysydd addasedig" }, "copyValue": { "message": "Copy value" }, "value": { - "message": "Value" + "message": "Gwerth" }, "newCustomField": { - "message": "New custom field" + "message": "Maes addasedig newydd" }, "dragToSort": { "message": "Drag to sort" }, "cfTypeText": { - "message": "Text" + "message": "Testun" }, "cfTypeHidden": { "message": "Hidden" }, "cfTypeBoolean": { - "message": "Boolean" + "message": "Gwerth Boole" }, "cfTypeLinked": { "message": "Linked", @@ -1056,10 +1089,10 @@ "message": "This browser cannot process U2F requests in this popup window. Do you want to open this popup in a new window so that you can log in using U2F?" }, "enableFavicon": { - "message": "Show website icons" + "message": "Dangos eiconau gwefannau" }, "faviconDesc": { - "message": "Show a recognizable image next to each login." + "message": "Dangos delwedd adnabyddadwy wrth ymyl pob eitem." }, "enableBadgeCounter": { "message": "Show badge counter" @@ -1068,67 +1101,67 @@ "message": "Indicate how many logins you have for the current web page." }, "cardholderName": { - "message": "Cardholder name" + "message": "Enw ar y cerdyn" }, "number": { - "message": "Number" + "message": "Rhif" }, "brand": { "message": "Brand" }, "expirationMonth": { - "message": "Expiration month" + "message": "Mis dod i ben" }, "expirationYear": { - "message": "Expiration year" + "message": "Blwyddyn dod i ben" }, "expiration": { - "message": "Expiration" + "message": "Dod i ben" }, "january": { - "message": "January" + "message": "Ionawr" }, "february": { - "message": "February" + "message": "Chwefror" }, "march": { - "message": "March" + "message": "Mawrth" }, "april": { - "message": "April" + "message": "Ebrill" }, "may": { - "message": "May" + "message": "Mai" }, "june": { - "message": "June" + "message": "Mehefin" }, "july": { - "message": "July" + "message": "Gorffennaf" }, "august": { - "message": "August" + "message": "Awst" }, "september": { - "message": "September" + "message": "Medi" }, "october": { - "message": "October" + "message": "Hydref" }, "november": { - "message": "November" + "message": "Tachwedd" }, "december": { - "message": "December" + "message": "Rhagfyr" }, "securityCode": { - "message": "Security code" + "message": "Cod diogelwch" }, "ex": { - "message": "ex." + "message": "engh." }, "title": { - "message": "Title" + "message": "Teitl" }, "mr": { "message": "Mr" @@ -1146,119 +1179,119 @@ "message": "Mx" }, "firstName": { - "message": "First name" + "message": "Enw cyntaf" }, "middleName": { - "message": "Middle name" + "message": "Enw canol" }, "lastName": { - "message": "Last name" + "message": "Cyfenw" }, "fullName": { - "message": "Full name" + "message": "Enw llawn" }, "identityName": { "message": "Identity name" }, "company": { - "message": "Company" + "message": "Cwmni" }, "ssn": { "message": "Social Security number" }, "passportNumber": { - "message": "Passport number" + "message": "Rhif pasbort" }, "licenseNumber": { - "message": "License number" + "message": "Rhif trwydded" }, "email": { - "message": "Email" + "message": "Ebost" }, "phone": { - "message": "Phone" + "message": "Ffôn" }, "address": { - "message": "Address" + "message": "Cyfeiriad" }, "address1": { - "message": "Address 1" + "message": "Cyfeiriad 1" }, "address2": { - "message": "Address 2" + "message": "Cyfeiriad 2" }, "address3": { - "message": "Address 3" + "message": "Cyfeiriad 3" }, "cityTown": { - "message": "City / Town" + "message": "Tref / Dinas" }, "stateProvince": { - "message": "State / Province" + "message": "Talaith / Rhanbarth" }, "zipPostalCode": { - "message": "Zip / Postal code" + "message": "Cod post / zip" }, "country": { - "message": "Country" + "message": "Gwlad" }, "type": { - "message": "Type" + "message": "Math" }, "typeLogin": { - "message": "Login" + "message": "Manylion mewngofnodi" }, "typeLogins": { - "message": "Logins" + "message": "Manylion mewngofnodi" }, "typeSecureNote": { - "message": "Secure note" + "message": "Nodyn diogel" }, "typeCard": { - "message": "Card" + "message": "Cerdyn" }, "typeIdentity": { - "message": "Identity" + "message": "Hunaniaeth" }, "passwordHistory": { "message": "Password history" }, "back": { - "message": "Back" + "message": "Yn ôl" }, "collections": { - "message": "Collections" + "message": "Casgliadau" }, "favorites": { - "message": "Favorites" + "message": "Ffefrynnau" }, "popOutNewWindow": { - "message": "Pop out to a new window" + "message": "Syumd i ffenestr newydd" }, "refresh": { "message": "Refresh" }, "cards": { - "message": "Cards" + "message": "Cardiau" }, "identities": { - "message": "Identities" + "message": "Eitemau hunaniaeth" }, "logins": { - "message": "Logins" + "message": "Manylion mewngofnodi" }, "secureNotes": { - "message": "Secure notes" + "message": "Nodiadau diogel" }, "clear": { - "message": "Clear", + "message": "Clirio", "description": "To clear something out. example: To clear browser history." }, "checkPassword": { - "message": "Check if password has been exposed." + "message": "Gwirio a ydy'r cyfrinair wedi'i ddatgelu." }, "passwordExposed": { - "message": "This password has been exposed $VALUE$ time(s) in data breaches. You should change it.", + "message": "Mae'r cyfrinair hwn wedi cael ei ddatgelu $VALUE$ o weithiau mewn achosion o dorri data. Dylech chi ei newid.", "placeholders": { "value": { "content": "$1", @@ -1267,7 +1300,7 @@ } }, "passwordSafe": { - "message": "This password was not found in any known data breaches. It should be safe to use." + "message": "Chafodd y cyfrinair hwn mo'i ganfod mewn unrhyw achos hysbys o dorri data. Dylai fod yn iawn i'w ddefnyddio." }, "baseDomain": { "message": "Base domain", @@ -1288,7 +1321,7 @@ "message": "Starts with" }, "regEx": { - "message": "Regular expression", + "message": "Mynegiant rheolaidd", "description": "A programming term, also known as 'RegEx'." }, "matchDetection": { @@ -1307,24 +1340,24 @@ "description": "Toggle the display of the URIs of the currently open tabs in the browser." }, "currentUri": { - "message": "Current URI", + "message": "URI cyfredol", "description": "The URI of one of the current open tabs in the browser." }, "organization": { - "message": "Organization", + "message": "Sefydliad", "description": "An entity of multiple related people (ex. a team or business organization)." }, "types": { "message": "Types" }, "allItems": { - "message": "All items" + "message": "Pob eitem" }, "noPasswordsInList": { - "message": "There are no passwords to list." + "message": "Does dim cyfrineiriau i'w rhestru." }, "remove": { - "message": "Remove" + "message": "Tynnu" }, "default": { "message": "Default" @@ -1351,44 +1384,44 @@ "message": "There are no collections to list." }, "ownership": { - "message": "Ownership" + "message": "Perchnogaeth" }, "whoOwnsThisItem": { - "message": "Who owns this item?" + "message": "Pwy sy'n berchen ar yr eitem hon?" }, "strong": { - "message": "Strong", + "message": "Cryf", "description": "ex. A strong password. Scale: Weak -> Good -> Strong" }, "good": { - "message": "Good", + "message": "Da", "description": "ex. A good password. Scale: Weak -> Good -> Strong" }, "weak": { - "message": "Weak", + "message": "Gwan", "description": "ex. A weak password. Scale: Weak -> Good -> Strong" }, "weakMasterPassword": { - "message": "Weak master password" + "message": "Prif gyfrinair gwan" }, "weakMasterPasswordDesc": { - "message": "The master password you have chosen is weak. You should use a strong master password (or a passphrase) to properly protect your Bitwarden account. Are you sure you want to use this master password?" + "message": "Mae'r prif gyfrinair rydych chi wedi'i ddewis yn wan. Dylech ddefnyddio prif gyfrinair (neu gyfrinymadrodd) cryf i amddiffyn eich cyfrif Bitwarden. Ydych chi'n siŵr eich bod am ddefnyddio'r prif gyfrinair hwn?" }, "pin": { "message": "PIN", "description": "PIN code. Ex. The short code (often numeric) that you use to unlock a device." }, "unlockWithPin": { - "message": "Unlock with PIN" + "message": "Datgloi â PIN" }, "setYourPinCode": { "message": "Set your PIN code for unlocking Bitwarden. Your PIN settings will be reset if you ever fully log out of the application." }, "pinRequired": { - "message": "PIN code is required." + "message": "Mae angen cod PIN." }, "invalidPin": { - "message": "Invalid PIN code." + "message": "Cod PIN annilys." }, "unlockWithBiometrics": { "message": "Unlock with biometrics" @@ -1418,30 +1451,27 @@ "message": "Vault timeout action" }, "lock": { - "message": "Lock", + "message": "Cloi", "description": "Verb form: to make secure or inaccesible by" }, "trash": { - "message": "Trash", + "message": "Sbwriel", "description": "Noun: a special folder to hold deleted items" }, "searchTrash": { - "message": "Search trash" + "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": "Restore item" - }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" + "message": "Adfer yr eitem" }, "restoredItem": { "message": "Item restored" @@ -1480,13 +1510,13 @@ } }, "setMasterPassword": { - "message": "Set master password" + "message": "Gosod prif gyfrinair" }, "currentMasterPass": { "message": "Current master password" }, "newMasterPass": { - "message": "New master password" + "message": "Prif gyfrinair newydd" }, "confirmNewMasterPass": { "message": "Confirm new master password" @@ -1534,16 +1564,16 @@ "message": "Your new master password does not meet the policy requirements." }, "acceptPolicies": { - "message": "By checking this box you agree to the following:" + "message": "Drwy dicio'r blwch hwn, rydych yn cytuno i'r canlynol:" }, "acceptPoliciesRequired": { "message": "Terms of Service and Privacy Policy have not been acknowledged." }, "termsOfService": { - "message": "Terms of Service" + "message": "Telerau gwasanaeth" }, "privacyPolicy": { - "message": "Privacy Policy" + "message": "Polisi preifatrwydd" }, "hintEqualsPassword": { "message": "Your password hint cannot be the same as your password." @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1618,13 +1654,13 @@ "message": "An organization policy is affecting your ownership options." }, "excludedDomains": { - "message": "Excluded domains" + "message": "Parthau wedi'u heithrio" }, "excludedDomainsDesc": { - "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." + "message": "Fydd Bitwarden ddim yn gofyn i gadw manylion mewngofnodi'r parthau hyn. Rhaid i chi ail-lwytho'r dudalen i newidiadau ddod i rym." }, "excludedDomainsInvalidDomain": { - "message": "$DOMAIN$ is not a valid domain", + "message": "Dyw $DOMAIN$ ddim yn barth dilys", "placeholders": { "domain": { "content": "$1", @@ -1637,21 +1673,21 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "searchSends": { - "message": "Search Sends", + "message": "Chwilio drwy Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "addSend": { - "message": "Add Send", + "message": "Ychwanegu Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeText": { - "message": "Text" + "message": "Testun" }, "sendTypeFile": { - "message": "File" + "message": "Ffeil" }, "allSends": { - "message": "All Sends", + "message": "Pob Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "maxAccessCountReached": { @@ -1659,7 +1695,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" @@ -1675,7 +1711,7 @@ "message": "Remove Password" }, "delete": { - "message": "Delete" + "message": "Dileu" }, "removedPassword": { "message": "Password removed" @@ -1718,24 +1754,24 @@ "message": "The file you want to send." }, "deletionDate": { - "message": "Deletion date" + "message": "Dyddiad dileu" }, "deletionDateDesc": { "message": "The Send will be permanently deleted on the specified date and time.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "expirationDate": { - "message": "Expiration date" + "message": "Dyddiad dod i ben" }, "expirationDateDesc": { "message": "If set, access to this Send will expire on the specified date and time.", "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", @@ -1744,7 +1780,7 @@ } }, "custom": { - "message": "Custom" + "message": "Addasedig" }, "maximumAccessCount": { "message": "Maximum Access Count" @@ -1812,7 +1848,7 @@ "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." }, "sendFileCalloutHeader": { - "message": "Before you start" + "message": "Cyn i chi ddechrau" }, "sendFirefoxCustomDatePopoutMessage1": { "message": "To use a calendar style date picker", @@ -1827,22 +1863,22 @@ "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To use a calendar style date picker click here **to pop out your window.**'" }, "expirationDateIsInvalid": { - "message": "The expiration date provided is not valid." + "message": "Dyw'r dyddiad dod i ben a roddwyd ddim yn ddilys." }, "deletionDateIsInvalid": { - "message": "The deletion date provided is not valid." + "message": "Dyw'r dyddiad dileu a roddwyd ddim yn ddilys." }, "expirationDateAndTimeRequired": { - "message": "An expiration date and time are required." + "message": "Mae angen rhoi dyddiad ac amser dod i ben." }, "deletionDateAndTimeRequired": { - "message": "A deletion date and time are required." + "message": "Mae angen rhoi dyddiad ac amser dileu." }, "dateParsingError": { "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." @@ -1881,7 +1917,7 @@ "message": "This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password." }, "selectFolder": { - "message": "Select folder..." + "message": "Dewis ffolder..." }, "ssoCompleteRegistration": { "message": "In order to complete logging in with SSO, please set a master password to access and protect your vault." @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -1989,16 +2025,16 @@ } }, "error": { - "message": "Error" + "message": "Gwall" }, "regenerateUsername": { "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", @@ -2014,22 +2050,22 @@ "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" }, "whatWouldYouLikeToGenerate": { - "message": "What would you like to generate?" + "message": "Beth hoffech chi ei gynhyrchu?" }, "passwordType": { - "message": "Password type" + "message": "Math o gyfrinair" }, "service": { - "message": "Service" + "message": "Gwasanaeth" }, "forwardedEmail": { "message": "Forwarded email alias" @@ -2045,7 +2081,7 @@ "message": "API Access Token" }, "apiKey": { - "message": "API Key" + "message": "Allwedd API" }, "ssoKeyConnectorError": { "message": "Key connector error: make sure key connector is available and working correctly." @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2117,10 +2153,10 @@ "message": "New around here?" }, "rememberEmail": { - "message": "Remember email" + "message": "Cofio'r ebost" }, "loginWithDevice": { - "message": "Log in with device" + "message": "Mewngofnodi â dyfais" }, "loginWithDeviceEnabledInfo": { "message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?" @@ -2135,13 +2171,13 @@ "message": "Resend notification" }, "viewAllLoginOptions": { - "message": "View all log in options" + "message": "Gweld pob dewis mewngofnodi" }, "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2153,19 +2189,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": "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", @@ -2177,10 +2213,10 @@ "message": "Your organization policies have turned on auto-fill on page load." }, "howToAutofill": { - "message": "How to auto-fill" + "message": "Sut i lenwi'n awtomatig" }, "autofillSelectInfoWithCommand": { - "message": "Select an item from this page or use the shortcut: $COMMAND$", + "message": "Dewiswch eitem o'r dudalen hon neu ddefnyddio'r llwybr byr: $COMMAND$", "placeholders": { "command": { "content": "$1", @@ -2192,10 +2228,10 @@ "message": "Select an item from this page or set a shortcut in settings." }, "gotIt": { - "message": "Got it" + "message": "Iawn" }, "autofillSettings": { - "message": "Auto-fill settings" + "message": "Gosodiadau llenwi awtomatig" }, "autofillShortcut": { "message": "Auto-fill keyboard shortcut" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Cofio'r ddyfais hon" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { - "message": "EU", + "message": "UE", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Mynediad wedi ei wrthod. Does gennych chi ddim caniatâd i weld y dudalen hon." + }, + "general": { + "message": "Cyffredinol" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Chwilio" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Dewis --" + }, + "multiSelectPlaceholder": { + "message": "-- Teipiwch i hidlo --" + }, + "multiSelectLoading": { + "message": "Yn nôl dewisiadau..." + }, + "multiSelectNotFound": { + "message": "Heb ganfod eitemau" + }, + "multiSelectClearAll": { + "message": "Clirio'r cyfan" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Is-ddewislen" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 5aebf5dd80d..5dd437827f3 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-udfyld" }, + "autoFillLogin": { + "message": "Autoudfyld login" + }, + "autoFillCard": { + "message": "Autoudfyld kort" + }, + "autoFillIdentity": { + "message": "Autoudfyld identitet" + }, "generatePasswordCopied": { "message": "Generér adgangskode (kopieret)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Ingen matchende logins" }, + "noCards": { + "message": "Ingen kort" + }, + "noIdentities": { + "message": "Ingen identiteter" + }, + "addLoginMenu": { + "message": "Tilføj login" + }, + "addCardMenu": { + "message": "Tilføj kort" + }, + "addIdentityMenu": { + "message": "Tilføj identitet" + }, "unlockVaultMenu": { "message": "Lås din boks op" }, @@ -338,6 +362,9 @@ "other": { "message": "Andre" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Opsæt en oplåsningsmetode til at ændre bokstimeouthandlingen." + }, "rateExtension": { "message": "Bedøm udvidelsen" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Opdatér" }, + "notificationUnlockDesc": { + "message": "Oplås din Bitwarden boks for at færdiggøre autoudfyldanmodningen." + }, + "notificationUnlock": { + "message": "Oplås" + }, "enableContextMenuItem": { "message": "Vis indstillinger i kontekstmenuen" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Funktion ikke tilgængelig" }, - "updateKey": { - "message": "Du kan ikke bruge denne funktion, før du opdaterer din krypteringsnøgle." + "encryptionKeyMigrationRequired": { + "message": "Krypteringsnøglemigrering nødvendig. Log ind gennem web-boksen for at opdatere krypteringsnøglen." }, "premiumMembership": { "message": "Premium-medlemskab" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Gendan element" }, - "restoreItemConfirmation": { - "message": "Er du sikker på, at du vil gendanne dette element?" - }, "restoredItem": { "message": "Element gendannet" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browserbiometri understøttes ikke på denne enhed." }, + "biometricsFailedTitle": { + "message": "Biometri mislykkedes" + }, + "biometricsFailedDesc": { + "message": "Biometri kan ikke fuldføres, overvej at bruge en hovedadgangskode eller logge ud og ind igen. Fortsætter problemet, kontakt Bitwarden-supporten." + }, "nativeMessaginPermissionErrorTitle": { "message": "Tilladelse ikke givet" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Eksporterer personlig boks" }, - "exportingPersonalVaultDescription": { - "message": "Kun de personlige bokselementer tilknyttet $EMAIL$ vil blive eksporteret. Organisationsbokseelementer vil ikke være inkluderet.", + "exportingIndividualVaultDescription": { + "message": "Kun individuelle Boksemner tilknyttet $EMAIL$ eksporteres. Organisationsboksemner medtages ikke. Kun Boksemneinformation uden tilhørende vedhæftninger eksporteres.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Selv-hostet" + "selfHostedServer": { + "message": "selv-hostet" }, "thirdParty": { "message": "Tredjepart" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "En notifikation er sendt til din enhed." }, - "logInInitiated": { + "loginInitiated": { "message": "Indlogning påbegyndt" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logger ind på" }, "opensInANewWindow": { "message": "Åbnes i et nyt vindue" }, + "deviceApprovalRequired": { + "message": "Enhedsgodkendelse kræves. Vælg en godkendelsesmulighed nedenfor:" + }, + "rememberThisDevice": { + "message": "Husk denne enhed" + }, + "uncheckIfPublicDevice": { + "message": "Slå fra, hvis en offentlig enhed benyttes" + }, + "approveFromYourOtherDevice": { + "message": "Godkend med min anden enhed" + }, + "requestAdminApproval": { + "message": "Anmod om admin-godkendelse" + }, + "approveWithMasterPassword": { + "message": "Godkend med hovedadgangskode" + }, + "ssoIdentifierRequired": { + "message": "Organisations SSO-identifikator kræves." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "USA", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Adgang nægtet. Nødvendig tilladelse til at se siden mangler." + }, + "general": { + "message": "Generelt" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Konto oprettet!" + }, + "adminApprovalRequested": { + "message": "Admin-godkendelse udbedt" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Anmodningen er sendt til din admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Du underrettes, når godkendelse foreligger." + }, + "troubleLoggingIn": { + "message": "Problemer med at logge ind?" + }, + "loginApproved": { + "message": "Indlogning godkendt" + }, + "userEmailMissing": { + "message": "Brugers e-mail mangler" + }, + "deviceTrusted": { + "message": "Enhed betroet" + }, + "inputRequired": { + "message": "Input obligatorisk." + }, + "required": { + "message": "obligatorisk" + }, + "search": { + "message": "Søg" + }, + "inputMinLength": { + "message": "Input skal udgøre minimum $COUNT$ tegn.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input må maksimalt udgøre $COUNT$ tegn.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Flg. tegn er ikke tilladt: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Inputværdi skal være mindst $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Inputværdi må ikke overstige $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 eller flere e-mails er ugyldige" + }, + "inputTrimValidator": { + "message": "Input må ikke indeholde kun mellemrum.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input er ikke en e-mailadresse." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ felt(er) ovenfor kræver opmærksomhed.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Vælg --" + }, + "multiSelectPlaceholder": { + "message": "-- Skriv for at filtrere --" + }, + "multiSelectLoading": { + "message": "Henter indstillinger…" + }, + "multiSelectNotFound": { + "message": "Ingen emner fundet" + }, + "multiSelectClearAll": { + "message": "Ryd alt" + }, + "plusNMore": { + "message": "+ $QUANTITY$ flere", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Undermenu" + }, + "toggleCollapse": { + "message": "Fold sammen/ud", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Aliasdomæne" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Emner, hvor der anmodes om hovedadgangskode igen, kan ikke autoudfyldes ved sideindlæsning. Autoudfyldning ved sideindlæsning er slået fra.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Autoudfyldning ved sideindlæsning sat til standardindstillingen.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Slå anmodning om hovedadgangskode igen fra for at redigere dette felt", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index c7827d4ddc4..3edd462918f 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -11,7 +11,7 @@ "description": "Extension description" }, "loginOrCreateNewAccount": { - "message": "Du musst dich anmelden oder einen neuen Account erstellen, um auf den Tresor zugreifen zu können." + "message": "Melde dich an oder erstelle ein neues Konto, um auf deinen Tresor zuzugreifen." }, "createAccount": { "message": "Konto erstellen" @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-Ausfüllen" }, + "autoFillLogin": { + "message": "Zugangsdaten automatisch ausfüllen" + }, + "autoFillCard": { + "message": "Karte automatisch ausfüllen" + }, + "autoFillIdentity": { + "message": "Identität automatisch ausfüllen" + }, "generatePasswordCopied": { "message": "Passwort generieren (kopiert)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Keine passenden Zugangsdaten" }, + "noCards": { + "message": "Keine Karten" + }, + "noIdentities": { + "message": "Keine Identitäten" + }, + "addLoginMenu": { + "message": "Zugangsdaten hinzufügen" + }, + "addCardMenu": { + "message": "Karte hinzufügen" + }, + "addIdentityMenu": { + "message": "Identität hinzufügen" + }, "unlockVaultMenu": { "message": "Entsperre deinen Tresor" }, @@ -294,7 +318,7 @@ "message": "Eintragsinformationen" }, "username": { - "message": "Nutzername" + "message": "Benutzername" }, "password": { "message": "Passwort" @@ -338,6 +362,9 @@ "other": { "message": "Sonstige" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Richte eine Entsperrmethode ein, um deine Aktion bei Timeout-Timeout zu ändern." + }, "rateExtension": { "message": "Erweiterung bewerten" }, @@ -357,7 +384,7 @@ "message": "Entsperren" }, "loggedInAsOn": { - "message": "Eingeloggt als $EMAIL$ auf $HOSTNAME$.", + "message": "Angemeldet als $EMAIL$ auf $HOSTNAME$.", "placeholders": { "email": { "content": "$1", @@ -409,7 +436,7 @@ "message": "1 Stunde" }, "fourHours": { - "message": "4 Stunde" + "message": "4 Stunden" }, "onLocked": { "message": "Wenn System gesperrt" @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Aktualisieren" }, + "notificationUnlockDesc": { + "message": "Entsperre deinen Bitwarden Tresor, um die Auto-Ausfüllen-Anfrage abzuschließen." + }, + "notificationUnlock": { + "message": "Entsperren" + }, "enableContextMenuItem": { "message": "Kontextmenüoptionen anzeigen" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Funktion nicht verfügbar" }, - "updateKey": { - "message": "Du kannst diese Funktion nicht nutzen, solange du deinen Verschlüsselungsschlüssel nicht aktualisiert hast." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium-Mitgliedschaft" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API Server-URL" }, "webVaultUrl": { "message": "URL des Web-Tresor-Servers" @@ -1170,7 +1203,7 @@ "message": "Reisepassnummer" }, "licenseNumber": { - "message": "Führerscheinnummer" + "message": "Lizenznummer" }, "email": { "message": "E-Mail" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Eintrag wiederherstellen" }, - "restoreItemConfirmation": { - "message": "Soll dieser Eintrag wirklich wiederhergestellt werden?" - }, "restoredItem": { "message": "Eintrag wiederhergestellt" }, @@ -1582,7 +1612,7 @@ "message": "Desktop-Kommunikation unterbrochen" }, "nativeMessagingWrongUserDesc": { - "message": "Die Desktop-Anwendung ist in ein anderes Konto eingeloggt. Bitte stelle sicher, dass beide Anwendungen mit demselben Konto angemeldet sind." + "message": "Die Desktop-Anwendung ist in einem anderen Konto angemeldet. Bitte stelle sicher, dass beide Anwendungen mit demselben Konto angemeldet sind." }, "nativeMessagingWrongUserTitle": { "message": "Konten stimmen nicht überein" @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Biometrie im Browser wird auf diesem Gerät nicht unterstützt." }, + "biometricsFailedTitle": { + "message": "Biometrie fehlgeschlagen" + }, + "biometricsFailedDesc": { + "message": "Die biometrische Verifizierung kann nicht abgeschlossen werden. Versuch es mit dem Master-Passwort oder melde dich ab. Sollte das Problem weiterhin bestehen, kontaktiere bitte den Bitwarden-Support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Berechtigung nicht erteilt" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Persönlicher Tresor wird exportiert" }, - "exportingPersonalVaultDescription": { - "message": "Nur die einzelnen Tresor-Einträge, die mit $EMAIL$ verbunden sind, werden exportiert. Tresor-Einträge der Organisation werden nicht berücksichtigt.", + "exportingIndividualVaultDescription": { + "message": "Es werden nur persönliche Tresoreinträge exportiert, die mit $EMAIL$ verbunden sind. Tresoreinträge der Organisation werden nicht berücksichtigt. Es werden nur Informationen der Tresoreinträge exportiert. Diese enthalten nicht die zugehörigen Anhänge.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server-Version" }, - "selfHosted": { - "message": "Selbst gehostet" + "selfHostedServer": { + "message": "selbst gehostet" }, "thirdParty": { "message": "Drittanbieter" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Eine Benachrichtigung wurde an dein Gerät gesendet." }, - "logInInitiated": { + "loginInitiated": { "message": "Anmeldung eingeleitet" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Anmelden bei" }, "opensInANewWindow": { "message": "Wird in einem neuen Fenster geöffnet" }, + "deviceApprovalRequired": { + "message": "Geräte-Genehmigung erforderlich. Wähle unten eine Genehmigungsoption aus:" + }, + "rememberThisDevice": { + "message": "Dieses Gerät merken" + }, + "uncheckIfPublicDevice": { + "message": "Deaktivieren, wenn ein öffentliches Gerät verwendet wird" + }, + "approveFromYourOtherDevice": { + "message": "Von deinem anderen Gerät genehmigen" + }, + "requestAdminApproval": { + "message": "Admin-Genehmigung anfragen" + }, + "approveWithMasterPassword": { + "message": "Mit Master-Passwort genehmigen" + }, + "ssoIdentifierRequired": { + "message": "SSO-Kennung der Organisation erforderlich." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Zugriff verweigert. Du hast keine Berechtigung, diese Seite anzuzeigen." + }, + "general": { + "message": "Allgemein" + }, + "display": { + "message": "Anzeige" + }, + "accountSuccessfullyCreated": { + "message": "Konto erfolgreich erstellt!" + }, + "adminApprovalRequested": { + "message": "Admin-Genehmigung angefragt" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Deine Anfrage wurde an deinen Administrator gesendet." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Nach einer Genehmigung wirst du benachrichtigt." + }, + "troubleLoggingIn": { + "message": "Probleme beim Anmelden?" + }, + "loginApproved": { + "message": "Anmeldung genehmigt" + }, + "userEmailMissing": { + "message": "E-Mail-Adresse des Benutzers fehlt" + }, + "deviceTrusted": { + "message": "Gerät wird vertraut" + }, + "inputRequired": { + "message": "Eingabe ist erforderlich." + }, + "required": { + "message": "Erforderlich" + }, + "search": { + "message": "Suche" + }, + "inputMinLength": { + "message": "Die Eingabe muss mindestens $COUNT$ Zeichen lang sein.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Die Eingabe darf $COUNT$ Zeichen nicht überschreiten.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Die folgenden Zeichen sind nicht erlaubt: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Der Eingabewert muss mindestens $MIN$ betragen.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Der Eingabewert darf $MAX$ nicht überschreiten.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "Mindestens 1 E-Mail-Adresse ist ungültig" + }, + "inputTrimValidator": { + "message": "Die Eingabe darf nicht nur Leerzeichen enthalten.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Die Eingabe ist keine E-Mail-Adresse." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ Feld(er) oben benötigen deine Aufmerksamkeit.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Auswählen --" + }, + "multiSelectPlaceholder": { + "message": "-- Schreiben zum Filtern --" + }, + "multiSelectLoading": { + "message": "Optionen werden abgerufen..." + }, + "multiSelectNotFound": { + "message": "Keine Einträge gefunden" + }, + "multiSelectClearAll": { + "message": "Alles löschen" + }, + "plusNMore": { + "message": "+ $QUANTITY$ mehr", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Untermenü" + }, + "toggleCollapse": { + "message": "Ein-/ausklappen", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias-Domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Einträge, die eine erneuten Abfrage des Master-Passworts verlangen, können nicht beim Laden einer Seite automatisch ausgefüllt werden. Auto-Ausfüllen beim Laden einer Seite deaktiviert.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-Ausfüllen beim Lader einer Seite wurde auf die Standardeinstellung gesetzt.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Deaktiviere die erneute Abfrage des Master-Passworts, um dieses Feld zu bearbeiten", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index e6da9f04c8d..2692a89b7db 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -44,7 +44,7 @@ "message": "Η υπόδειξη του κύριου κωδικού μπορεί να σας βοηθήσει να θυμηθείτε τον κωδικό σας, σε περίπτωση που τον ξεχάσετε." }, "reTypeMasterPass": { - "message": "Εισάγετε Ξανά τον Κύριο Κωδικό σας" + "message": "Εισάγετε ξανά τον Κύριο Κωδικό" }, "masterPassHint": { "message": "Υπόδειξη Κύριου Κωδικού (προαιρετικό)" @@ -91,6 +91,15 @@ "autoFill": { "message": "Αυτόματη συμπλήρωση" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Δημιουργία Κωδικού (αντιγράφηκε)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Δεν υπάρχουν αντιστοιχίσεις σύνδεσης." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Ξεκλειδώστε το vault σας" }, @@ -338,6 +362,9 @@ "other": { "message": "Άλλες" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Ρυθμίστε μια μέθοδο ξεκλειδώματος για να αλλάξετε την ενέργεια χρονικού ορίου θησαυ/κιου." + }, "rateExtension": { "message": "Βαθμολογήστε την επέκταση" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Ναι, Ενημέρωση Τώρα" }, + "notificationUnlockDesc": { + "message": "Ξεκλειδώστε το θησαυ/κιο Bitwarden σας για να ολοκληρώσετε το αίτημα αυτόματης πλήρωσης." + }, + "notificationUnlock": { + "message": "Ξεκλείδωμα" + }, "enableContextMenuItem": { "message": "Εμφάνιση επιλογών μενού περιβάλλοντος" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Μη διαθέσιμο χαρακτηριστικό" }, - "updateKey": { - "message": "Δεν μπορείτε να χρησιμοποιήσετε αυτήν τη λειτουργία μέχρι να ενημερώσετε το κλειδί κρυπτογράφησης." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Συνδρομή Premium" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "1 GB κρυπτογραφημένο αποθηκευτικό χώρο για συνημμένα αρχεία." }, - "ppremiumSignUpTwoStep": { - "message": "Πρόσθετες επιλογές σύνδεσης δύο βημάτων, όπως το YubiKey, το FIDO U2F και το Duo." + "premiumSignUpTwoStepOptions": { + "message": "Πρόσθετες επιλογές σύνδεσης δύο βημάτων, όπως το YubiKey και το Duo." }, "ppremiumSignUpReports": { "message": "Ασφάλεια κωδικών, υγεία λογαριασμού και αναφορές παραβίασης δεδομένων για να διατηρήσετε ασφαλές το vault σας." @@ -988,7 +1021,7 @@ "message": "Μπορείτε να απενεργοποιήσετε την αυτόματη συμπλήρωση φόρτωσης σελίδας για μεμονωμένα στοιχεία σύνδεσης από την προβολή Επεξεργασία στοιχείου." }, "itemAutoFillOnPageLoad": { - "message": "Αυτόματη συμπλήρωση της Φόρτισης Σελίδας (αν είναι ενεργοποιημένη στις Επιλογές)" + "message": "Αυτόματη συμπλήρωση κατά τη φόρτωση της σελίδας (αν έχει ενεργοποιηθεί στις Ρυθμίσεις)" }, "autoFillOnPageLoadUseDefault": { "message": "Χρήση προεπιλεγμένης ρύθμισης" @@ -1429,7 +1462,7 @@ "message": "Αναζήτηση Κάδου" }, "permanentlyDeleteItem": { - "message": "Μόνιμη Διαγραφή Αντικειμένου" + "message": "Οριστική διαγραφή αντικειμένου" }, "permanentlyDeleteItemConfirmation": { "message": "Είστε βέβαιοι ότι θέλετε να διαγράψετε μόνιμα αυτό το στοιχείο;" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Ανάκτηση Στοιχείου" }, - "restoreItemConfirmation": { - "message": "Είστε βέβαιοι ότι θέλετε να ανακτήσετε αυτό το στοιχείο;" - }, "restoredItem": { "message": "Στοιχείο που έχει Ανακτηθεί" }, @@ -1462,16 +1492,16 @@ "message": "Αυτόματη συμπλήρωση αντικειμένου" }, "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": "Προειδοποίηση: Αυτή είναι μια μη ασφαλή σελίδα HTTP και οποιαδήποτε πληροφορία υποβάλλετε μπορεί να γίνει ορατή και επεμβάσιμη από άλλους. Αυτή η σύνδεση αποθηκεύτηκε αρχικά σε μια ασφαλή (HTTPS) σελίδα." }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "Θέλετε ακόμα να συμπληρώσετε αυτή τη σύνδεση;" }, "autofillIframeWarning": { - "message": "The form is hosted by a different domain than the URI of your saved login. Choose OK to auto-fill anyway, or Cancel to stop." + "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", @@ -1480,13 +1510,13 @@ } }, "setMasterPassword": { - "message": "Ορισμός Κύριου Κωδικού" + "message": "Καθορισμός κύριου κωδικού" }, "currentMasterPass": { "message": "Τρέχων Κύριος Κωδικός" }, "newMasterPass": { - "message": "Νέος Κύριος Κωδικός" + "message": "Νέος κύριος κωδικός" }, "confirmNewMasterPass": { "message": "Επιβεβαίωση Νέου Κύριου Κωδικού" @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Τα βιομετρικά στοιχεία του προγράμματος περιήγησης δεν υποστηρίζονται σε αυτήν τη συσκευή." }, + "biometricsFailedTitle": { + "message": "Ο βιομετρικός έλεγχος απέτυχε" + }, + "biometricsFailedDesc": { + "message": "Τα βιομετρικά δεν μπόρεσαν να ολοκληρωθούν, σκεφτείτε να χρησιμοποιήσετε έναν κύριο κωδικό πρόσβασης ή να αποσυνδεθείτε. Αν αυτό εξακολουθεί να συμβαίνει, παρακαλώ επικοινωνήστε με την υποστήριξη της Bitwarden." + }, "nativeMessaginPermissionErrorTitle": { "message": "Δεν Έχει Χορηγηθεί Άδεια" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Εξαγωγή Προσωπικού Vault" }, - "exportingPersonalVaultDescription": { - "message": "Θα εξαχθούν μόνο τα προσωπικά αντικείμενα Vault που σχετίζονται με το $EMAIL$ . Τα αντικείμενα Vault οργανισμού δεν θα συμπεριληφθούν.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Έκδοση διακομιστή" }, - "selfHosted": { - "message": "Αυτο-φιλοξενείται" + "selfHostedServer": { + "message": "αυτο-φιλοξενούμενο" }, "thirdParty": { "message": "Τρίτο μέρος" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Μια ειδοποίηση έχει σταλεί στη συσκευή σας." }, - "logInInitiated": { + "loginInitiated": { "message": "Η σύνδεση ξεκίνησε" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Σύνδεση ως" }, "opensInANewWindow": { "message": "Ανοίγει σε νέο παράθυρο" }, + "deviceApprovalRequired": { + "message": "Απαιτείται έγκριση συσκευής. Επιλέξτε μια επιλογή έγκρισης παρακάτω:" + }, + "rememberThisDevice": { + "message": "Απομνημόνευση αυτής της συσκευής" + }, + "uncheckIfPublicDevice": { + "message": "Αποεπιλέξτε αν γίνεται χρήση δημόσιας συσκευής" + }, + "approveFromYourOtherDevice": { + "message": "Έγκριση από άλλη συσκευή σας" + }, + "requestAdminApproval": { + "message": "Αίτηση έγκρισης διαχειριστή" + }, + "approveWithMasterPassword": { + "message": "Έγκριση με τον κύριο κωδικό" + }, + "ssoIdentifierRequired": { + "message": "Απαιτείται αναγνωριστικό οργανισμού SSO." + }, "eu": { - "message": "EU", + "message": "ΕΕ", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Δεν επιτρέπεται η πρόσβαση. Δεν έχετε άδεια για να δείτε αυτή τη σελίδα." + }, + "general": { + "message": "Γενικά" + }, + "display": { + "message": "Εμφάνιση" + }, + "accountSuccessfullyCreated": { + "message": "Επιτυχής δημιουργία λογαριασμού!" + }, + "adminApprovalRequested": { + "message": "Ζητήθηκε έγκριση διαχειριστή" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Το αίτημά σας εστάλη στον διαχειριστή σας." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Θα ειδοποιηθείτε μόλις εγκριθεί." + }, + "troubleLoggingIn": { + "message": "Δεν μπορείτε να συνδεθείτε;" + }, + "loginApproved": { + "message": "Η σύνδεση εγκρίθηκε" + }, + "userEmailMissing": { + "message": "Το email του χρήστη απουσιάζει" + }, + "deviceTrusted": { + "message": "Αξιόπιστη συσκευή" + }, + "inputRequired": { + "message": "Απαιτείται εισαγωγή." + }, + "required": { + "message": "απαιτείται" + }, + "search": { + "message": "Αναζήτηση" + }, + "inputMinLength": { + "message": "Η καταχώρηση πρέπει να είναι τουλάχιστον $COUNT$ χαρακτήρες.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Η καταχώρηση δεν πρέπει να υπερβαίνει τους $COUNT$ χαρακτήρες σε μήκος.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Οι ακόλουθοι χαρακτήρες δεν επιτρέπονται: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Η τιμή καταχώρησης πρέπει να είναι τουλάχιστον $MIN$", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Η τιμή καταχώρησης δεν πρέπει να υπερβαίνει το $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 ή περισσότερα email δεν είναι έγκυρα" + }, + "inputTrimValidator": { + "message": "Η καταχώρηση δεν πρέπει να περιέχει μόνο κενά.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Η καταχώρηση δεν είναι διεύθυνση email." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ Το/α παραπάνω πεδίo/α χρειάζονται την προσοχή σας.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Επιλογή --" + }, + "multiSelectPlaceholder": { + "message": "-- Πληκτρολογήστε για φιλτράρισμα --" + }, + "multiSelectLoading": { + "message": "Ανάκτηση επιλογών..." + }, + "multiSelectNotFound": { + "message": "Δεν βρέθηκαν αντικείμενα" + }, + "multiSelectClearAll": { + "message": "Εκκαθάριση όλων" + }, + "plusNMore": { + "message": "+ $QUANTITY$ περισσότερα", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Υπομενού" + }, + "toggleCollapse": { + "message": "Εναλλαγή σύμπτυξης", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 69d26333a84..22330901579 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API server URL" }, "webVaultUrl": { "message": "Web vault server URL" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Item restored" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 653ff32074c..4e1057956fa 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Item restored" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organisation vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organisation vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organisation SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 3fe6222cfcf..32dd196f2ae 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Yes, update now" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Restored item" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting Personal Vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the personal vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server Version" }, - "selfHosted": { - "message": "Self-Hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-Party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index afd3ea4ce87..b81a05c9a4b 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Autorellenar" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generar contraseña (copiada)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Sin entradas coincidentes." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Desbloquea la caja fuerte" }, @@ -338,6 +362,9 @@ "other": { "message": "Otros" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Valora la extensión" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Actualizar" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Desbloquear" + }, "enableContextMenuItem": { "message": "Mostrar las opciones de menú contextuales" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Característica no disponible" }, - "updateKey": { - "message": "No puedes usar esta característica hasta que actualices tu clave de cifrado." + "encryptionKeyMigrationRequired": { + "message": "Se requiere migración de la clave de cifrado. Por favor, inicie sesión a través de la caja fuerte para actualizar su clave de cifrado." }, "premiumMembership": { "message": "Membresía Premium" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restaurar elemento" }, - "restoreItemConfirmation": { - "message": "¿Estás seguro de que quieres restaurar este elemento?" - }, "restoredItem": { "message": "Elemento restaurado" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "La biometría del navegador no es compatible con este dispositivo." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permiso no proporcionado" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exportando caja fuerte personal" }, - "exportingPersonalVaultDescription": { - "message": "Solo se exportarán los elementos de la caja fuerte personal asociados a $EMAIL$. Los elementos de la caja fuerte de tu organización no se incluirán.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Versión del servidor" }, - "selfHosted": { - "message": "Autoalojado" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Aplicaciones de terceros" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "Se ha enviado una notificación a tu dispositivo." }, - "logInInitiated": { - "message": "Inicio de sesión en proceso" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Contraseña maestra comprometida" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Región" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Abre en una nueva ventana" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { - "message": "Unión Europea", + "message": "EU", "description": "European Union" }, - "us": { - "message": "EE.UU.", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Acceso denegado. No tiene permiso para ver esta página." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 02ee8d04384..4fe20767c0e 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Automaatne täitmine" }, + "autoFillLogin": { + "message": "Täida konto andmed" + }, + "autoFillCard": { + "message": "Täida kaardi andmed" + }, + "autoFillIdentity": { + "message": "Täida identiteet" + }, "generatePasswordCopied": { "message": "Genereeri parool (kopeeritakse)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Sobivaid kontoandmeid ei leitud." }, + "noCards": { + "message": "Kaardid puuduvad" + }, + "noIdentities": { + "message": "Identiteedid puuduvad" + }, + "addLoginMenu": { + "message": "Lisa konto andmed" + }, + "addCardMenu": { + "message": "Lisa kaart" + }, + "addIdentityMenu": { + "message": "Lisa identiteet" + }, "unlockVaultMenu": { "message": "Lukusta hoidla lahti" }, @@ -338,6 +362,9 @@ "other": { "message": "Muu" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Hoidla ajalõpu tegevuse muutmiseks vali esmalt lahtilukustamise meetod." + }, "rateExtension": { "message": "Hinda seda laiendust" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Jah, uuenda" }, + "notificationUnlockDesc": { + "message": "Ava Bitwardeni hoidla, et automaattäide lõpuni viia." + }, + "notificationUnlock": { + "message": "Lukusta lahti" + }, "enableContextMenuItem": { "message": "Kuva parema kliki menüü valikud" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Funktsioon pole saadaval" }, - "updateKey": { - "message": "Seda funktsiooni ei saa enne krüpteerimise võtme uuendamist kasutada." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium versioon" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Taasta kirje" }, - "restoreItemConfirmation": { - "message": "Oled kindel, et soovid selle kirje taastada?" - }, "restoredItem": { "message": "Kirje on taastatud" }, @@ -1462,16 +1492,16 @@ "message": "Kirje täideti" }, "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": "Hoiatus: See on ebaturvaline HTTP lehekülg. Teised osapooled võivad sinu sisestatud infot potentsiaalselt näha ja muuta. Algselt oli see kirje salvestatud turvalise (HTTPS) lehe jaoks." }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "Soovid kirje automaattäita?" }, "autofillIframeWarning": { - "message": "The form is hosted by a different domain than the URI of your saved login. Choose OK to auto-fill anyway, or Cancel to stop." + "message": "See vorm on majutatud teistsugusel domeenil kui sinu salvestatud URI. Vajuta OK, et automaattäita või Tühista, et täitmine peatada." }, "autofillIframeWarningTip": { - "message": "To prevent this warning in the future, save this URI, $HOSTNAME$, to your Bitwarden login item for this site.", + "message": "Selleks, et antud teavitust edaspidi ei kuvataks, salvesta see URI $HOSTNAME$ Bitwardeni kirjesse.", "placeholders": { "hostname": { "content": "$1", @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Brauseri biomeetria ei ole selles seadmes toetatud" }, + "biometricsFailedTitle": { + "message": "Biomeetria nurjus" + }, + "biometricsFailedDesc": { + "message": "Biomeetriaga kinnitamine ebaõnnestus. Kasuta ülemparooli või logi välja. Kui probleem püsib, võta ühendust Bitwardeni toega." + }, "nativeMessaginPermissionErrorTitle": { "message": "Luba puudub" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Personaalse hoidla eksportimine" }, - "exportingPersonalVaultDescription": { - "message": "Ainult personaalsed $EMAIL$ alla kuuluvad kirjed eksportidakse. Organisatsiooni kirjeid ei ekspordita.", + "exportingIndividualVaultDescription": { + "message": "Ainult e-postiga $EMAIL$ seonduvad kirjed eksporditakse. Organisatsiooni kirjeid ei kaasata. Samuti ei kaasata organisatsiooniga seonduvaid manuseid.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Serveri versioon" }, - "selfHosted": { - "message": "Enda majutatud" + "selfHostedServer": { + "message": "enda majutatud" }, "thirdParty": { "message": "Kolmanda osapoole" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Sinu seadmesse saadeti teavitus." }, - "logInInitiated": { + "loginInitiated": { "message": "Sisselogimine on käivitatud" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Piirkond" + "loggingInOn": { + "message": "Sisselogimas kui" }, "opensInANewWindow": { "message": "Avaneb uues aknas" }, + "deviceApprovalRequired": { + "message": "Nõutav on seadme kinnitamine. Vali kinnitamise meetod alt:" + }, + "rememberThisDevice": { + "message": "Mäleta seda seadet" + }, + "uncheckIfPublicDevice": { + "message": "Eemalda märgistus, kui oled avalikus seadmes" + }, + "approveFromYourOtherDevice": { + "message": "Kinnita teises seadmes" + }, + "requestAdminApproval": { + "message": "Küsi admini kinnitust" + }, + "approveWithMasterPassword": { + "message": "Kinnita ülemparooliga" + }, + "ssoIdentifierRequired": { + "message": "Nõutav on organisatsiooni SSO identifikaator." + }, "eu": { "message": "EL", "description": "European Union" }, - "us": { - "message": "USA", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Ligipääs keelatud. Sul pole lubatud seda lehekülge vaadata." + }, + "general": { + "message": "Üldine" + }, + "display": { + "message": "Kuvamine" + }, + "accountSuccessfullyCreated": { + "message": "Konto edukalt loodud!" + }, + "adminApprovalRequested": { + "message": "Päring on saadetud" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Kinnituspäring saadeti adminile." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Kinnitamise järel saad selle kohta teavituse." + }, + "troubleLoggingIn": { + "message": "Kas sisselogimisel on probleeme?" + }, + "loginApproved": { + "message": "Sisselogimine on kinnitatud" + }, + "userEmailMissing": { + "message": "Kasutaja e-post on puudulik" + }, + "deviceTrusted": { + "message": "Seade on usaldusväärne" + }, + "inputRequired": { + "message": "Sisestus on nõutav." + }, + "required": { + "message": "nõutav" + }, + "search": { + "message": "Otsi" + }, + "inputMinLength": { + "message": "Sisend peab olema vähemalt $COUNT$ tähemärki pikk.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Sisend ei tohi olla üle $COUNT$ tähemärgi pikkune.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Järgnevad kirjamärgid pole lubatud: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Sisend peab olema vähemalt $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Sisend ei tohi ületada $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "Üks või rohkem e-posti on kehtetud" + }, + "inputTrimValidator": { + "message": "Sisend ei tohi koosneda ainult tühikutest.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Sisend pole e-posti aadress." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ välja nõuab tähelepanu.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Vali --" + }, + "multiSelectPlaceholder": { + "message": "-- Filtreeritav tüüp --" + }, + "multiSelectLoading": { + "message": "Valikute hankimine..." + }, + "multiSelectNotFound": { + "message": "Ühtki kirjet ei leitud" + }, + "multiSelectClearAll": { + "message": "Tühjenda kõik" + }, + "plusNMore": { + "message": "+ $QUANTITY$ veel", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Alammenüü" + }, + "toggleCollapse": { + "message": "Peida", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domeen" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index e2189ce6a68..291a687f8b4 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-betetzea" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Sortu pasahitza (kopiatuta)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Bat datozen saio-hasierarik gabe" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Desblokeatu kutxa gotorra" }, @@ -196,7 +220,7 @@ "message": "Laguntza eta iritziak" }, "helpCenter": { - "message": "Bitwarden Help center" + "message": "Bitwarden Laguntza zentroa" }, "communityForums": { "message": "Explore Bitwarden community forums" @@ -338,6 +362,9 @@ "other": { "message": "Bestelakoak" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Baloratu gehigarria" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Eguneratu" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Erakutsi laster-menuko aukerak" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Ezaugarria ez dago erabilgarri" }, - "updateKey": { - "message": "Ezin duzu ezaugarri hau erabili zifratze-gakoa eguneratu arte." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium bazkidea" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Berreskuratu elementua" }, - "restoreItemConfirmation": { - "message": "Ziur zaude elementu hau berreskuratu nahi duzula?" - }, "restoredItem": { "message": "Elementua berreskuratua" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Nabigatzailearen biometria ezin da gailu honetan erabili." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Baimena ukatuta" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Kutxa gotor pertsonala esportatzen" }, - "exportingPersonalVaultDescription": { - "message": "$EMAIL$-ekin lotutako kutxa gotor pertsonaleko elementuak bakarrik esportatuko dira. Erakundeko kutxa gotorraren elementuak ez dira sartuko.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Zerbitzariaren bertsioa" }, - "selfHosted": { - "message": "Ostatatze propioduna" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Hirugarrenen aplikazioak" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 0e4a59e164b..bcfab0cdbc8 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "پر کردن خودکار" }, + "autoFillLogin": { + "message": "پر کردن خودکار ورود" + }, + "autoFillCard": { + "message": "پر کردن خودکار کارت" + }, + "autoFillIdentity": { + "message": "پر کردن خودکار هویت" + }, "generatePasswordCopied": { "message": "ساخت کلمه عبور (کپی شد)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "ورودی‌ها منتطبق نیست" }, + "noCards": { + "message": "کارتی وجود ندارد" + }, + "noIdentities": { + "message": "هویتی وجود ندارد" + }, + "addLoginMenu": { + "message": "افزودن ورود" + }, + "addCardMenu": { + "message": "افزودن کارت" + }, + "addIdentityMenu": { + "message": "افزودن هویت" + }, "unlockVaultMenu": { "message": "قفل گاوصندوق خود را باز کنید" }, @@ -338,6 +362,9 @@ "other": { "message": "ساير" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "یک روش بازگشایی برای پایان زمان مجاز تنظیم کنید." + }, "rateExtension": { "message": "به این افزونه امتیاز دهید" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "به‌روزرسانی" }, + "notificationUnlockDesc": { + "message": "برای پر کردن خودکار گاوصندوق Bitwarden خود را باز کنید." + }, + "notificationUnlock": { + "message": "باز کردن قفل" + }, "enableContextMenuItem": { "message": "نمایش گزینه‌های منوی زمینه" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "ویژگی موجود نیست" }, - "updateKey": { - "message": "تا زمانی که کد رمزنگاری را به‌روز نکنید نمی‌توانید از این قابلیت استفاده کنید." + "encryptionKeyMigrationRequired": { + "message": "انتقال کلید رمزگذاری مورد نیاز است. لطفاً از طریق گاوصندوق وب وارد شوید تا کلید رمزگذاری خود را به روز کنید." }, "premiumMembership": { "message": "عضویت پرمیوم" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "۱ گیگابایت فضای ذخیره سازی رمزگذاری شده برای پیوست های پرونده." }, - "ppremiumSignUpTwoStep": { - "message": "گزینه‌های ورود دو مرحله‌ای اضافی مانند YubiKey, FIDO U2F و Duo." + "premiumSignUpTwoStepOptions": { + "message": "گزینه های ورود اضافی دو مرحله ای مانند YubiKey و Duo." }, "ppremiumSignUpReports": { "message": "گزارش‌های بهداشت رمز عبور، سلامت حساب و نقض داده‌ها برای ایمن نگهداشتن گاوصندوق شما." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "بازیابی مورد" }, - "restoreItemConfirmation": { - "message": "آیا مطمئن هستید که می‌خواهید این مورد را بازیابی کنید؟" - }, "restoredItem": { "message": "مورد بازیابی شد" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "بیومتریک مرورگر در این دستگاه پشتیبانی نمی‌شود." }, + "biometricsFailedTitle": { + "message": "زیست‌سنجی ناموفق بود" + }, + "biometricsFailedDesc": { + "message": "زیست‌سنجی نمی‌تواند انجام شود، استفاده از کلمه عبور اصلی یا خروج را در نظر بگیرید. اگر این مشکل ادامه یافت لطفاً با پشتیبانی Bitwarden تماس بگیرید." + }, "nativeMessaginPermissionErrorTitle": { "message": "مجوز ارائه نشده است" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "برون ریزی گاو‌صندوق شخصی" }, - "exportingPersonalVaultDescription": { - "message": "فقط موارد گاو‌صندوق شخصی مرتبط با $EMAIL$ برون ریزی خواهد شد. موارد گاو‌صندوق سازمان شامل نخواهد شد.", + "exportingIndividualVaultDescription": { + "message": "فقط موارد شخصی گاوصندوق مرتبط با $EMAIL$ برون ریزی خواهند شد. موارد گاوصندوق سازمان شامل نخواهد شد. فقط اطلاعات مورد گاوصندوق برون ریزی خواهد شد و شامل تاریخچه کلمه عبور مرتبط یا پیوست نمی‌شود.", "placeholders": { "email": { "content": "$1", @@ -2080,7 +2116,7 @@ "serverVersion": { "message": "نسخه سرور" }, - "selfHosted": { + "selfHostedServer": { "message": "خود میزبان" }, "thirdParty": { @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "یک اعلان به دستگاه شما ارسال شده است." }, - "logInInitiated": { + "loginInitiated": { "message": "ورود به سیستم آغاز شد" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "ورود با" }, "opensInANewWindow": { "message": "در پنجره جدید باز می‌شود" }, + "deviceApprovalRequired": { + "message": "تأیید دستگاه لازم است. یک روش تأیید انتخاب کنید:" + }, + "rememberThisDevice": { + "message": "این دستگاه را به خاطر بسپار" + }, + "uncheckIfPublicDevice": { + "message": "اگر از دستگاه عمومی استفاده می‌کنید علامت را بردارید" + }, + "approveFromYourOtherDevice": { + "message": "تأیید با دستگاه دیگرتان" + }, + "requestAdminApproval": { + "message": "درخواست تأیید مدیر" + }, + "approveWithMasterPassword": { + "message": "تأیید با کلمه عبور اصلی" + }, + "ssoIdentifierRequired": { + "message": "شناسه سازمان SSO مورد نیاز است." + }, "eu": { - "message": "EU", + "message": "اروپا", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "دسترسی رد شد. شما اجازه مشاهده این صفحه را ندارید." + }, + "general": { + "message": "عمومی" + }, + "display": { + "message": "نمایش" + }, + "accountSuccessfullyCreated": { + "message": "حساب کاربری با موفقیت ایجاد شد!" + }, + "adminApprovalRequested": { + "message": "تأیید مدیر درخواست شد" + }, + "adminApprovalRequestSentToAdmins": { + "message": "درخواست شما به مدیرتان فرستاده شد." + }, + "youWillBeNotifiedOnceApproved": { + "message": "به محض تأیید مطلع خواهید شد." + }, + "troubleLoggingIn": { + "message": "در ورود مشکلی دارید؟" + }, + "loginApproved": { + "message": "ورود تأیید شد" + }, + "userEmailMissing": { + "message": "ایمیل کاربر وجود ندارد" + }, + "deviceTrusted": { + "message": "دستگاه مورد اعتماد است" + }, + "inputRequired": { + "message": "ورودی ضروری است." + }, + "required": { + "message": "ضروری" + }, + "search": { + "message": "جستجو" + }, + "inputMinLength": { + "message": "ورودی باید حداقل $COUNT$ کاراکتر داشته باشد.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "طول ورودی نباید بیش از $COUNT$ کاراکتر باشد.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "کاراکترهای زیر مجاز نیستند: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "مقدار ورودی باید حداقل $MIN$ باشد.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "مقدار ورودی نباید از $MAX$ تجاوز کند.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "یک یا چند ایمیل نامعتبر است" + }, + "inputTrimValidator": { + "message": "ورودی نباید فقط حاوی فضای خالی باشد.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "ورودی یک نشانی ایمیل نیست." + }, + "fieldsNeedAttention": { + "message": "فیلد $COUNT$ در بالا به توجه شما نیاز دارد.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- انتخاب --" + }, + "multiSelectPlaceholder": { + "message": "-- برای فیلتر تایپ کنید --" + }, + "multiSelectLoading": { + "message": "در حال بازیابی گزینه‌ها..." + }, + "multiSelectNotFound": { + "message": "موردی یافت نشد" + }, + "multiSelectClearAll": { + "message": "پاک‌کردن همه" + }, + "plusNMore": { + "message": "+ $QUANTITY$ بیشتر", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "زیرمنو" + }, + "toggleCollapse": { + "message": "دکمه بستن", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "دامنه مستعار" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "موارد با درخواست مجدد کلمه عبور اصلی را نمی‌توان در بارگذاری صفحه به‌صورت خودکار پر کرد. پر کردن خودکار در بارگیری صفحه خاموش شد.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "پر کردن خودکار در بارگیری صفحه برای استفاده از تنظیمات پیش‌فرض تنظیم شده است.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "برای ویرایش این فیلد، درخواست مجدد کلمه عبور اصلی را خاموش کنید", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 69b77b26123..daaa9a89258 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -3,11 +3,11 @@ "message": "Bitwarden" }, "extName": { - "message": "Bitwarden – Ilmainen salasananhallinta", + "message": "Bitwarden – Ilmainen salasanahallinta", "description": "Extension name, MUST be less than 40 characters (Safari restriction)" }, "extDesc": { - "message": "Turvallinen ja ilmainen salasanojen hallinta kaikille laitteillesi.", + "message": "Turvallinen ja ilmainen salasanahallinta kaikille laitteillesi.", "description": "Extension description" }, "loginOrCreateNewAccount": { @@ -91,6 +91,15 @@ "autoFill": { "message": "Automaattinen täyttö" }, + "autoFillLogin": { + "message": "Täytä kirjautumistieto automaattisesti" + }, + "autoFillCard": { + "message": "Täytä kortti automaattisesti" + }, + "autoFillIdentity": { + "message": "Täytä identiteetti automaattisesti" + }, "generatePasswordCopied": { "message": "Luo salasana (leikepöydälle)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Ei tunnistettuja kirjautumistietoja." }, + "noCards": { + "message": "Kortteja ei ole" + }, + "noIdentities": { + "message": "Identiteettejä ei ole" + }, + "addLoginMenu": { + "message": "Lisää kirjautumistieto" + }, + "addCardMenu": { + "message": "Lisää kortti" + }, + "addIdentityMenu": { + "message": "Lisää identiteetti" + }, "unlockVaultMenu": { "message": "Avaa holvisi" }, @@ -224,7 +248,7 @@ "message": "Luo kirjautumistiedoillesi automaattisesti vahvoja, ainutlaatuisia salasanoja." }, "bitWebVault": { - "message": "Bitwardenin verkkoholvi" + "message": "Bitwarden Verkkoholvi" }, "importItems": { "message": "Tuo kohteita" @@ -279,7 +303,7 @@ "message": "Vältä epäselviä merkkejä" }, "searchVault": { - "message": "Hae holvista" + "message": "Etsi holvista" }, "edit": { "message": "Muokkaa" @@ -288,7 +312,7 @@ "message": "Näytä" }, "noItemsInList": { - "message": "Ei näytettäviä kohteita." + "message": "Näytettäviä kohteita ei ole." }, "itemInformation": { "message": "Kohteen tiedot" @@ -338,6 +362,9 @@ "other": { "message": "Muut" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Muuta holvisi aikakatkaisutoimintoa määrittämällä lukituksen avaustapa." + }, "rateExtension": { "message": "Arvioi laajennus" }, @@ -574,20 +601,20 @@ "message": "Haluatko varmasti korvata nykyisen käyttäjätunnuksen?" }, "searchFolder": { - "message": "Hae kansiosta" + "message": "Etsi kansiosta" }, "searchCollection": { - "message": "Hae kokoelmasta" + "message": "Etsi kokoelmasta" }, "searchType": { - "message": "Hae tyypeistä" + "message": "Etsi tyypistä" }, "noneFolder": { "message": "Ei kansiota", "description": "This is the folder for uncategorized items" }, "enableAddLoginNotification": { - "message": "Kysy lisätäänkö kirjautimistieto" + "message": "Kysy lisätäänkö kirjautumistieto" }, "addLoginNotificationDesc": { "message": "Kysy lisätäänkö uusi kohde, jos holvissa ei vielä ole sopivaa kohdetta." @@ -619,7 +646,7 @@ "message": "Tallenna" }, "enableChangedPasswordNotification": { - "message": "Kysy päivitetäänkö olemassa oleva kirjautumistieto" + "message": "Kysy päivitetäänkö kirjautumistieto" }, "changedPasswordNotificationDesc": { "message": "Kysy päivitetäänkö kirjautumistiedon salasana sivustolla havaittua muutosta vastaavaksi." @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Päivitä" }, + "notificationUnlockDesc": { + "message": "Viimeistele automaattitäytön pyyntö avaamalla Bitwarden-holvisi lukitus." + }, + "notificationUnlock": { + "message": "Avaa" + }, "enableContextMenuItem": { "message": "Näytä sisältövalikon valinnat" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Ominaisuus ei ole käytettävissä" }, - "updateKey": { - "message": "Et voi käyttää tätä toimintoa ennen kuin päivität salausavaimesi." + "encryptionKeyMigrationRequired": { + "message": "Salausavaimen siirto vaaditaan. Päivitä salausavaimesi kirjautumalla verkkoholviin." }, "premiumMembership": { "message": "Premium-jäsenyys" @@ -786,8 +819,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." @@ -1426,7 +1459,7 @@ "description": "Noun: a special folder to hold deleted items" }, "searchTrash": { - "message": "Hae roskakorista" + "message": "Etsi roskakorista" }, "permanentlyDeleteItem": { "message": "Poista kohde pysyvästi" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Palauta kohde" }, - "restoreItemConfirmation": { - "message": "Haluatko varmasti palauttaa kohteen?" - }, "restoredItem": { "message": "Kohde palautettiin" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Selaimen biometriaa ei tueta tällä laitteella." }, + "biometricsFailedTitle": { + "message": "Biometria epäonnistui" + }, + "biometricsFailedDesc": { + "message": "Biometristä todennusta ei voida suorittaa. Harkitse pääsalasanan käyttämistä tai uloskirjautumista. Jos tämä jatkuu, ole yhteydessä Bitwardenin asiakaspalveluun." + }, "nativeMessaginPermissionErrorTitle": { "message": "Oikeutta ei myönnetty" }, @@ -1637,7 +1673,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "searchSends": { - "message": "Hae Sendeistä", + "message": "Etsi Sendeistä", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "addSend": { @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Henkilökohtaisen holvin vienti" }, - "exportingPersonalVaultDescription": { - "message": "Vain tunnukseen $EMAIL$ liitetyt henkilökohtaisen holvin kohteet viedään. Organisaation kohteet eivät sisälly tähän.", + "exportingIndividualVaultDescription": { + "message": "Vain tunnukseen $EMAIL$ liitetyt yksityisen holvin kohteet viedään. Organisaation holvin kohteita ei sisällytetä. Vain holvin kohteiden tiedot viedään ilman niiden sisältämiä liitteitä.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Palvelimen versio" }, - "selfHosted": { - "message": "Itse ylläpidetty" + "selfHostedServer": { + "message": "itse ylläpidetty" }, "thirdParty": { "message": "Ulkopuolinen taho" @@ -2123,7 +2159,7 @@ "message": "Laitteella kirjautuminen" }, "loginWithDeviceEnabledInfo": { - "message": "Laitteella kirjautuminen on määritettävä Bitwarden-mobiilisovelluksen asetuksista. Tarvitsetko eri vaihtoehdon?" + "message": "Laitteella kirjautuminen on määritettävä Bitwarden-sovelluksen asetuksista. Tarvitsetko eri vaihtoehdon?" }, "fingerprintPhraseHeader": { "message": "Tunnistelauseke" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Laitteellesi on lähetetty ilmoitus." }, - "logInInitiated": { + "loginInitiated": { "message": "Kirjautuminen aloitettu" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Alue" + "loggingInOn": { + "message": "Kirjaudutaan sijaintiin" }, "opensInANewWindow": { "message": "Avautuu uudessa ikkunassa" }, + "deviceApprovalRequired": { + "message": "Laitehyväksyntä vaaditaan. Valitse hyväksyntätapa alta:" + }, + "rememberThisDevice": { + "message": "Muista tämä laite" + }, + "uncheckIfPublicDevice": { + "message": "Poista valinta julkisilla laitteilla" + }, + "approveFromYourOtherDevice": { + "message": "Hyväksy muilta laitteiltasi" + }, + "requestAdminApproval": { + "message": "Pyydä hyväksyntää ylläpidolta" + }, + "approveWithMasterPassword": { + "message": "Hyväksy pääsalasanalla" + }, + "ssoIdentifierRequired": { + "message": "Organisaation kertakirjautumistunniste tarvitaan." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Pääsy estetty. Sinulla ei ole oikeutta avata sivua." + }, + "general": { + "message": "Yleiset" + }, + "display": { + "message": "Ulkoasu" + }, + "accountSuccessfullyCreated": { + "message": "Tilin luonti onnistui!" + }, + "adminApprovalRequested": { + "message": "Hyväksyntää pyydetty ylläpidolta" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Pyyntösi on välitetty ylläpidollesi." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Saat ilmoituksen kun se on hyväksytty." + }, + "troubleLoggingIn": { + "message": "Ongelmia kirjautumisessa?" + }, + "loginApproved": { + "message": "Kirjautuminen hyväksyttiin" + }, + "userEmailMissing": { + "message": "Käyttäjän sähköpostiosoite puuttuu" + }, + "deviceTrusted": { + "message": "Laitteeseen luotettu" + }, + "inputRequired": { + "message": "Syöte vaaditaan." + }, + "required": { + "message": "pakollinen" + }, + "search": { + "message": "Hae" + }, + "inputMinLength": { + "message": "Syötteen tulee sisältää ainakin $COUNT$ merkkiä.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Syötteen enimmäismerkkimäärä on $COUNT$.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Seuraavia merkkejä ei sallita: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Syötteen vähimmäisarvo on $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Syötteen enimmäisarvo on $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "Ainakin yksi sähköpostiosoite on virheellinen" + }, + "inputTrimValidator": { + "message": "Syöte ei voi sisältää vain tyhjiä merkkejä.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Syöte ei ole sähköpostiosoite." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ yllä oleva(a) kenttä(ä) vaatii huomiotasi.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Valitse --" + }, + "multiSelectPlaceholder": { + "message": "-- Suodatettava tyyppi --" + }, + "multiSelectLoading": { + "message": "Noudetaan vaihtoehtoja..." + }, + "multiSelectNotFound": { + "message": "Kohteita ei löytynyt" + }, + "multiSelectClearAll": { + "message": "Tyhjennä kaikki" + }, + "plusNMore": { + "message": "+ $QUANTITY$ lisää", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Alavalikko" + }, + "toggleCollapse": { + "message": "Laajenna tai supista", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Aliasverkkotunnus" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Kohteita, joille pääsalasanan uudelleenkysely on käytössä, ei voida täyttää automaattisesti sivujen avautuessa. Automaattinen täyttö sivujen avautuessa poistettiin käytöstä.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Automaattinen täyttö sivun avautuessa käyttää oletusasetusta.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Poista pääsalasanan uudelleenkysely käytöstä muokataksesi kenttää", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index e98884c2007..464cf888899 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill sa Filipino ay Awtomatikong Pagpuno" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Maglagay ng Password" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Walang tumutugmang mga login" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Buksan ang iyong kahadeyero" }, @@ -338,6 +362,9 @@ "other": { "message": "Iba pa" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "I-rate ang extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "I-update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Ipakita ang mga opsyon ng menu ng konteksto" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Hindi magagamit ang tampok" }, - "updateKey": { - "message": "Hindi mo maari gamitin ang tampok na ito hanggang hindi mo iupdate ang iyong encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Pagiging miyembro ng premium" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Ibalik ang item" }, - "restoreItemConfirmation": { - "message": "Sigurado ka bang nais mong ibalik ang item na ito?" - }, "restoredItem": { "message": "Item na nai-restore" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Ang browser biometrics ay hindi sinusuportahan sa device na ito." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permiso ay hindi ibinigay" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Nag-export ng indibidwal na vault" }, - "exportingPersonalVaultDescription": { - "message": "Lamang ang mga item ng indibidwal na vault na nauugnay sa $EMAIL$ ang maie-export. Hindi kasama ang mga item ng vault ng organisasyon.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Bersyon ng server" }, - "selfHosted": { - "message": "Auto-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Ika-tatlong-partido" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "Naipadala na ang notification sa iyong device." }, - "logInInitiated": { - "message": "Mag log in na sinimulan" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Nakalantad na Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 8f4e769c695..b0827b39006 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Saisie automatique" }, + "autoFillLogin": { + "message": "Saisie automatique de l'identifiant" + }, + "autoFillCard": { + "message": "Saisie automatique de la carte" + }, + "autoFillIdentity": { + "message": "Saisie automatique de l'identité" + }, "generatePasswordCopied": { "message": "Générer un mot de passe (copié)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Aucun identifiant correspondant." }, + "noCards": { + "message": "Aucune carte" + }, + "noIdentities": { + "message": "Aucune identité" + }, + "addLoginMenu": { + "message": "Ajouter un identifiant" + }, + "addCardMenu": { + "message": "Ajouter une carte" + }, + "addIdentityMenu": { + "message": "Ajouter une identité" + }, "unlockVaultMenu": { "message": "Déverrouillez votre coffre" }, @@ -338,6 +362,9 @@ "other": { "message": "Autre" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Configurez une méthode de déverrouillage pour changer le délai d'attente de votre coffre." + }, "rateExtension": { "message": "Noter l'extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Mettre à jour" }, + "notificationUnlockDesc": { + "message": "Déverrouillez votre coffre Bitwarden pour terminer la demande de saisie automatique." + }, + "notificationUnlock": { + "message": "Déverrouiller" + }, "enableContextMenuItem": { "message": "Afficher les options du menu contextuel" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Fonctionnalité non disponible" }, - "updateKey": { - "message": "Vous ne pouvez pas utiliser cette fonctionnalité avant de mettre à jour votre clé de chiffrement." + "encryptionKeyMigrationRequired": { + "message": "Migration de la clé de chiffrement nécessaire. Veuillez vous connecter sur le coffre web pour mettre à jour votre clé de chiffrement." }, "premiumMembership": { "message": "Adhésion Premium" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restaurer l'élément" }, - "restoreItemConfirmation": { - "message": "Êtes-vous sûr de vouloir restaurer cet élément ?" - }, "restoredItem": { "message": "Élément restauré" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Le déverrouillage biométrique dans le navigateur n’est pas pris en charge sur cet appareil" }, + "biometricsFailedTitle": { + "message": "Le déverrouillage biométique a échoué\n" + }, + "biometricsFailedDesc": { + "message": "Impossible d'utiliser le déverrouillage biométrique, utilisez votre mot de passe principal ou déconnectez-vous. Si le problème persiste, veuillez contacter le support Bitwarden." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission non accordée" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Export du coffre personnel" }, - "exportingPersonalVaultDescription": { - "message": "Seuls les éléments individuels du coffre associés à $EMAIL$ seront exportés. Les éléments du coffre de l'organisation ne seront pas inclus.", + "exportingIndividualVaultDescription": { + "message": "Seuls les éléments individuels du coffre associés à $EMAIL$ seront exportés. Les éléments du coffre de l'organisation ne seront pas inclus. Seules les informations sur les éléments du coffre seront exportées et n'incluront pas les pièces jointes associées.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Version du serveur" }, - "selfHosted": { - "message": "Auto-hébergé" + "selfHostedServer": { + "message": "auto-hébergé" }, "thirdParty": { "message": "Tierce partie" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Une notification a été envoyée à votre appareil." }, - "logInInitiated": { + "loginInitiated": { "message": "Connexion initiée" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Région" + "loggingInOn": { + "message": "Connexion sur" }, "opensInANewWindow": { "message": "S'ouvre dans une nouvelle fenêtre" }, + "deviceApprovalRequired": { + "message": "L'approbation de l'appareil est requise. Sélectionnez une option d'approbation ci-dessous :" + }, + "rememberThisDevice": { + "message": "Se souvenir de cet appareil" + }, + "uncheckIfPublicDevice": { + "message": "Décocher si vous utilisez un appareil public" + }, + "approveFromYourOtherDevice": { + "message": "Approuver sur votre autre appareil" + }, + "requestAdminApproval": { + "message": "Demander l'approbation de l'administrateur" + }, + "approveWithMasterPassword": { + "message": "Approuver avec le mot de passe principal" + }, + "ssoIdentifierRequired": { + "message": "Identifiant SSO de l'organisation requis." + }, "eu": { - "message": "EU", + "message": "UE", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Accès refusé. Vous n'avez pas l'autorisation de voir cette page." + }, + "general": { + "message": "Général" + }, + "display": { + "message": "Affichage" + }, + "accountSuccessfullyCreated": { + "message": "Compte créé avec succès !" + }, + "adminApprovalRequested": { + "message": "Approbation de l'administrateur demandée" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Demande transmise à votre administrateur." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Vous serez notifié une fois approuvé." + }, + "troubleLoggingIn": { + "message": "Problème pour vous connecter ?" + }, + "loginApproved": { + "message": "Connexion approuvée" + }, + "userEmailMissing": { + "message": "Courriel de l'utilisateur manquant" + }, + "deviceTrusted": { + "message": "Appareil de confiance" + }, + "inputRequired": { + "message": "Saisie requise." + }, + "required": { + "message": "requis" + }, + "search": { + "message": "Rechercher" + }, + "inputMinLength": { + "message": "La saisie doit comporter au moins $COUNT$ caractères.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "La saisie ne doit pas dépasser $COUNT$ caractères de long.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Les caractères suivants ne sont pas autorisés : $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "La valeur d'entrée doit être au moins de $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "La valeur d'entrée ne doit pas excéder $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "Une ou plusieurs adresses e-mail ne sont pas valides" + }, + "inputTrimValidator": { + "message": "La saisie ne doit pas contenir que des espaces.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "La saisie n'est pas une adresse e-mail." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ champ(s) ci-dessus nécessitent votre attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Sélectionner --" + }, + "multiSelectPlaceholder": { + "message": "-- Saisir pour filtrer --" + }, + "multiSelectLoading": { + "message": "Récupération des options..." + }, + "multiSelectNotFound": { + "message": "Aucun élément trouvé" + }, + "multiSelectClearAll": { + "message": "Effacer tout" + }, + "plusNMore": { + "message": "+ $QUANTITY$ de plus", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Sous-menu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Domaine de l'alias" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Désactivez la resaisie du mot de passe maître pour éditer ce champ", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 69d26333a84..22330901579 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API server URL" }, "webVaultUrl": { "message": "Web vault server URL" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Item restored" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index fc90bade6c0..19985cd5ec8 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "השלמה אוטומטית" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "צור סיסמה (העתק)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "לא נמצאו פרטי כניסה תואמים." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "שחרור הכספת שלך" }, @@ -338,6 +362,9 @@ "other": { "message": "אחר" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "דירוג הרחבה" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "כן, עדכן עכשיו" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "יכולת זו לא זמינה" }, - "updateKey": { - "message": "לא ניתן להשתמש ביכולת זו עד שתעדכן את מפתח ההצפנה שלך." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "חשבון פרימיום" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "1 ג'יגה של מקום אחסון עבור קבצים מצורפים." }, - "ppremiumSignUpTwoStep": { - "message": "אפשרויות כניסה דו שלבית מתקדמות כמו YubiKey, FIDO U2F, וגם Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "היגיינת סיסמאות, מצב בריאות החשבון, ודיווחים מעודכנים על פרצות חדשות בכדי לשמור על הכספת שלך בטוחה." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "שחזר פריט" }, - "restoreItemConfirmation": { - "message": "האם אתה בטוח שברצונך לשחזר פריט זה?" - }, "restoredItem": { "message": "פריט ששוחזר" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "מכשיר זה לא תומך בזיהוי ביומטרי בדפדפן." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "הרשאה לא סופקה" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "הכספת האישית מיוצאת" }, - "exportingPersonalVaultDescription": { - "message": "רק פריטי הכספת האישית שמשויכת אל $EMAIL$ ייוצאו. פריטי הכספת הארגוניים לא יהיו חלק מהייצוא.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "כללי" + }, + "display": { + "message": "תצוגה" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index df561ad0e47..4b686192902 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "स्वत:भरण" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate Password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "कोई मेल-मिला लॉगिन नहीं |" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "आपकी तिजोरी का ताला खोलें" }, @@ -338,6 +362,9 @@ "other": { "message": "अन्य" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the Extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Yes, Update Now" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "संदर्भ मेनू विकल्प दिखाएं" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature Unavailable" }, - "updateKey": { - "message": "जब तक आप अपनी एन्क्रिप्शन कुंजी को अपडेट नहीं करते, तब तक आप इस सुविधा का उपयोग नहीं कर सकते हैं।" + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium Membership" @@ -786,8 +819,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": "अपनी वॉल्ट को सुरक्षित रखने के लिए पासवर्ड स्वच्छता, खाता स्वास्थ्य और डेटा उल्लंघन रिपोर्ट।" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "आइटम बहाल करें" }, - "restoreItemConfirmation": { - "message": "क्या आप सुनिश्चित हैं कि आप इस आइटम को बहाल करना चाहते हैं?" - }, "restoredItem": { "message": "बहाल आइटम" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "ब्राउज़र बॉयोमीट्रिक्स इस डिवाइस पर समर्थित नहीं है।" }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "अनुमति नहीं दी गयी है" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "केवल $EMAIL$ से संबद्ध, व्यक्तिगत वॉल्ट वस्तुएँ निर्यात की जाएंगी. संगठन वॉल्ट वस्तुएँ शामिल नहीं की जाएंगी. केवल वॉल्ट वस्तुओं की जानकारी निर्यात की जाएगी और इसमें संबंधित अनुलग्नक शामिल नहीं होंगे.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "डोमेन उपनाम" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 37ecb3de8c8..b334d22332f 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-ispuna" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generiraj lozinku (i kopiraj)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Nema podudarajućih prijava" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Otključaj svoj trezor" }, @@ -338,6 +362,9 @@ "other": { "message": "Ostalo" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Za promjenu vremena isteka trezora, odredi način otključavanja." + }, "rateExtension": { "message": "Ocijeni proširenje" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Ažuriraj" }, + "notificationUnlockDesc": { + "message": "Za dovršetak auto-ispune, otključaj svoj trezor." + }, + "notificationUnlock": { + "message": "Otključaj" + }, "enableContextMenuItem": { "message": "Prikaži opcije kotekstualnog izbornika" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Značajka nije dostupna" }, - "updateKey": { - "message": "Ne možeš koristiti ovu značajku prije nego ažuriraš ključ za šifriranje." + "encryptionKeyMigrationRequired": { + "message": "Potrebna je migracija ključa za šifriranje. Prijavi se na web trezoru za ažuriranje ključa za šifriranje." }, "premiumMembership": { "message": "Premium članstvo" @@ -786,8 +819,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": "Mogućnosti za prijavu u dva koraka kao što su YubiKey i Duo." }, "ppremiumSignUpReports": { "message": "Higijenu lozinki, zdravlje računa i izvještaje o krađi podatak radi zaštite svojeg trezora." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Vrati stavku" }, - "restoreItemConfirmation": { - "message": "Sigurno želiš vratiti ovu stavku?" - }, "restoredItem": { "message": "Stavka vraćena" }, @@ -1450,7 +1480,7 @@ "message": "Odjava će ukloniti pristup tvom trezoru i zahtijevati mrežnu potvrdu identiteta nakon isteka vremenske neaktivnosti. Sigurno želiš koristiti ovu postavku?" }, "vaultTimeoutLogOutConfirmationTitle": { - "message": "Potvrda akcije vremenske neaktivnosti" + "message": "Potvrda radnje nakon vremenske neaktivnosti" }, "autoFillAndSave": { "message": "Auto-ispuni i spremi" @@ -1462,16 +1492,16 @@ "message": "Auto-ispunjena stavka" }, "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": "Upozorenje: Ovo je nezaštićena HTTP stranica i svi podaci koje preko nje pošalješ drugi mogu vidjeti i izmijeniti. Ova prijava je prvotno bila spremljena za sigurnu (HTTPS) stranicu." }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "Želiš li i dalje ispuniti ove podatke za prijavu?" }, "autofillIframeWarning": { - "message": "The form is hosted by a different domain than the URI of your saved login. Choose OK to auto-fill anyway, or Cancel to stop." + "message": "Obrazac je na poslužitelju koji se nalazi na drugačijoj domeni od URI-a za koji su spremljeni tvoji podaci za pristup. Odobri za auto-ispunu ili odustani za prekid." }, "autofillIframeWarningTip": { - "message": "To prevent this warning in the future, save this URI, $HOSTNAME$, to your Bitwarden login item for this site.", + "message": "Kako se ovo upozorenje ubuduće ne bi prikazivalo, spremi ovaj URI ( $HOSTNAME$) u svoju stavku za prijavu.", "placeholders": { "hostname": { "content": "$1", @@ -1483,13 +1513,13 @@ "message": "Postavi glavnu lozinku" }, "currentMasterPass": { - "message": "Current master password" + "message": "Trenutna glavna lozinka" }, "newMasterPass": { - "message": "New master password" + "message": "Nova glavna lozinka" }, "confirmNewMasterPass": { - "message": "Confirm new master password" + "message": "Potvrdi novu glavnu lozinku" }, "masterPasswordPolicyInEffect": { "message": "Jedno ili više pravila organizacije zahtijeva da tvoja glavna lozinka ispunjava sljedeće uvjete:" @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Biometrija preglednika nije podržana na ovom uređaju." }, + "biometricsFailedTitle": { + "message": "Biometrija neuspješna" + }, + "biometricsFailedDesc": { + "message": "Biometrija se ne može dovršiti. Pokušaj glavnom lozinkom ili se odjavi i ponovno prijavi. Ako se ovo nastavi, obrati se Bitwarden podršci." + }, "nativeMessaginPermissionErrorTitle": { "message": "Dopuštenje nije dano" }, @@ -1872,7 +1908,7 @@ "message": "Tvoju glavnu lozinku je nedavno promijenio administrator tvoje organizacije. Za pristup trezoru, potrebno je ažurirati glavnu lozinku, što će te odjaviti iz trenutne sesije, te ćeš se morati ponovno prijaviti. Aktivne sesije na drugim uređajima mogu ostati aktivne još sat vremena." }, "updateWeakMasterPasswordWarning": { - "message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." + "message": "Tvoja glavna lozinka ne zadovoljava pravila ove organizacije. Za pristup trezoru moraš odmah ažurirati svoju glavnu lozinku. Ako nastaviš, odjaviti ćeš se iz trenutne sesije te ćeš se morati ponovno prijaviti. Aktivne sesije na drugim uređajima mogu ostati aktivne do jedan sat." }, "resetPasswordPolicyAutoEnroll": { "message": "Automatsko učlanjenje" @@ -1893,7 +1929,7 @@ "message": "minuta" }, "vaultTimeoutPolicyInEffect": { - "message": "Pravilo tvoje organizacije utječe na istek trezora. Najveće dozvoljeno vrijeme isteka je $HOURS$:$MINUTES$ h.", + "message": "Pravilo tvoje organizacije podesilo je najveće dozvoljeno vrijeme isteka trezora na $HOURS$:$MINUTES$ h.", "placeholders": { "hours": { "content": "$1", @@ -1906,7 +1942,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is $HOURS$ hour(s) and $MINUTES$ minute(s). Your vault timeout action is set to $ACTION$.", + "message": "Pravilo tvoje organizacije utječe na istek trezora. Najveće dozvoljeno vrijeme isteka je $HOURS$:$MINUTES$ h. Tvoja radnja nakon isteka trezora je: $ACTION$.", "placeholders": { "hours": { "content": "$1", @@ -1923,7 +1959,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "Your organization policies have set your vault timeout action to $ACTION$.", + "message": "Pravilo tvoje organizacije podesilo je radnju nakon isteka trezora na: $ACTION$.", "placeholders": { "action": { "content": "$1", @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Izvoz osobnog trezora" }, - "exportingPersonalVaultDescription": { - "message": "Izvest će se samo stavke osobnog trezora povezanog s $EMAIL$. Stavke organizacijskog trezora neće biti uključene.", + "exportingIndividualVaultDescription": { + "message": "Izvest će se samo stavke osobnog trezora povezanog s $EMAIL$. Stavke organizacijskog trezora neće biti uključene. Izvest će se samo informacija o stavci trezora bez pripadajućih podataka o povijesti lozinki i privitaka.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Verzija poslužitelja" }, - "selfHosted": { - "message": "Vlastiti poslužitelj" + "selfHostedServer": { + "message": "vlastiti poslužitelj" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "Obavijest je poslana na tvoj uređaj." }, - "logInInitiated": { - "message": "Pokrenuta prijava" + "loginInitiated": { + "message": "Prijava pokrenuta" }, "exposedMasterPassword": { "message": "Ukradena glavna lozinka" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Prijava na" }, "opensInANewWindow": { - "message": "Opens in a new window" + "message": "Otvara u novom prozoru" + }, + "deviceApprovalRequired": { + "message": "Potrebno je odobriti uređaj. Odaberi metodu odobravanja:" + }, + "rememberThisDevice": { + "message": "Zapamti ovaj uređaj" + }, + "uncheckIfPublicDevice": { + "message": "Odznači ako koristiš javni uređaj" + }, + "approveFromYourOtherDevice": { + "message": "Odobri drugim uređajem" + }, + "requestAdminApproval": { + "message": "Zatraži odobrenje administratora" + }, + "approveWithMasterPassword": { + "message": "Odobri glavnom lozinkom" + }, + "ssoIdentifierRequired": { + "message": "Potreban je identifikator organizacije." }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Pristup odbijen. Nemaš prava vidjeti ovu stranicu." + }, + "general": { + "message": "Opće" + }, + "display": { + "message": "Prikaz" + }, + "accountSuccessfullyCreated": { + "message": "Račun je uspješno stvoren!" + }, + "adminApprovalRequested": { + "message": "Zatraženo odobrenje administratora" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Tvoj zahtjev je poslan administratoru." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Dobiti ćeš obavijest kada bude odobreno." + }, + "troubleLoggingIn": { + "message": "Problem s prijavom?" + }, + "loginApproved": { + "message": "Prijava odobrena" + }, + "userEmailMissing": { + "message": "Nedostaje e-pošta korisnika" + }, + "deviceTrusted": { + "message": "Uređaj pouzdan" + }, + "inputRequired": { + "message": "Potreban je unos." + }, + "required": { + "message": "obavezno" + }, + "search": { + "message": "Traži" + }, + "inputMinLength": { + "message": "Unos mora sadržavati najmanje $COUNT$ znakova.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Unos ne smije imati više od $COUNT$ znakova.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Ovi znakovi nisu dozvoljeni: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Unos mora biti najmanje $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Unos ne smije biti više od $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "Jedna ili više adresa e-pošte nije valjana" + }, + "inputTrimValidator": { + "message": "Unos ne smije biti prazan.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Nije unesena adresa e-pošte." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ polje/a treba tvoju pažnju.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Odaberi --" + }, + "multiSelectPlaceholder": { + "message": "-- Upiši za filtriranje --" + }, + "multiSelectLoading": { + "message": "Dohvaćanje opcija..." + }, + "multiSelectNotFound": { + "message": "Nije pronađena niti jedna stavka" + }, + "multiSelectClearAll": { + "message": "Očisti sve" + }, + "plusNMore": { + "message": "+ još $QUANTITY$", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Podizbornik" + }, + "toggleCollapse": { + "message": "Sažmi/Proširi", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domene" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Stavke za koje je potrebna glavna lozinka neće se auto-ispuniti kod učitavanja stranice. Auto-ispuna pri učitavanju stranice je isključena.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-ispuna kod učitavanja stranice koristi zadane postavke.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Isključi traženje glavne lozinke za promjenu ovog polja", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index c5e8214b635..abf5a12e5e5 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Automatikus kitöltés" }, + "autoFillLogin": { + "message": "Automatikus kitöltés bejelentkezés" + }, + "autoFillCard": { + "message": "Automatikus kitöltés kártya" + }, + "autoFillIdentity": { + "message": "Automatikus kitöltés személyazonosság" + }, "generatePasswordCopied": { "message": "Jelszó generálás (másolt)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Nincsenek egyező bejelentkezések." }, + "noCards": { + "message": "Nincsenek kártyák" + }, + "noIdentities": { + "message": "Nincsenek személyazonosságok" + }, + "addLoginMenu": { + "message": "Bejelentkezés hozzáadása" + }, + "addCardMenu": { + "message": "Kártya hozzáadása" + }, + "addIdentityMenu": { + "message": "Személyazonossság hozzáadása" + }, "unlockVaultMenu": { "message": "Széf kinyitása" }, @@ -143,7 +167,7 @@ "message": "A folytatáshoz meg kell erősíteni a személyazonosságot." }, "account": { - "message": "Felhasználó" + "message": "Fiók" }, "changeMasterPassword": { "message": "Mesterjelszó módosítása" @@ -338,6 +362,9 @@ "other": { "message": "Egyéb" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Állítsunk be egy feloldási módot a széf időkifutási műveletének módosításához." + }, "rateExtension": { "message": "Bővítmény értékelése" }, @@ -510,7 +537,7 @@ "message": "A kétlépcsős bejelentkezés biztonságosabbá teszi a fiókot azáltal, hogy ellenőrizni kell a bejelentkezést egy másik olyan eszközzel mint például biztonsági kulcs, hitelesítő alkalmazás, SMS, telefon hívás vagy email. A kétlépcsős bejelentkezést a bitwarden.com webes széfben lehet engedélyezni. Felkeressük a webhelyet most?" }, "editedFolder": { - "message": "A mappa módosításra került." + "message": "A mappa mentésre került." }, "deleteFolderConfirmation": { "message": "Biztos, hogy törölni akarod ezt a mappát?" @@ -559,7 +586,7 @@ "message": "Biztosan törlésre kerüljön ezt az elem?" }, "deletedItem": { - "message": "Az elem törlésre került." + "message": "Az elem a lomtárba került." }, "overwritePassword": { "message": "Jelszó felülírása" @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Frissítés" }, + "notificationUnlockDesc": { + "message": "A Bitwarden széf feloldása az automatikus kitöltési kérés teljesítéséhez." + }, + "notificationUnlock": { + "message": "Feloldás" + }, "enableContextMenuItem": { "message": "Helyi menü opciók megjelenítése" }, @@ -760,10 +793,10 @@ "message": "A naximális fájlméret 500 MB." }, "featureUnavailable": { - "message": "Ez a funkció nem érhető el." + "message": "A funkció nem érhető el." }, - "updateKey": { - "message": "Ez a funkció nem használható, amíg nem frissíted a titkosítási kulcsod." + "encryptionKeyMigrationRequired": { + "message": "Titkosítási kulcs migráció szükséges. Jelentkezzünk be a webes széfen keresztül a titkosítási kulcs frissítéséhez." }, "premiumMembership": { "message": "Prémium tagság" @@ -778,7 +811,7 @@ "message": "Tagság frissítése" }, "premiumNotCurrentMember": { - "message": "Jelenleg nincs prémium tagság." + "message": "Jelenleg nem vagyunk prémium tag." }, "premiumSignUpAndGet": { "message": "Regisztráció a prémium tagságra az alábbi funkciókért:" @@ -786,8 +819,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": "Saját kétlépcsős bejelentkezési lehetőségek mint a YubiKey és a Duo." }, "ppremiumSignUpReports": { "message": "Jelszó higiénia, fiók biztonság és adatszivárgási jelentések a széf biztonsága érdekében." @@ -808,7 +841,7 @@ "message": "A prémium tagság megvásárolható a bitwarden.com webes széfben. Szeretnénk felkeresni a webhelyet most?" }, "premiumCurrentMember": { - "message": "Jelenleg a prémium tagság érvényben van." + "message": "Prémium tag vagyunk!" }, "premiumCurrentMemberThanks": { "message": "Köszönjük a Bitwarden támogatását." @@ -985,7 +1018,7 @@ "message": "Alapértelmezett beállítások bejelentkezési elemekhez" }, "defaultAutoFillOnPageLoadDesc": { - "message": "Az Automatikus kitöltés engedélyezése az oldalbetöltéskor engedélyezheti vagy letilthatja a funkciót az egyes bejelentkezési elemeknél. Ez az alapértelmezett beállítás a bejelentkezési elemeknéll, amelyek nincsenek külön konfigurálva." + "message": "Az egyes bejelentkezési elemeknél kikapcsolhatjuk oldalbetöltéskor az automatikus kitöltést az elem Szerkesztés nézetében." }, "itemAutoFillOnPageLoad": { "message": "Automatikus kitöltés oldal betöltésnél (Ha engedélyezett az opcióknál)" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Elem visszaállítása" }, - "restoreItemConfirmation": { - "message": "Biztosan visszaállításra kerüljön ezt az elem?" - }, "restoredItem": { "message": "Visszaállított elem" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "A böngésző biometrikus adatait ez az eszköz nem támogatja." }, + "biometricsFailedTitle": { + "message": "A biometria nem sikerült." + }, + "biometricsFailedDesc": { + "message": "A biometrikus adatokat nem lehet kitölteni, fontoljuk meg a mesterjelszó használatát vagy a kijelentkezést. Ha ez továbbra is fennáll, forduljunk a Bitwarden ügyfélszolgálatához." + }, "nativeMessaginPermissionErrorTitle": { "message": "A jogosultság nincs megadva." }, @@ -1893,7 +1929,7 @@ "message": "Perc" }, "vaultTimeoutPolicyInEffect": { - "message": "A szervezeti házirendek hatással vannak a széf időkorlátjára. A széf időkorlátja legfeljebb $HOURS$ óra és $MINUTES$ perc lehet.", + "message": "A szervezeti szabályzata $HOURS$ órára és $MINUTES$ percre állította be a maximálisan megengedett széf időtúllépést.", "placeholders": { "hours": { "content": "$1", @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Személyes széf exportálása" }, - "exportingPersonalVaultDescription": { - "message": "Csak $EMAIL$ email címmel társított személyes széf elemek kerülnek exportálásra. Ebbe nem kerülnek be a szervezeti széf elemek.", + "exportingIndividualVaultDescription": { + "message": "$EMAIL$ email címhez társított egyedi széfek kerülnek csak exportálásra. A szervezeti széf elemei nem lesznek benne. Csak a széf információk kerülnek exportálásra és nem tartalmazzák a kapcsolódó mellékleteket.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Szerver verzió" }, - "selfHosted": { - "message": "Saját kiszolgáló" + "selfHostedServer": { + "message": "saját üzemeltetésű" }, "thirdParty": { "message": "Harmadik fél" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Egy értesítés lett elküldve az eszközre." }, - "logInInitiated": { + "loginInitiated": { "message": "A bejelentkezés elindításra került." }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Régió" + "loggingInOn": { + "message": "Bejelentkezés:" }, "opensInANewWindow": { "message": "Megnyitás új ablakban" }, + "deviceApprovalRequired": { + "message": "Az eszköz jóváhagyása szükséges. Válasszunk egy jóváhagyási lehetőséget lentebb:" + }, + "rememberThisDevice": { + "message": "Eszköz megjegyzése" + }, + "uncheckIfPublicDevice": { + "message": "Töröljük a jelölést, ha nyilvános eszközt használunk." + }, + "approveFromYourOtherDevice": { + "message": "Jóváhagyás másik eszközzel" + }, + "requestAdminApproval": { + "message": "Adminisztrátori jóváhagyás kérés" + }, + "approveWithMasterPassword": { + "message": "Jóváhagyás mesterjelszóval" + }, + "ssoIdentifierRequired": { + "message": "A szervezeti SSO azonosító megadása szükséges." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "A hozzáférés megtagadásra került. Nincs jogosultság az oldal megtekintésére." + }, + "general": { + "message": "Általános" + }, + "display": { + "message": "Megjelenítés" + }, + "accountSuccessfullyCreated": { + "message": "A fiók sikeresen létrehozásra került." + }, + "adminApprovalRequested": { + "message": "Adminisztrátori jóváhagyás kérés történt" + }, + "adminApprovalRequestSentToAdmins": { + "message": "A kérés elküldésre került az adminisztrátornak." + }, + "youWillBeNotifiedOnceApproved": { + "message": "A jóváhagyás után értesítés érkezik." + }, + "troubleLoggingIn": { + "message": "Probléma van a bejelentkezéssel?" + }, + "loginApproved": { + "message": "A bejelentkezés jóváhagyásra került." + }, + "userEmailMissing": { + "message": "A felhasználói email cím hiányzik." + }, + "deviceTrusted": { + "message": "Az eszköz megbízható." + }, + "inputRequired": { + "message": "Az adatbevitel kötelező." + }, + "required": { + "message": "kötelező" + }, + "search": { + "message": "Keresés" + }, + "inputMinLength": { + "message": "A bevitel legyen legalább $COUNT$ karakter hosszú.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "A bevitel nem haladhatja meg $COUNT$ karakter hosszt.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "A következő karakterek nem engedélyezettek: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "A beviteli érték legyen legalább $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "A beviteli érték ne haladja meg $MAX$ értéket.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 vagy több email cím érvénytelen." + }, + "inputTrimValidator": { + "message": "A bevitel nem tartalmazhat csak fehér szóköz karaktert.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Az megadott bevitel nem email cím." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ mező fentebb figyelmet érdemel.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Választás --" + }, + "multiSelectPlaceholder": { + "message": "-- Gépelés a szűréshez --" + }, + "multiSelectLoading": { + "message": "Az opciók beolvasása folyamatban can..." + }, + "multiSelectNotFound": { + "message": "Nem található elem." + }, + "multiSelectClearAll": { + "message": "Összes törlése" + }, + "plusNMore": { + "message": "+ $QUANTITY$ további", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Almenü" + }, + "toggleCollapse": { + "message": "Összezárás váltás", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Áldomain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "A mesterjelszót újra bekérő elemeket nem lehet automatikusan kitölteni az oldal betöltésekor. Az automatikus kitöltés az oldal betöltésekor kikapcsolásra kerül.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Az automatikus kitöltés az oldal betöltésekor az alapértelmezett beállítás használatára lett beállítva.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Kapcsoljuk ki a mesterjelszó újbóli bekérését a mező szerkesztéséhez.", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 6addc264fb2..7b19a6838c0 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Isi otomatis" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Membuat Kata Sandi (tersalin)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Tidak ada info masuk yang cocok." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Buka brankas Anda" }, @@ -338,6 +362,9 @@ "other": { "message": "Lainnya" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Nilai Ekstensi" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Iya, Perbarui Sekarang" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Fitur Tidak Tersedia" }, - "updateKey": { - "message": "Anda tidak dapat menggunakan fitur ini sampai Anda memperbarui kunci enkripsi Anda." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Keanggotaan Premium" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Pulihkan Item" }, - "restoreItemConfirmation": { - "message": "Apakah Anda yakin ingin memulihkan item ini?" - }, "restoredItem": { "message": "Item Yang Dipulihkan" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Biometrik peramban tidak didukung di perangkat ini." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Izin tidak diberikan" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index 1971331fd98..9bca2da6062 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Riempimento automatico" }, + "autoFillLogin": { + "message": "Riempi automaticamente login" + }, + "autoFillCard": { + "message": "Riempi automaticamente carta" + }, + "autoFillIdentity": { + "message": "Riempi automaticamente identità" + }, "generatePasswordCopied": { "message": "Genera password e copiala" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Nessun login corrispondente" }, + "noCards": { + "message": "Nessuna carta" + }, + "noIdentities": { + "message": "Nessuna identità" + }, + "addLoginMenu": { + "message": "Aggiungi login" + }, + "addCardMenu": { + "message": "Aggiungi carta" + }, + "addIdentityMenu": { + "message": "Aggiungi identità" + }, "unlockVaultMenu": { "message": "Sblocca la tua cassaforte" }, @@ -338,6 +362,9 @@ "other": { "message": "Altro" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Imposta un metodo di sblocco per modificare l'azione timeout cassaforte." + }, "rateExtension": { "message": "Valuta l'estensione" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Aggiorna" }, + "notificationUnlockDesc": { + "message": "Sblocca la tua cassaforte di Bitwarden per completare la richiesta di riempimento automatico." + }, + "notificationUnlock": { + "message": "Sblocca" + }, "enableContextMenuItem": { "message": "Mostra opzioni nel menu contestuale" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Funzionalità non disponibile" }, - "updateKey": { - "message": "Non puoi usare questa funzionalità finché non aggiorni la tua chiave di criptografia." + "encryptionKeyMigrationRequired": { + "message": "Migrazione della chiave di criptografia obbligatoria. Accedi tramite la cassaforte web per aggiornare la tua chiave di criptografia." }, "premiumMembership": { "message": "Abbonamento Premium" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Ripristina elemento" }, - "restoreItemConfirmation": { - "message": "Sei sicuro di voler ripristinare questo elemento?" - }, "restoredItem": { "message": "Elemento ripristinato" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "L'autenticazione biometrica del browser non è supportata su questo dispositivo." }, + "biometricsFailedTitle": { + "message": "Autenticazione biometrica fallita" + }, + "biometricsFailedDesc": { + "message": "L'autenticazione biometrica non può essere completata, prova a usare una password principale o a uscire. Se il problema persiste, contatta l'assistenza di Bitwarden." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permesso non fornito" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Esportazione cassaforte personale" }, - "exportingPersonalVaultDescription": { - "message": "Solo gli elementi della cassaforte personale associati a $EMAIL$ saranno esportati. Gli elementi della cassaforte dell'organizzazione non saranno inclusi.", + "exportingIndividualVaultDescription": { + "message": "Solo gli elementi della cassaforte personale associati a $EMAIL$ saranno esportati. Gli elementi della cassaforte dell'organizzazione non saranno inclusi. Solo le informazioni sugli elementi della cassaforte saranno esportate e non includeranno gli allegati.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Versione Server" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Terze parti" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "Una notifica è stata inviata al tuo dispositivo." }, - "logInInitiated": { - "message": "Login avviato" + "loginInitiated": { + "message": "Accesso avviato" }, "exposedMasterPassword": { "message": "Password principale violata" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Regione" + "loggingInOn": { + "message": "Accedendo su" }, "opensInANewWindow": { "message": "Si apre in una nuova finestra" }, + "deviceApprovalRequired": { + "message": "Approvazione del dispositivo obbligatoria. Seleziona un'opzione di approvazione:" + }, + "rememberThisDevice": { + "message": "Ricorda questo dispositivo" + }, + "uncheckIfPublicDevice": { + "message": "Deseleziona se stai usando un dispositivo pubblico" + }, + "approveFromYourOtherDevice": { + "message": "Approva dall'altro tuo dispositivo" + }, + "requestAdminApproval": { + "message": "Richiedi approvazione dell'amministratore" + }, + "approveWithMasterPassword": { + "message": "Approva con password principale" + }, + "ssoIdentifierRequired": { + "message": "Identificatore SSO dell'organizzazione obbligatorio." + }, "eu": { - "message": "UE", + "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Accesso negato. Non hai i permessi necessari per visualizzare questa pagina." + }, + "general": { + "message": "Generale" + }, + "display": { + "message": "Schermo" + }, + "accountSuccessfullyCreated": { + "message": "Account creato!" + }, + "adminApprovalRequested": { + "message": "Approvazione dell'amministratore richiesta" + }, + "adminApprovalRequestSentToAdmins": { + "message": "La tua richiesta è stata inviata al tuo amministratore." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Riceverai una notifica una volta approvato." + }, + "troubleLoggingIn": { + "message": "Problemi ad accedere?" + }, + "loginApproved": { + "message": "Accesso approvato" + }, + "userEmailMissing": { + "message": "Email utente mancante" + }, + "deviceTrusted": { + "message": "Dispositivo fidato" + }, + "inputRequired": { + "message": "Input obbligatorio." + }, + "required": { + "message": "obbligatorio" + }, + "search": { + "message": "Cerca" + }, + "inputMinLength": { + "message": "L'input deve essere lungo almeno $COUNT$ caratteri.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "L'input non deve essere più lungo di $COUNT$ caratteri.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Questi caratteri non sono permessi: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Il valore immesso deve essere almeno $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Il valore immesso non deve superare $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "Una o più email non sono valide" + }, + "inputTrimValidator": { + "message": "L'input non può contenere solo spazi.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "L'input non è un indirizzo email." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ campi qui sopra richiedono la tua attenzione.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Seleziona --" + }, + "multiSelectPlaceholder": { + "message": "-- Digita per filtrare --" + }, + "multiSelectLoading": { + "message": "Ottenendo opzioni..." + }, + "multiSelectNotFound": { + "message": "Nessun elemento trovato" + }, + "multiSelectClearAll": { + "message": "Cancella tutto" + }, + "plusNMore": { + "message": "+$QUANTITY$ in più", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Sottomenu" + }, + "toggleCollapse": { + "message": "Comprimi/espandi", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Dominio alias" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Gli elementi che richiedono di inserire la password principale di nuovo non possono essere riempiti automaticamente al caricamento della pagina.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Il riempimento automatico al caricamento della pagina impostata per usare l'impostazione predefinita.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Disattiva l'inserimento della password principale di nuovo per modificare questo campo", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 630dfb3e217..d2528a45489 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "自動入力" }, + "autoFillLogin": { + "message": "自動入力ログイン" + }, + "autoFillCard": { + "message": "自動入力カード" + }, + "autoFillIdentity": { + "message": "自動入力 ID" + }, "generatePasswordCopied": { "message": "パスワードを生成 (コピー)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "一致するログインがありません。" }, + "noCards": { + "message": "カードなし" + }, + "noIdentities": { + "message": "ID なし" + }, + "addLoginMenu": { + "message": "ログイン情報を追加" + }, + "addCardMenu": { + "message": "カードを追加" + }, + "addIdentityMenu": { + "message": "ID を追加" + }, "unlockVaultMenu": { "message": "保管庫のロックを解除" }, @@ -338,6 +362,9 @@ "other": { "message": "その他" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "保管庫のタイムアウト動作を変更するには、ロック解除方法を設定してください。" + }, "rateExtension": { "message": "拡張機能の評価" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "今すぐ更新する" }, + "notificationUnlockDesc": { + "message": "Bitwarden 保管庫をロック解除して自動入力リクエストを完了してください。" + }, + "notificationUnlock": { + "message": "ロック解除" + }, "enableContextMenuItem": { "message": "コンテキストメニューオプションを表示" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "サービスが利用できません" }, - "updateKey": { - "message": "暗号キーを更新するまでこの機能は使用できません。" + "encryptionKeyMigrationRequired": { + "message": "暗号化キーの移行が必要です。暗号化キーを更新するには、ウェブ保管庫からログインしてください。" }, "premiumMembership": { "message": "プレミアム会員" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "1GB の暗号化されたファイルストレージ" }, - "ppremiumSignUpTwoStep": { - "message": "YubiKey、FIDO U2F、Duoなどの追加の2段階認証ログインオプション" + "premiumSignUpTwoStepOptions": { + "message": "YubiKey、Duo などのプロプライエタリな2段階認証オプション。" }, "ppremiumSignUpReports": { "message": "保管庫を安全に保つための、パスワードやアカウントの健全性、データ侵害に関するレポート" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "アイテムをリストア" }, - "restoreItemConfirmation": { - "message": "このアイテムをリストアしますか?" - }, "restoredItem": { "message": "リストアされたアイテム" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "このデバイスではブラウザの生体認証に対応していません。" }, + "biometricsFailedTitle": { + "message": "生体認証に失敗しました" + }, + "biometricsFailedDesc": { + "message": "生体認証を完了できません。マスターパスワードを使うかログアウトしてください。それでも直らない場合は、Bitwarden サポートまでお問い合わせください。" + }, "nativeMessaginPermissionErrorTitle": { "message": "権限が提供されていません" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "個人保管庫のエクスポート" }, - "exportingPersonalVaultDescription": { - "message": "$EMAIL$ に関連付けられた個人用保管庫アイテムのみがエクスポートされます。組織用保管庫アイテムは含まれません。", + "exportingIndividualVaultDescription": { + "message": "$EMAIL$ に関連付けられた個人の保管庫アイテムのみがエクスポートされます。組織の保管庫アイテムは含まれません。 保管庫アイテム情報のみがエクスポートされ、関連する添付ファイルはエクスポートされません。", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "サーバーのバージョン" }, - "selfHosted": { - "message": "セルフホスト" + "selfHostedServer": { + "message": "自己ホスト型" }, "thirdParty": { "message": "サードパーティー" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "デバイスに通知を送信しました。" }, - "logInInitiated": { + "loginInitiated": { "message": "ログイン開始" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "リージョン" + "loggingInOn": { + "message": "ログイン先" }, "opensInANewWindow": { "message": "新しいウィンドウで開く" }, + "deviceApprovalRequired": { + "message": "デバイスの承認が必要です。以下から承認オプションを選択してください:" + }, + "rememberThisDevice": { + "message": "このデバイスを記憶する" + }, + "uncheckIfPublicDevice": { + "message": "パブリックデバイスを使用している場合はチェックしないでください" + }, + "approveFromYourOtherDevice": { + "message": "他のデバイスから承認する" + }, + "requestAdminApproval": { + "message": "管理者の承認を要求する" + }, + "approveWithMasterPassword": { + "message": "マスターパスワードで承認する" + }, + "ssoIdentifierRequired": { + "message": "組織の SSO ID が必要です。" + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "米国", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "アクセスが拒否されました。このページを表示する権限がありません。" + }, + "general": { + "message": "全般" + }, + "display": { + "message": "表示" + }, + "accountSuccessfullyCreated": { + "message": "アカウントを正常に作成しました!" + }, + "adminApprovalRequested": { + "message": "管理者の承認を要求しました" + }, + "adminApprovalRequestSentToAdmins": { + "message": "要求を管理者に送信しました。" + }, + "youWillBeNotifiedOnceApproved": { + "message": "承認されると通知されます。 " + }, + "troubleLoggingIn": { + "message": "ログインできない場合" + }, + "loginApproved": { + "message": "ログインが承認されました" + }, + "userEmailMissing": { + "message": "ユーザーのメールアドレスがありません" + }, + "deviceTrusted": { + "message": "信頼されたデバイス" + }, + "inputRequired": { + "message": "入力が必要です。" + }, + "required": { + "message": "必須" + }, + "search": { + "message": "検索" + }, + "inputMinLength": { + "message": "$COUNT$ 文字以上でなければなりません。", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "$COUNT$ 文字を超えてはいけません。", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "次の文字は許可されていません: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "入力値は少なくとも$MIN$桁でなければなりません。", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "入力値は$MAX$桁を超えてはいけません。", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1つ以上のメールアドレスが無効です" + }, + "inputTrimValidator": { + "message": "値を空白のみにしないでください。", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "入力したものはメールアドレスではありません。" + }, + "fieldsNeedAttention": { + "message": "上記の $COUNT$ 点を確認してください。", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- 選択 --" + }, + "multiSelectPlaceholder": { + "message": "-- 入力して絞り込み --" + }, + "multiSelectLoading": { + "message": "オプションを取得中..." + }, + "multiSelectNotFound": { + "message": "アイテムが見つかりません" + }, + "multiSelectClearAll": { + "message": "すべてクリア" + }, + "plusNMore": { + "message": "+ $QUANTITY$ 個以上", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "サブメニュー" + }, + "toggleCollapse": { + "message": "開く/閉じる", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "エイリアスドメイン" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "マスターパスワードの再入力を促すアイテムは、ページ読み込み時に自動入力できません。ページ読み込み時の自動入力をオフにしました。", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "ページ読み込み時の自動入力はデフォルトの設定を使うよう設定しました。", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "このフィールドを編集するには、マスターパスワードの再入力をオフにしてください", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 2f484324f39..2d2b5cb8a5e 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "თვითშევსება" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "სხვა" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API server URL" }, "webVaultUrl": { "message": "Web vault server URL" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Item restored" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 69d26333a84..22330901579 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API server URL" }, "webVaultUrl": { "message": "Web vault server URL" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Item restored" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index baa61fad177..95c9350aff8 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "ಸ್ವಯಂ ಭರ್ತಿ" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "ಪಾಸ್ವರ್ಡ್ ರಚಿಸಿ (ನಕಲಿಸಲಾಗಿದೆ)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "ಹೊಂದಾಣಿಕೆಯ ಲಾಗಿನ್‌ಗಳು ಇಲ್ಲ." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "ಇತರೆ" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "ವಿಸ್ತರಣೆಯನ್ನು ರೇಟ್ ಮಾಡಿ" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "ಹೌದು, ಈಗ ನವೀಕರಿಸಿ" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "ವೈಶಿಷ್ಟ್ಯ ಲಭ್ಯವಿಲ್ಲ" }, - "updateKey": { - "message": "ನಿಮ್ಮ ಎನ್‌ಕ್ರಿಪ್ಶನ್ ಕೀಲಿಯನ್ನು ನವೀಕರಿಸುವವರೆಗೆ ನೀವು ಈ ವೈಶಿಷ್ಟ್ಯವನ್ನು ಬಳಸಲಾಗುವುದಿಲ್ಲ." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "ಪ್ರೀಮಿಯಂ ಸದಸ್ಯತ್ವ" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "ಫೈಲ್ ಲಗತ್ತುಗಳಿಗಾಗಿ 1 ಜಿಬಿ ಎನ್‌ಕ್ರಿಪ್ಟ್ ಮಾಡಿದ ಸಂಗ್ರಹ." }, - "ppremiumSignUpTwoStep": { - "message": "ಹೆಚ್ಚುವರಿ ಎರಡು-ಹಂತದ ಲಾಗಿನ್ ಆಯ್ಕೆಗಳಾದ ಯೂಬಿಕೆ, ಎಫ್‌ಐಡಿಒ ಯು 2 ಎಫ್, ಮತ್ತು ಡ್ಯುವೋ." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "ನಿಮ್ಮ ವಾಲ್ಟ್ ಅನ್ನು ಸುರಕ್ಷಿತವಾಗಿರಿಸಲು ಪಾಸ್ವರ್ಡ್ ನೈರ್ಮಲ್ಯ, ಖಾತೆ ಆರೋಗ್ಯ ಮತ್ತು ಡೇಟಾ ಉಲ್ಲಂಘನೆ ವರದಿಗಳು." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "ಐಟಂ ಅನ್ನು ಮರುಸ್ಥಾಪಿಸಿ" }, - "restoreItemConfirmation": { - "message": "ಈ ಐಟಂ ಅನ್ನು ಮರುಸ್ಥಾಪಿಸಲು ನೀವು ಖಚಿತವಾಗಿ ಬಯಸುವಿರಾ?" - }, "restoredItem": { "message": "ಐಟಂ ಅನ್ನು ಮರುಸ್ಥಾಪಿಸಲಾಗಿದೆ" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "ಬ್ರೌಸರ್ ಬಯೋಮೆಟ್ರಿಕ್ಸ್ ಈ ಸಾಧನದಲ್ಲಿ ಬೆಂಬಲಿಸುವುದಿಲ್ಲ." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "ಅನುಮತಿ ಒದಗಿಸಲಾಗಿಲ್ಲ" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 962fe7347cf..e7aba95f493 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "자동 완성" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "비밀번호 생성 및 클립보드에 복사" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "사용할 수 있는 로그인이 없습니다." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "보관함 잠금 해제" }, @@ -338,6 +362,9 @@ "other": { "message": "기타" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "확장 프로그램 평가" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "예, 지금 변경하겠습니다." }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "기능 사용할 수 없음" }, - "updateKey": { - "message": "이 기능을 사용하려면 암호화 키를 업데이트해야 합니다." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "프리미엄 멤버십" @@ -786,8 +819,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": "보관함을 안전하게 유지하기 위한 암호 위생, 계정 상태, 데이터 유출 보고서" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "항목 복원" }, - "restoreItemConfirmation": { - "message": "정말 이 항목을 복원하시겠습니까?" - }, "restoredItem": { "message": "복원된 항목" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "이 기기에서는 생체 인식이 지원되지 않습니다." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "권한이 부여되지 않음" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "개인 보관함을 내보내는 중" }, - "exportingPersonalVaultDescription": { - "message": "오직 $EMAIL$와 연관된 개인 보관함의 항목만 내보내집니다. 조직 보관함의 항목은 포함되지 않습니다.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index e6a9dbdc5c6..885b3cbec8f 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -56,7 +56,7 @@ "message": "Saugykla" }, "myVault": { - "message": "Saugykla" + "message": "Mano saugykla" }, "allVaults": { "message": "Visos saugyklos" @@ -91,6 +91,15 @@ "autoFill": { "message": "Automatinis užpildymas" }, + "autoFillLogin": { + "message": "Automatinio užpildymo prisijungimas" + }, + "autoFillCard": { + "message": "Automatinio užpildymo kortelė" + }, + "autoFillIdentity": { + "message": "Automatinio užpildymo tapatybė" + }, "generatePasswordCopied": { "message": "Kurti slaptažodį (paruoštas įterpti)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Nėra atitinkančių prisijungimų." }, + "noCards": { + "message": "Nėra kortelių" + }, + "noIdentities": { + "message": "Nėra tapatybių" + }, + "addLoginMenu": { + "message": "Pridėti prisijungimą" + }, + "addCardMenu": { + "message": "Pridėti kortelę" + }, + "addIdentityMenu": { + "message": "Pridėti tapatybę" + }, "unlockVaultMenu": { "message": "Atrakinti saugyklą" }, @@ -338,6 +362,9 @@ "other": { "message": "Kita" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Nustatyk atrakinimo būdą, kad pakeistum saugyklos laiko limito veiksmą." + }, "rateExtension": { "message": "Įvertinkite šį plėtinį" }, @@ -596,13 +623,13 @@ "message": "Rodyti korteles skirtuko puslapyje" }, "showCardsCurrentTabDesc": { - "message": "List card items on the Tab page for easy auto-fill." + "message": "Pateikti kortelių elementų skirtuko puslapyje sąrašą, kad būtų lengva automatiškai užpildyti." }, "showIdentitiesCurrentTab": { - "message": "Show identities on Tab page" + "message": "Rodyti tapatybes skirtuko puslapyje" }, "showIdentitiesCurrentTabDesc": { - "message": "List identity items on the Tab page for easy auto-fill." + "message": "Pateikti tapatybės elementų skirtuko puslapyje, kad būtų lengva automatiškai užpildyti." }, "clearClipboard": { "message": "Išvalyti iškarpinę", @@ -613,10 +640,10 @@ "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "notificationAddDesc": { - "message": "Ar „Bitwarden“ turėtų prisiminti šį slaptažodį?" + "message": "Ar Bitwarden turėtų įsiminti šį slaptažodį už tave?" }, "notificationAddSave": { - "message": "Taip, išsaugoti dabar" + "message": "Išsaugoti" }, "enableChangedPasswordNotification": { "message": "Paprašyti atnaujinti esamą prisijungimą" @@ -625,10 +652,16 @@ "message": "Paprašyti atnaujinti prisijungimo slaptažodį, kai pakeitimas aptiktas svetainėje." }, "notificationChangeDesc": { - "message": "Ar norite atnaujinti šį slaptažodį „Bitwarden“?" + "message": "Ar nori atnaujinti šį slaptažodį Bitwarden?" }, "notificationChangeSave": { - "message": "Taip, atnaujinti dabar" + "message": "Atnaujinti" + }, + "notificationUnlockDesc": { + "message": "Atrakink savo Bitwarden saugyklą, kad užpildytum automatinio užpildymo užklausą." + }, + "notificationUnlock": { + "message": "Atrakinti" }, "enableContextMenuItem": { "message": "Rodyti kontekstinio meniu pasririnkimus" @@ -658,7 +691,7 @@ "description": "Light color" }, "solarizedDark": { - "message": "Solarized dark", + "message": "Saulėtas tamsą", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, "exportVault": { @@ -748,7 +781,7 @@ "message": "Priedų nėra." }, "attachmentSaved": { - "message": "Priedas buvo išsaugotas." + "message": "Priedas išsaugotas" }, "file": { "message": "Failas" @@ -757,13 +790,13 @@ "message": "Pasirinkite failą." }, "maxFileSize": { - "message": "Failai negali būti didesni už 500 MB." + "message": "Didžiausias failo dydis – 500 MB." }, "featureUnavailable": { "message": "Funkcija neprieinama" }, - "updateKey": { - "message": "Negalite naudoti šios funkcijos, kol neatnaujinsite šifravimo raktą." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium narystė" @@ -781,19 +814,19 @@ "message": "Neturite Premium narystės." }, "premiumSignUpAndGet": { - "message": "Prisijungite prie Premium narystės ir gaukite:" + "message": "Prisijunk prie Premium narystės ir gauk:" }, "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 jūsų seifas būtų saugus." + "message": "Slaptažodžio higiena, prieigos sveikata ir duomenų nutekinimo ataskaitos, kad tavo saugyklas būtų saugus." }, "ppremiumSignUpTotp": { - "message": "TOTP patvirtinimo kodų (2FA) generatorius prisijungimams prie jūsų saugyklos." + "message": "TOTP patvirtinimo kodų (2FA) generatorius prisijungimams prie tavo saugyklos." }, "ppremiumSignUpSupport": { "message": "Prioritetinis klientų aptarnavimas." @@ -802,13 +835,13 @@ "message": "Visos būsimos Premium savybės. Daugiau jau greitai!" }, "premiumPurchase": { - "message": "Įsigyti Premium planą" + "message": "Įsigyti Premium" }, "premiumPurchaseAlert": { - "message": "Jūs galite įsigyti Premium narystę bitwarden.com puslapyje. Ar norite aplankyti šį puslapį dabar?" + "message": "Gali įsigyti Premium narystę bitwarden.com interneto saugykloje. Ar nori aplankyti svetainėje dabar?" }, "premiumCurrentMember": { - "message": "Jūs esate Premium narys!" + "message": "Tu esi Premium narys!" }, "premiumCurrentMemberThanks": { "message": "Dėkojame, kad remiate Bitwarden." @@ -829,22 +862,22 @@ "message": "Kopijuoti vienkartinį kodą (TOTP) automatiškai" }, "disableAutoTotpCopyDesc": { - "message": "If a login has an authenticator key, copy the TOTP verification code to your clip-board when you auto-fill the login." + "message": "Jei prisijungimas turi autentifikatoriaus raktą, nukopijuokite TOTP tikrinimo kodą į iškarpinę, kai automatiškai užpildysite prisijungimą." }, "enableAutoBiometricsPrompt": { "message": "Paleidžiant patvirtinti biometrinius duomenis" }, "premiumRequired": { - "message": "Tik su Premium naryste" + "message": "Premium reikalinga" }, "premiumRequiredDesc": { "message": "Premium narystė reikalinga šiai funkcijai naudoti." }, "enterVerificationCodeApp": { - "message": "Įveskite 6 skaitmenų patvirtinimo kodą iš jūsų autentifikavimo aplikacijos." + "message": "Įvesk 6 skaitmenų patvirtinimo kodą iš tavo autentifikavimo aplikacijos." }, "enterVerificationCodeEmail": { - "message": "Įveskite 6 skaitmenų prisijungimo kodą, kuris buvo išsiųstas $EMAIL$ el. paštu.", + "message": "Įvesk 6 skaitmenų prisijungimo kodą, kuris buvo išsiųstas $EMAIL$ el. paštu.", "placeholders": { "email": { "content": "$1", @@ -871,34 +904,34 @@ "message": "Naudoti dar vieną dviejų žingsnių prisijungimo metodą" }, "insertYubiKey": { - "message": "Insert your YubiKey into your computer's USB port, then touch its button." + "message": "Įkišk YubiKey į savo kompiuterio USB prievadą, tada paliesk jo mygtuką." }, "insertU2f": { - "message": "Insert your security key into your computer's USB port. If it has a button, touch it." + "message": "Įkišk savo saugos raktą į kompiuterio USB prievadą. Jei jame yra mygtukas, paliesk jį." }, "webAuthnNewTab": { - "message": "To start the WebAuthn 2FA verification. Click the button below to open a new tab and follow the instructions provided in the new tab." + "message": "Norint pradėti WebAuthn 2FA patikrinimą. Spustelėk toliau esantį mygtuką, kad atsidarytų naujas skirtukas, ir sek naujame skirtuke pateiktas instrukcijas." }, "webAuthnNewTabOpen": { "message": "Atidaryti naują skirtuką" }, "webAuthnAuthenticate": { - "message": "Authenticate WebAuthn" + "message": "Autentifikuoti WebAuthn" }, "loginUnavailable": { "message": "Prisijungimas nepasiekiamas" }, "noTwoStepProviders": { - "message": "This account has two-step login set up, however, none of the configured two-step providers are supported by this web browser." + "message": "Šioje paskyroje nustatytas dviejų žingsnių prisijungimas, tačiau, nė vienas iš sukonfigūruotų dviejų žingsnių paslaugų teikėjų nėra palaikomas šioje interneto naršyklėje." }, "noTwoStepProviders2": { - "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)." + "message": "Prašome naudoti palaikomą interneto naršyklę (pvz., Chrome) ir/arba pridėti papildomus paslaugų teikėjus, kurie geriau palaikomi įvairiose interneto naršyklėse (pvz., autentifikavimo programėlę)." }, "twoStepOptions": { "message": "Dviejų žingsnių prisijungimo parinktys" }, "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." + "message": "Praradai prieigą prie visų savo dviejų veiksnių teikėjų? Naudok atkūrimo kodą, kad iš savo paskyros išjungtum visus dviejų veiksnių teikėjus." }, "recoveryCodeTitle": { "message": "Atkūrimo kodas" @@ -907,46 +940,46 @@ "message": "Autentifikavimo programa" }, "authenticatorAppDesc": { - "message": "Use an authenticator app (such as Authy or Google Authenticator) to generate time-based verification codes.", + "message": "Naudok autentifikatoriaus programėlę (pvz., Authy arba Google Autentifikatorius), kad sugeneruotum laiko patikrinimo kodus.", "description": "'Authy' and 'Google Authenticator' are product names and should not be translated." }, "yubiKeyTitle": { - "message": "YubiKey OTP Security Key" + "message": "YubiKey OTP saugumo raktas" }, "yubiKeyDesc": { - "message": "Use a YubiKey to access your account. Works with YubiKey 4, 4 Nano, 4C, and NEO devices." + "message": "Naudok YubiKey, kad prisijungtum prie savo paskyros. Veikia su YubiKey 4, 4 Nano, 4C ir NEO įrenginiais." }, "duoDesc": { - "message": "Verify with Duo Security using the Duo Mobile app, SMS, phone call, or U2F security key.", + "message": "Patvirtink su Duo Security naudodami Duo Mobile programą, SMS žinutę, telefono skambutį arba U2F saugumo raktą.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "duoOrganizationDesc": { - "message": "Verify with Duo Security for your organization using the Duo Mobile app, SMS, phone call, or U2F security key.", + "message": "Patikrink su Duo Security savo organizacijai naudodamasis Duo Mobile programą, SMS žinutę, telefono skambutį arba U2F saugumo raktą.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "webAuthnTitle": { "message": "FIDO2 WebAuthn" }, "webAuthnDesc": { - "message": "Use any WebAuthn compatible security key to access your account." + "message": "Naudok bet kurį WebAuthn palaikantį saugumo raktą, kad galėtum naudotis savo paskyra." }, "emailTitle": { "message": "El. paštas" }, "emailDesc": { - "message": "Verification codes will be emailed to you." + "message": "Patvirtinimo kodai bus atsiųsti el. paštu tau." }, "selfHostedEnvironment": { - "message": "Self-hosted environment" + "message": "Savarankiškai sukurta aplinka" }, "selfHostedEnvironmentFooter": { - "message": "Specify the base URL of your on-premises hosted Bitwarden installation." + "message": "Nurodyk pagrindinį URL adresą savo patalpose esančio Bitwarden diegimo." }, "customEnvironment": { "message": "Individualizuota aplinka" }, "customEnvironmentFooter": { - "message": "For advanced users. You can specify the base URL of each service independently." + "message": "Pažengusiems naudotojams. Galite nurodyti kiekvienos paslaugos pagrindinį URL adresą atskirai." }, "baseUrl": { "message": "Serverio URL" @@ -967,13 +1000,13 @@ "message": "Piktogramų serverio URL" }, "environmentSaved": { - "message": "Environment URLs saved" + "message": "Aplinkos URL adresai išsaugoti" }, "enableAutoFillOnPageLoad": { "message": "Automatiškai užpildyti užsikrovus puslapiui" }, "enableAutoFillOnPageLoadDesc": { - "message": "If a login form is detected, auto-fill when the web page loads." + "message": "Jei aptikta prisijungimo forma, automatiškai užpildyti, kai kraunamas tinklalapis." }, "experimentalFeature": { "message": "Compromised or untrusted websites can exploit auto-fill on page load." @@ -1030,7 +1063,7 @@ "message": "Naujas pasirinktis laukelis" }, "dragToSort": { - "message": "Drag to sort" + "message": "Rūšiuok, kad surūšiuotum" }, "cfTypeText": { "message": "Tekstas" @@ -1050,10 +1083,10 @@ "description": "This describes a value that is 'linked' (tied) to another value." }, "popup2faCloseMessage": { - "message": "Clicking outside the popup window to check your email for your verification code will cause this popup to close. Do you want to open this popup in a new window so that it does not close?" + "message": "Paspaudę už iššokančio lango, kad patikrintum, ar el. paštu gausi patvirtinimo kodą, uždarys šį iššokantį langą. Ar nori atidaryti šį iššokantį langą naujame lange, kad jis neužsidarytų?" }, "popupU2fCloseMessage": { - "message": "This browser cannot process U2F requests in this popup window. Do you want to open this popup in a new window so that you can log in using U2F?" + "message": "Ši naršyklė negali apdoroti U2F prašymų šiame iššokančiame lange. Ar nori atidaryti šį iššokantį langą naujame lange, kad galėtum prisijungti naudodamas (-a) U2F?" }, "enableFavicon": { "message": "Rodyti tinklalapių ikonėles" @@ -1062,10 +1095,10 @@ "message": "Show a recognizable image next to each login." }, "enableBadgeCounter": { - "message": "Show badge counter" + "message": "Rodyti ženkliukų skaitiklį" }, "badgeCounterDesc": { - "message": "Indicate how many logins you have for the current web page." + "message": "Nurodoma, kiek prisijungimų turi dabartiniame žiniatinklio puslapyje." }, "cardholderName": { "message": "Mokėjimo kortelės savininko vardas" @@ -1143,7 +1176,7 @@ "message": "Dr" }, "mx": { - "message": "Mx" + "message": "Neutralinis (-i)" }, "firstName": { "message": "Vardas" @@ -1267,7 +1300,7 @@ } }, "passwordSafe": { - "message": "This password was not found in any known data breaches. It should be safe to use." + "message": "Šis slaptažodis nebuvo rastas per jokius žinomus duomenų pažeidimus. Jis turėtų būti saugus naudoti." }, "baseDomain": { "message": "Bazinis domenas", @@ -1288,7 +1321,7 @@ "message": "Prasideda" }, "regEx": { - "message": "Regular expression", + "message": "Reguliari išraiška", "description": "A programming term, also known as 'RegEx'." }, "matchDetection": { @@ -1296,7 +1329,7 @@ "description": "URI match detection for auto-fill." }, "defaultMatchDetection": { - "message": "Default match detection", + "message": "Numatytasis atitikties aptikimas", "description": "Default URI match detection for auto-fill." }, "toggleOptions": { @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Atkurti elementą" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Elementas atkurtas" }, @@ -1462,7 +1492,7 @@ "message": "Item auto-filled " }, "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": "Įspėjimas: Tai – neapsaugotas HTTP puslapis, todėl bet kokią pateiktą informaciją gali matyti ir keisti kiti asmenys. Šis prisijungimas iš pradžių buvo išsaugotas saugiame (HTTPS) puslapyje." }, "insecurePageWarningFillPrompt": { "message": "Do you still wish to fill this login?" @@ -1483,19 +1513,19 @@ "message": "Pagrindinio slaptažodžio nustatymas" }, "currentMasterPass": { - "message": "Current master password" + "message": "Dabartinis pagrindinis slaptažodis" }, "newMasterPass": { - "message": "New master password" + "message": "Naujas pagrindinis slaptažodis" }, "confirmNewMasterPass": { - "message": "Confirm new master password" + "message": "Patvirtinti naują pagrindinį slaptažodį" }, "masterPasswordPolicyInEffect": { - "message": "One or more organization policies require your master password to meet the following requirements:" + "message": "Viena ar daugiau organizacijos politikos reikalauja, kad tavo pagrindinis slaptažodis atitiktų šiuos reikalavimus:" }, "policyInEffectMinComplexity": { - "message": "Minimum complexity score of $SCORE$", + "message": "Minimalus sudėtingumo balas $SCORE$", "placeholders": { "score": { "content": "$1", @@ -1531,13 +1561,13 @@ } }, "masterPasswordPolicyRequirementsNotMet": { - "message": "Your new master password does not meet the policy requirements." + "message": "Tavo naujasis pagrindinis slaptažodis neatitinka politikos reikalavimų." }, "acceptPolicies": { "message": "By checking this box you agree to the following:" }, "acceptPoliciesRequired": { - "message": "Terms of Service and Privacy Policy have not been acknowledged." + "message": "Paslaugų teikimo sąlygos ir privatumo politika nebuvo pripažinti." }, "termsOfService": { "message": "Paslaugų teikimo paslaugos" @@ -1552,13 +1582,13 @@ "message": "Gerai" }, "desktopSyncVerificationTitle": { - "message": "Desktop sync verification" + "message": "Darbalaukio sinchronizavimo verifikavimas" }, "desktopIntegrationVerificationText": { - "message": "Please verify that the desktop application shows this fingerprint: " + "message": "Patikrink, ar darbalaukio programoje rodomas šis pirštų atspaudas: " }, "desktopIntegrationDisabledTitle": { - "message": "Browser integration is not set up" + "message": "Naršyklės integracija nėra nustatyta" }, "desktopIntegrationDisabledDesc": { "message": "Browser integration is not set up in the Bitwarden desktop application. Please set it up in the settings within the desktop application." @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Šiame įrenginyje biometrikos negalima naudoti." }, + "biometricsFailedTitle": { + "message": "Biometrika nepavyko" + }, + "biometricsFailedDesc": { + "message": "Biometriniai duomenys negali būti užpildyti, apsvarstyk galimybę naudoti pagrindinį slaptažodį arba atsijungti. Jei tai tęsiasi, kreipkitės į Bitwarden pagalbą." + }, "nativeMessaginPermissionErrorTitle": { "message": "Nesuteiktos teisės" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Trečioji šalis" @@ -2105,10 +2141,10 @@ } }, "loginWithMasterPassword": { - "message": "Log in with master password" + "message": "Prisijungti su pagrindiniu slaptažodžiu" }, "loggingInAs": { - "message": "Logging in as" + "message": "Prisijungimas kaip" }, "notYou": { "message": "Ne jūs?" @@ -2117,13 +2153,13 @@ "message": "Ar jūs naujas čia?" }, "rememberEmail": { - "message": "Remember email" + "message": "Prisiminti el. paštą" }, "loginWithDevice": { "message": "Prisijunkite naudodami įrenginį" }, "loginWithDeviceEnabledInfo": { - "message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?" + "message": "Prisijungti su įrenginiu turi būti nustatyta Bitwarden aplikacijos nustatymuose. Reikia kito pasirinkimo?" }, "fingerprintPhraseHeader": { "message": "Fingerprint phrase" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { + "loginInitiated": { "message": "Pradėtas prisijungimas" }, "exposedMasterPassword": { @@ -2177,10 +2213,10 @@ "message": "Your organization policies have turned on auto-fill on page load." }, "howToAutofill": { - "message": "How to auto-fill" + "message": "Kaip automatiškai užpildyti" }, "autofillSelectInfoWithCommand": { - "message": "Select an item from this page or use the shortcut: $COMMAND$", + "message": "Pasirink elementą iš šio puslapio arba naudok trumpąjį klavišą: $COMMAND$", "placeholders": { "command": { "content": "$1", @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Įrenginio patvirtinimas reikalingas. Pasirink patvirtinimo būdą toliau:" + }, + "rememberThisDevice": { + "message": "Prisiminti šį įrenginį" + }, + "uncheckIfPublicDevice": { + "message": "Panaikink žymėjimą, jei naudojamas viešasis įrenginys" + }, + "approveFromYourOtherDevice": { + "message": "Patvirtinti iš tavo kito įrenginio" + }, + "requestAdminApproval": { + "message": "Prašyti administratoriaus patvirtinimo" + }, + "approveWithMasterPassword": { + "message": "Patvirtinti su pagrindiniu slaptažodžiu" + }, + "ssoIdentifierRequired": { + "message": "Organizacijos SSO identifikatorius yra reikalingas." + }, "eu": { - "message": "EU", + "message": "ES", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Prieiga uždrausta. Neturi teisės peržiūrėti šį puslapį." + }, + "general": { + "message": "Bendra" + }, + "display": { + "message": "Rodyti" + }, + "accountSuccessfullyCreated": { + "message": "Paskyra sėkmingai sukurta!" + }, + "adminApprovalRequested": { + "message": "Prašymas administratoriaus patvirtinimas" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Tavo prašymas išsiųstas administratoriui." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Tu būsi praneštas (-a), kai bus patvirtinta." + }, + "troubleLoggingIn": { + "message": "Problemos prisijungiant?" + }, + "loginApproved": { + "message": "Patvirtintas prisijungimas" + }, + "userEmailMissing": { + "message": "Trūksta naudotojo el. pašto" + }, + "deviceTrusted": { + "message": "Patikimas įrenginys" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 38c892aaede..2abb0ac4de6 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -91,8 +91,17 @@ "autoFill": { "message": "Automātiskā aizpildīšana" }, + "autoFillLogin": { + "message": "Automātiski aizpildīt pieteikšanos" + }, + "autoFillCard": { + "message": "Automātiski aizpildīt karti" + }, + "autoFillIdentity": { + "message": "Automātiski aizpildīt identitāti" + }, "generatePasswordCopied": { - "message": "Veidot paroli (ievietota starpliktuvē)" + "message": "Izveidot paroli (tiks ievietota starpliktuvē)" }, "copyElementIdentifier": { "message": "Pavairot pielāgotā lauka nosaukumu" @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Nav atbilstošu pieteikšanās vienumu" }, + "noCards": { + "message": "Nav karšu" + }, + "noIdentities": { + "message": "Nav identitāšu" + }, + "addLoginMenu": { + "message": "Pievienot pieteikšanās vienumu" + }, + "addCardMenu": { + "message": "Pievienot karti" + }, + "addIdentityMenu": { + "message": "Pievienot identitāti" + }, "unlockVaultMenu": { "message": "Atslēgt glabātavu" }, @@ -140,7 +164,7 @@ "message": "Apstiprināšanas kods" }, "confirmIdentity": { - "message": "Apstiprināt identitāti, lai turpinātu." + "message": "Jāapstiprina identitāte, lai turpinātu." }, "account": { "message": "Konts" @@ -338,6 +362,9 @@ "other": { "message": "Cits" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Jāuzstāda atslēgšanas veids, lai mainītu glabātavas noildzes darbību." + }, "rateExtension": { "message": "Novērtēt paplašinājumu" }, @@ -351,7 +378,7 @@ "message": "Apstiprināt identitāti" }, "yourVaultIsLocked": { - "message": "Glabātava ir slēgta. Nepieciešams norādīt galveno paroli, lai turpinātu." + "message": "Glabātava ir aizslēgta. Jāapstiprina identitāte, lai turpinātu." }, "unlock": { "message": "Atslēgt" @@ -464,7 +491,7 @@ "message": "Nederīgs apstiprinājuma kods" }, "valueCopied": { - "message": "$VALUE$ ievietota starpliktuvē", + "message": "$VALUE$ ir starpliktuvē", "description": "Value has been copied to the clipboard.", "placeholders": { "value": { @@ -556,7 +583,7 @@ "message": "Vienums labots" }, "deleteItemConfirmation": { - "message": "Vai tiešām pārvietot vienumu uz atkritni?" + "message": "Vai tiešām pārvietot uz atkritni?" }, "deletedItem": { "message": "Vienums pārvietots uz atkritni" @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Jā, atjaunināt" }, + "notificationUnlockDesc": { + "message": "Jāatslēdz Bitwarden glabātava, lai pabeigtu automātiskās aizpildīšanas pieprasījumu." + }, + "notificationUnlock": { + "message": "Atslēgt" + }, "enableContextMenuItem": { "message": "Rādīt konteksta izvēlnes iespējas" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Iespēja nav pieejama" }, - "updateKey": { - "message": "Jūs nevarat izmantot šo funkciju līdz jūs atjaunojat savu šifrēšanas atslēgu." + "encryptionKeyMigrationRequired": { + "message": "Nepieciešama šifrēšanas atslēgas nomaiņa. Lūgums pieteikties tīmekļa glabātavā, lai atjauninātu savu šifrēšanas atslēgu." }, "premiumMembership": { "message": "Premium dalība" @@ -786,8 +819,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." @@ -841,10 +874,10 @@ "message": "Ir nepieciešama Premium dalība, lai izmantotu šo iespēju." }, "enterVerificationCodeApp": { - "message": "Ievadi 6 ciparu apstiprinājuma kodu no autentificētāja lietotnes!" + "message": "Jāievada 6 ciparu apstiprinājuma kods no autentificētāja lietotnes." }, "enterVerificationCodeEmail": { - "message": "Ievadi 6 ciparu apstiprinājuma kodu, kas tika nosūtīts uz $EMAIL$.", + "message": "Jāievada 6 ciparu apstiprinājuma kods, kas tika nosūtīts uz $EMAIL$.", "placeholders": { "email": { "content": "$1", @@ -892,7 +925,7 @@ "message": "Šim kontam ir iespējota divpakāpju pieteikšanās, bet šajā pārlūkā netiek atbalstīts neviens no uzstādītajiem divpakāpju pārbaudes nodrošinātājiem." }, "noTwoStepProviders2": { - "message": "Lūgums izmantot atbalstītu tīmekļa pārlūku (piemēram Chrome) un/vai pievienot papildus nodrošinātājus, kas tiek labāk atbalstīti dažādos pārlūkos (piemēram autentificētāja lietotni)." + "message": "Lūgums izmantot atbalstītu tīmekļa pārlūku (piemēram Chrome) un/vai pievienot papildu nodrošinātājus, kas tiek labāk atbalstīti dažādos pārlūkos (piemēram autentificētāja lietotni)." }, "twoStepOptions": { "message": "Divpakāpju pieteikšanās iespējas" @@ -914,21 +947,21 @@ "message": "YubiKey OTP drošības atslēga" }, "yubiKeyDesc": { - "message": "Izmanto YubiKey, lai piekļūtu savam kontam! Darbojas ar YubiKey 4, 4 Nano, 4C un NEO ierīcēm." + "message": "Ir izmantojams YubiKey, lai piekļūtu savam kontam. Darbojas ar YubiKey 4, 4 Nano, 4C un NEO ierīcēm." }, "duoDesc": { - "message": "Apstiprini ar Duo Security, izmantojot Duo Mobile lietotni, īsziņu, tālruņa zvanu vai U2F drošības atslēgu!", + "message": "Ar Duo Security apliecināšanu var veikt ar Duo Mobile lietotni, īsziņu, tālruņa zvanu vai U2F drošības atslēgu.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "duoOrganizationDesc": { - "message": "Apstiprini ar Duo Security savā apvienībā, izmantojot Duo Mobile lietotni, īsziņu, tālruņa zvanu vai U2F drošības atslēgu!", + "message": "Apliecināšana ar savas apvienības Duo Security, izmantojot Duo Mobile lietotni, īsziņu, tālruņa zvanu vai U2F drošības atslēgu.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "webAuthnTitle": { "message": "FIDO2 WebAuthn" }, "webAuthnDesc": { - "message": "Izmantot jebkuru WebAuthn atbalstošu drošības atslēgu, lai piekļūtu kontam." + "message": "Ir izmantojama jebkura WebAuthn atbalstošu drošības atslēgu, lai piekļūtu kontam." }, "emailTitle": { "message": "E-pasts" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Atjaunot vienumu" }, - "restoreItemConfirmation": { - "message": "Jūs tiešām atjaunot šo vienumu?" - }, "restoredItem": { "message": "Vienums atjaunots" }, @@ -1534,10 +1564,10 @@ "message": "Jaunā galvenā parole neatbilst nosacījumu prasībām." }, "acceptPolicies": { - "message": "Atzīmējot šo rūtiņu, Tu piekrīti sekojošajam:" + "message": "Ar šīs rūtiņas atzīmēšanu tiek piekrists sekojošajam:" }, "acceptPoliciesRequired": { - "message": "Nav apstiprināti izmantošanas nosacījumi un privātuma politika." + "message": "Nav apstiprināti izmantošanas noteikumi un privātuma nosacījumi." }, "termsOfService": { "message": "Izmantošanas nosacījumi" @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Šajā ierīcē netiek atbalstīta pārlūka biometrija." }, + "biometricsFailedTitle": { + "message": "Biometrija neizdevās" + }, + "biometricsFailedDesc": { + "message": "Biometriju nevar pabeigt, jāapsver galvenās paroles izmantošana vai atteikšanās. Ja tas turpinās, lūgums sazināties ar Bitwarden atbalstu." + }, "nativeMessaginPermissionErrorTitle": { "message": "Atļauja nav nodrošināta" }, @@ -1633,15 +1669,15 @@ } }, "send": { - "message": "Sūtījums", + "message": "Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "searchSends": { - "message": "Meklēt Sūtījumus", + "message": "Meklēt Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "addSend": { - "message": "Pievienot Sūtījumu", + "message": "Pievienot Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeText": { @@ -1651,11 +1687,11 @@ "message": "Datne" }, "allSends": { - "message": "Visi Sūtījumi", + "message": "Visi Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "maxAccessCountReached": { - "message": "Sasniegts lielākais pieļaujamais piekļuvju skaits", + "message": "Sasniegts lielākais pieļaujamais piekļuves reižu skaits", "description": "This text will be displayed after a Send has been accessed the maximum amount of times." }, "expired": { @@ -1668,7 +1704,7 @@ "message": "Aizsargāts ar paroli" }, "copySendLink": { - "message": "Ievietot \"Send\" saiti starpliktuvē", + "message": "Ievietot Send saiti starpliktuvē", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "removePassword": { @@ -1681,11 +1717,11 @@ "message": "Parole noņemta" }, "deletedSend": { - "message": "Sūtījums dzēsts", + "message": "Send izdzēsts", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendLink": { - "message": "Sūtījuma saite", + "message": "Send saite", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "disabled": { @@ -1695,23 +1731,23 @@ "message": "Vai tiešām noņemt paroli?" }, "deleteSend": { - "message": "Dzēst Sūtījumu", + "message": "Izdzēst Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deleteSendConfirmation": { - "message": "Vai tiešām vēlaties dzēst šo Sūtījumu?", + "message": "Vai tiešām izdzēst šo Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editSend": { - "message": "Rediģēt Sūtījumu", + "message": "Labot Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeHeader": { - "message": "Kāds ir šī Sūtījums veids?", + "message": "Kāds ir šī Send veids?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendNameDesc": { - "message": "Draudzīgs nosaukums, lai raksturotu šo Sūtījumu.", + "message": "Lasāms nosaukums, kas apraksta šo Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendFileDesc": { @@ -1721,14 +1757,14 @@ "message": "Dzēšanas datums" }, "deletionDateDesc": { - "message": "Sūtījums tiks neatgriezeniski dzēsts norādītajā datumā un laikā.", + "message": "Send tiks neatgriezeniski izdzēsts norādītajā datumā un laikā.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "expirationDate": { "message": "Derīguma beigu datums" }, "expirationDateDesc": { - "message": "Ja tas ir iestatīts, piekļuve šim Sūtījumam beigsies norādītajā datumā un laikā.", + "message": "Ja iestatīts, piekļuve šim Send beigsies norādītajā datumā un laikā.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "oneDay": { @@ -1747,59 +1783,59 @@ "message": "Pielāgots" }, "maximumAccessCount": { - "message": "Lielākais pieļaujamais piekļuvju skaits" + "message": "Lielākais pieļaujamais piekļuves reižu skaits" }, "maximumAccessCountDesc": { - "message": "Ja tas ir iestatīts, lietotāji vairs nevarēs piekļūt šim Sūtījumam, tiklīdz būs sasniegts maksimālais piekļuves skaits.", + "message": "Ja iestatīts, lietotāji nevarēs piekļūt šim Send, kad tiks sasniegts lielākais pieļaujamais piekļūšanas reižu skaits.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDesc": { - "message": "Pēc izvēles pieprasīt paroli, lai lietotāji varētu piekļūt šim Sūtījumam.", + "message": "Pēc izvēles pieprasīt paroli, lai lietotāji varētu piekļūt šim Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendNotesDesc": { - "message": "Privātas piezīmes par šo Sūtījumu.", + "message": "Personīgas piezīmes par šo Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendDisableDesc": { - "message": "Deaktivizēt šo Sūtījumu, lai neviens tam nevarētu piekļūt.", + "message": "Izslēgt šo Send, lai neviens tam nevarētu piekļūt.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendShareDesc": { - "message": "Saglabāšanas brīdī ievietot šī \"Send\" saiti starpliktuvē.", + "message": "Saglabāšanas brīdī ievietot šī Send saiti starpliktuvē.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTextDesc": { "message": "Teksts, kuru ir vēlme nosūtīt." }, "sendHideText": { - "message": "Pēc noklusējuma slēpt šī Sūtījuma tekstu.", + "message": "Pēc noklusējuma paslēpt šī Send tekstu.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "currentAccessCount": { "message": "Pašreizējais piekļuvju skaits" }, "createSend": { - "message": "Jauns Sūtījums", + "message": "Jauns Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newPassword": { "message": "Jauna parole" }, "sendDisabled": { - "message": "Sūtījums noņemts", + "message": "Send noņemts", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendDisabledWarning": { - "message": "Uzņēmuma nosacījumu dēļ ir iespējams izdzēst tikai esošu \"Send\".", + "message": "Uzņēmuma nosacījumu dēļ ir iespējams izdzēst tikai esošu Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSend": { - "message": "Sūtījums izveidots", + "message": "Send izveidots", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { - "message": "Sūtījums saglabāts", + "message": "Send saglabāts", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendLinuxChromiumFileWarning": { @@ -1812,14 +1848,14 @@ "message": "Lai izvēlētos datni, ja tiek izmantots Safari, paplašinājums ir jāatver jaunā logā, klikšķinot uz šī paziņojuma." }, "sendFileCalloutHeader": { - "message": "Pirms Jūs sākat" + "message": "Pirms sākšanas" }, "sendFirefoxCustomDatePopoutMessage1": { "message": "Lai izmantotu kalendāra veida datumu atlasītāju,", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read '**To use a calendar style date picker ** click here to pop out your window.'" }, "sendFirefoxCustomDatePopoutMessage2": { - "message": "noklikšķiniet šeit", + "message": "klikšķināt šeit", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To use a calendar style date picker **click here** to pop out your window.'" }, "sendFirefoxCustomDatePopoutMessage3": { @@ -1845,7 +1881,7 @@ "message": "Slēpt e-pasta adresi no saņēmējiem." }, "sendOptionsPolicyInEffect": { - "message": "Viena vai vairākas organizācijas politikas ietekmē jūsu Sūtījuma opcijas." + "message": "Viens vai vairāki apvienības nosacījumi ietekmē Send iespējas." }, "passwordPrompt": { "message": "Galvenās paroles pārvaicāšana" @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Izdod personīgo glabātavu" }, - "exportingPersonalVaultDescription": { - "message": "Tiks izdoti tikai personīgie glabātavas vienumi, kas ir saistīti ar $EMAIL$. Apvienības glabātavas vienumi netiks iekļauti.", + "exportingIndividualVaultDescription": { + "message": "Tiks izgūti tikai atsevišķi glabātavas vienumi, kas ir saistīti ar $EMAIL$. Apvienības glabātavas vienumi netiks iekļauti. Tiks izgūta tikai glabātavas vienumu informācija, un saistītie pielikumi netiks iekļauti.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Servera versija" }, - "selfHosted": { - "message": "Pašizvietots" + "selfHostedServer": { + "message": "pašizvietots" }, "thirdParty": { "message": "Trešās puses" @@ -2129,7 +2165,7 @@ "message": "Atpazīšanas vārdkopa" }, "fingerprintMatchInfo": { - "message": "Jāpārliecinās, ka glabātava ir atslēgta un atpazīšanas vārdkopa ir tāda pati arī citā ierīcē." + "message": "Lūgums pārliecināties, ka glabātava ir atslēgta un atpazīšanas vārdkopa ir tāda pati arī citā ierīcē." }, "resendNotification": { "message": "Atkārtoti nosūtīt paziņojumu" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Uz ierīci ir nosūtīts paziņojums." }, - "logInInitiated": { + "loginInitiated": { "message": "Uzsākta pieteikšanās" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Apgabals" + "loggingInOn": { + "message": "Piesakās" }, "opensInANewWindow": { "message": "Atver jaunā logā" }, + "deviceApprovalRequired": { + "message": "Nepieciešams ierīces apstiprinājums. Zemāk jāatlasa apstiprinājuma iespēja:" + }, + "rememberThisDevice": { + "message": "Atcerēties šo ierīci" + }, + "uncheckIfPublicDevice": { + "message": "Jānoņem atzīme, ja tiek izmantota publiska ierīce" + }, + "approveFromYourOtherDevice": { + "message": "Jāapstiprina citā savā ierīcē" + }, + "requestAdminApproval": { + "message": "Pieprasīt pārvaldītāja apstiprinājumu" + }, + "approveWithMasterPassword": { + "message": "Apstiprināt ar galveno paroli" + }, + "ssoIdentifierRequired": { + "message": "Ir nepieciešams apvienības SSO identifikators." + }, "eu": { "message": "ES", "description": "European Union" }, - "us": { - "message": "ASV", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Piekļuve liegta. Nav nepieciešamo atļauju, lai skatītu šo lapu." + }, + "general": { + "message": "Vispārīgi" + }, + "display": { + "message": "Attēlojums" + }, + "accountSuccessfullyCreated": { + "message": "Konts ir veiksmīgi izveidots." + }, + "adminApprovalRequested": { + "message": "Pieprasīts pārvaldītāja apstiprinājums" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Pieprasījums tika nosūtīts pārvaldītājam." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Tiks saņemts paziņojums, tiklīdz būs apstiprināts." + }, + "troubleLoggingIn": { + "message": "Neizdodas pieteikties?" + }, + "loginApproved": { + "message": "Pieteikšanās apstiprināta" + }, + "userEmailMissing": { + "message": "Trūkst lietotāja e-pasta adreses" + }, + "deviceTrusted": { + "message": "Ierīce ir uzticama" + }, + "inputRequired": { + "message": "Jāievada vērtība." + }, + "required": { + "message": "nepieciešams" + }, + "search": { + "message": "Meklēt" + }, + "inputMinLength": { + "message": "Ievadītajai vērtībai ir jābūt vismaz $COUNT$ rakstzīmes garai.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Ievadītās vērtības garums nedrīkst pārsniegt $COUNT$ rakstzīmes.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Šādas rakstzīmes nav atļautas: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Ievadītajai vērtībai jābūt vismaz $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Ievadītā vērtība nedrīkst pārsniegt $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 vai vairākas e-pasta adreses nav derīgas" + }, + "inputTrimValidator": { + "message": "Ievadītā vērtība nevar sastāvēt tikai no atstarpēm.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Ievadītā vērtība nav e-pasta adrese." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ augstāk esošajam(iem) laukam(iem) ir jāpievērš uzmanība.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Atlasīt --" + }, + "multiSelectPlaceholder": { + "message": "-- Rakstīt, lai atlasītu --" + }, + "multiSelectLoading": { + "message": "Iegūst iespējas..." + }, + "multiSelectNotFound": { + "message": "Netika atrasti vienumi" + }, + "multiSelectClearAll": { + "message": "Notīrīt visu" + }, + "plusNMore": { + "message": "+ vēl $QUANTITY$", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Apakšizvēlne" + }, + "toggleCollapse": { + "message": "Pārslēgt sakļaušanu", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Aizstājdomēns" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Vienumus ar galvenās paroles pārvaicāšanu nevar automātiski aizpildīt lapas ielādes brīdī. Automātiskā aizpilde lapas ielādes brīdī ir izslēgta.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Automātiskā aizpilde lapas ielādes brīdī iestatīta izmantot noklusējuma iestatījumu.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Jāizslēdz galvenās paroles pārvaicāšana, lai labotu šo lauku", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 4c4e9936e0f..258ad3fd966 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "ഓട്ടോഫിൽ" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "പാസ്‌വേഡ് സൃഷ്ടിക്കുക (പകർത്തുക )" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "പൊരുത്തപ്പെടുന്ന ലോഗിനുകളൊന്നുമില്ല." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "മറ്റുള്ളവ" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "എക്സ്റ്റൻഷൻ റേറ്റ് ചെയ്യുക " }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "ശരി, ഇപ്പോൾ അപ്ഡേറ്റ് ചെയ്യുക" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "സവിശേഷത ലഭ്യമല്ല" }, - "updateKey": { - "message": "നിങ്ങളുടെ എൻ‌ക്രിപ്ഷൻ കീ അപ്‌ഡേറ്റ് ചെയ്യുന്നതുവരെ നിങ്ങൾക്ക് ഈ സവിശേഷത ഉപയോഗിക്കാൻ കഴിയില്ല." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "പ്രീമിയം അംഗത്വം" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "ഫയൽ അറ്റാച്ചുമെന്റുകൾക്കായി 1 ജിബി എൻക്രിപ്റ്റുചെയ്‌ത സംഭരണം." }, - "ppremiumSignUpTwoStep": { - "message": "രണ്ട്-ഘട്ട പ്രവേശന ഓപ്ഷനുകളായ Yubikey, FIDO U2F, Duo." + "premiumSignUpTwoStepOptions": { + "message": "Proprietary two-step login options such as YubiKey and Duo." }, "ppremiumSignUpReports": { "message": "നിങ്ങളുടെ വാൾട് സൂക്ഷിക്കുന്നതിന്. പാസ്‌വേഡ് ശുചിത്വം, അക്കൗണ്ട് ആരോഗ്യം, ഡാറ്റ ലംഘന റിപ്പോർട്ടുകൾ." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "ഇനം വീണ്ടെടുക്കുക " }, - "restoreItemConfirmation": { - "message": "ഈ ഇനം വീണ്ടെടുക്കണമെന്ന് ഉറപ്പാണോ?" - }, "restoredItem": { "message": "വീണ്ടെടുത്ത ഇനം" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json new file mode 100644 index 00000000000..131c062d544 --- /dev/null +++ b/apps/browser/src/_locales/mr/messages.json @@ -0,0 +1,2449 @@ +{ + "appName": { + "message": "Bitwarden" + }, + "extName": { + "message": "Bitwarden - विनामूल्य पासवर्ड व्यवस्थापक", + "description": "Extension name, MUST be less than 40 characters (Safari restriction)" + }, + "extDesc": { + "message": "तुमच्या सर्व उपकरणांसाठी एक सुरक्षित व विनामूल्य पासवर्ड व्यवस्थापक.", + "description": "Extension description" + }, + "loginOrCreateNewAccount": { + "message": "तुमच्या सुरक्षित तिजोरीत पोहचण्यासाठी लॉग इन करा किंवा नवीन खाते उघडा." + }, + "createAccount": { + "message": "खाते तयार करा" + }, + "login": { + "message": "प्रवेश करा" + }, + "enterpriseSingleSignOn": { + "message": "Enterprise single sign-on" + }, + "cancel": { + "message": "रद्द" + }, + "close": { + "message": "मिटवा" + }, + "submit": { + "message": "पाठवा" + }, + "emailAddress": { + "message": "ईमेल पत्ता" + }, + "masterPass": { + "message": "मुख्य पासवर्ड" + }, + "masterPassDesc": { + "message": "The master password is the password you use to access your vault. It is very important that you do not forget your master password. There is no way to recover the password in the event that you forget it." + }, + "masterPassHintDesc": { + "message": "A master password hint can help you remember your password if you forget it." + }, + "reTypeMasterPass": { + "message": "Re-type master password" + }, + "masterPassHint": { + "message": "मुख्य पासवर्डचा संकेत (पर्यायी)" + }, + "tab": { + "message": "टॅब" + }, + "vault": { + "message": "तिजोरी" + }, + "myVault": { + "message": "माझी तिजोरी" + }, + "allVaults": { + "message": "सर्व तिजोऱ्या" + }, + "tools": { + "message": "साधने" + }, + "settings": { + "message": "मांडणी" + }, + "currentTab": { + "message": "वर्तमान टॅब" + }, + "copyPassword": { + "message": "पासवर्ड कॉपी करा" + }, + "copyNote": { + "message": "टीप कॉपी करा" + }, + "copyUri": { + "message": "URI कॉपी करा" + }, + "copyUsername": { + "message": "वापरकर्तानाव कॉपी करा" + }, + "copyNumber": { + "message": "क्रमांक कॉपी करा" + }, + "copySecurityCode": { + "message": "सुरक्षा कोड कॉपी करा" + }, + "autoFill": { + "message": "स्वयंभरण" + }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, + "generatePasswordCopied": { + "message": "Generate password (copied)" + }, + "copyElementIdentifier": { + "message": "Copy custom field name" + }, + "noMatchingLogins": { + "message": "No matching logins" + }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, + "unlockVaultMenu": { + "message": "तिजोरी उघडा" + }, + "loginToVaultMenu": { + "message": "तिजोरीत प्रवेश करा" + }, + "autoFillInfo": { + "message": "There are no logins available to auto-fill for the current browser tab." + }, + "addLogin": { + "message": "लॉगिन जोडा" + }, + "addItem": { + "message": "वस्तू जोडा" + }, + "passwordHint": { + "message": "पासवर्ड संकेत" + }, + "enterEmailToGetHint": { + "message": "Enter your account email address to receive your master password hint." + }, + "getMasterPasswordHint": { + "message": "मुख्य पासवर्ड संकेत मिळवा" + }, + "continue": { + "message": "पुढे" + }, + "sendVerificationCode": { + "message": "तुमच्या ईमेलवर एक सत्यापन कोड पाठवा" + }, + "sendCode": { + "message": "कोड पाठवा" + }, + "codeSent": { + "message": "कोड पाठवला" + }, + "verificationCode": { + "message": "सत्यापन कोड" + }, + "confirmIdentity": { + "message": "पुढे जाण्यासाठी तुमच्या ओळखीची पुष्टी करा." + }, + "account": { + "message": "खाते" + }, + "changeMasterPassword": { + "message": "मुख्य पासवर्ड बदला" + }, + "fingerprintPhrase": { + "message": "अंगुलिमुद्रा वाक्यांश", + "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." + }, + "yourAccountsFingerprint": { + "message": "तुमच्या खात्याचा अंगुलिमुद्रा वाक्यांश", + "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." + }, + "twoStepLogin": { + "message": "दोन टप्प्यात लॉगिन" + }, + "logOut": { + "message": "बाहेर पडा" + }, + "about": { + "message": "आमच्या विषयी" + }, + "version": { + "message": "आवृत्ती" + }, + "save": { + "message": "साठवा" + }, + "move": { + "message": "हलवा" + }, + "addFolder": { + "message": "फोल्डर जोडा" + }, + "name": { + "message": "नाव" + }, + "editFolder": { + "message": "फोल्डर संपादित करा" + }, + "deleteFolder": { + "message": "फोल्डर खोडून टाका" + }, + "folders": { + "message": "Folders" + }, + "noFolders": { + "message": "There are no folders to list." + }, + "helpFeedback": { + "message": "Help & feedback" + }, + "helpCenter": { + "message": "Bitwarden Help center" + }, + "communityForums": { + "message": "Explore Bitwarden community forums" + }, + "contactSupport": { + "message": "Contact Bitwarden support" + }, + "sync": { + "message": "संकालन" + }, + "syncVaultNow": { + "message": "तिजोरी संकालन आता करा" + }, + "lastSync": { + "message": "शेवटचे संकालन:" + }, + "passGen": { + "message": "पासवर्ड जनित्र" + }, + "generator": { + "message": "जनित्र", + "description": "Short for 'Password Generator'." + }, + "passGenInfo": { + "message": "Automatically generate strong, unique passwords for your logins." + }, + "bitWebVault": { + "message": "Bitwarden web vault" + }, + "importItems": { + "message": "वस्तू आयात करा" + }, + "select": { + "message": "Select" + }, + "generatePassword": { + "message": "Generate password" + }, + "regeneratePassword": { + "message": "पासवर्ड पुनर्जनित करा" + }, + "options": { + "message": "पर्याय" + }, + "length": { + "message": "लांबी" + }, + "uppercase": { + "message": "Uppercase (A-Z)" + }, + "lowercase": { + "message": "Lowercase (a-z)" + }, + "numbers": { + "message": "Numbers (0-9)" + }, + "specialCharacters": { + "message": "Special characters (!@#$%^&*)" + }, + "numWords": { + "message": "Number of words" + }, + "wordSeparator": { + "message": "Word separator" + }, + "capitalize": { + "message": "Capitalize", + "description": "Make the first letter of a work uppercase." + }, + "includeNumber": { + "message": "Include number" + }, + "minNumbers": { + "message": "Minimum numbers" + }, + "minSpecial": { + "message": "Minimum special" + }, + "avoidAmbChar": { + "message": "Avoid ambiguous characters" + }, + "searchVault": { + "message": "तिजोरीत शोधा" + }, + "edit": { + "message": "Edit" + }, + "view": { + "message": "View" + }, + "noItemsInList": { + "message": "There are no items to list." + }, + "itemInformation": { + "message": "Item information" + }, + "username": { + "message": "Username" + }, + "password": { + "message": "Password" + }, + "passphrase": { + "message": "वाक्यांश" + }, + "favorite": { + "message": "आवडते" + }, + "notes": { + "message": "टिप" + }, + "note": { + "message": "Note" + }, + "editItem": { + "message": "वस्तू संपादित करा" + }, + "folder": { + "message": "फोल्डर" + }, + "deleteItem": { + "message": "वस्तू खोडून टाका" + }, + "viewItem": { + "message": "वस्तू बघा" + }, + "launch": { + "message": "उघडा" + }, + "website": { + "message": "संकेतस्थळ" + }, + "toggleVisibility": { + "message": "दृश्यात उलटवा" + }, + "manage": { + "message": "व्यवस्थापन" + }, + "other": { + "message": "इतर" + }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, + "rateExtension": { + "message": "विस्तारकाचे मूल्यांकन करा" + }, + "rateExtensionDesc": { + "message": "चांगला अभिप्राय देऊन आम्हाला मदत करा!" + }, + "browserNotSupportClipboard": { + "message": "Your web browser does not support easy clipboard copying. Copy it manually instead." + }, + "verifyIdentity": { + "message": "ओळख सत्यापित करा" + }, + "yourVaultIsLocked": { + "message": "तुमची तिजोरीला कुलूप लावले आहे. पुढे जाण्यासाठी तुमची ओळख सत्यापित करा." + }, + "unlock": { + "message": "कुलूप उघडा" + }, + "loggedInAsOn": { + "message": "Logged in as $EMAIL$ on $HOSTNAME$.", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + }, + "hostname": { + "content": "$2", + "example": "bitwarden.com" + } + } + }, + "invalidMasterPassword": { + "message": "अवैध मुख्य पासवर्ड" + }, + "vaultTimeout": { + "message": "Vault timeout" + }, + "lockNow": { + "message": "Lock now" + }, + "immediately": { + "message": "Immediately" + }, + "tenSeconds": { + "message": "10 seconds" + }, + "twentySeconds": { + "message": "20 seconds" + }, + "thirtySeconds": { + "message": "30 seconds" + }, + "oneMinute": { + "message": "1 minute" + }, + "twoMinutes": { + "message": "2 minutes" + }, + "fiveMinutes": { + "message": "5 minutes" + }, + "fifteenMinutes": { + "message": "15 minutes" + }, + "thirtyMinutes": { + "message": "30 minutes" + }, + "oneHour": { + "message": "1 hour" + }, + "fourHours": { + "message": "4 hours" + }, + "onLocked": { + "message": "On system lock" + }, + "onRestart": { + "message": "On browser restart" + }, + "never": { + "message": "Never" + }, + "security": { + "message": "Security" + }, + "errorOccurred": { + "message": "An error has occurred" + }, + "emailRequired": { + "message": "Email address is required." + }, + "invalidEmail": { + "message": "Invalid email address." + }, + "masterPasswordRequired": { + "message": "Master password is required." + }, + "confirmMasterPasswordRequired": { + "message": "Master password retype is required." + }, + "masterPasswordMinlength": { + "message": "Master password must be at least $VALUE$ characters long.", + "description": "The Master Password must be at least a specific number of characters long.", + "placeholders": { + "value": { + "content": "$1", + "example": "8" + } + } + }, + "masterPassDoesntMatch": { + "message": "Master password confirmation does not match." + }, + "newAccountCreated": { + "message": "Your new account has been created! You may now log in." + }, + "masterPassSent": { + "message": "We've sent you an email with your master password hint." + }, + "verificationCodeRequired": { + "message": "Verification code is required." + }, + "invalidVerificationCode": { + "message": "Invalid verification code" + }, + "valueCopied": { + "message": "$VALUE$ copied", + "description": "Value has been copied to the clipboard.", + "placeholders": { + "value": { + "content": "$1", + "example": "Password" + } + } + }, + "autofillError": { + "message": "Unable to auto-fill the selected item on this page. Copy and paste the information instead." + }, + "loggedOut": { + "message": "Logged out" + }, + "loginExpired": { + "message": "Your login session has expired." + }, + "logOutConfirmation": { + "message": "Are you sure you want to log out?" + }, + "yes": { + "message": "Yes" + }, + "no": { + "message": "No" + }, + "unexpectedError": { + "message": "An unexpected error has occurred." + }, + "nameRequired": { + "message": "Name is required." + }, + "addedFolder": { + "message": "Folder added" + }, + "changeMasterPass": { + "message": "Change master password" + }, + "changeMasterPasswordConfirmation": { + "message": "You can change your master password on the bitwarden.com web vault. Do you want to visit the website now?" + }, + "twoStepLoginConfirmation": { + "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" + }, + "deleteFolderConfirmation": { + "message": "Are you sure you want to delete this folder?" + }, + "deletedFolder": { + "message": "Folder deleted" + }, + "gettingStartedTutorial": { + "message": "Getting started tutorial" + }, + "gettingStartedTutorialVideo": { + "message": "Watch our getting started tutorial to learn how to get the most out of the browser extension." + }, + "syncingComplete": { + "message": "Syncing complete" + }, + "syncingFailed": { + "message": "Syncing failed" + }, + "passwordCopied": { + "message": "Password copied" + }, + "uri": { + "message": "URI" + }, + "uriPosition": { + "message": "URI $POSITION$", + "description": "A listing of URIs. Ex: URI 1, URI 2, URI 3, etc.", + "placeholders": { + "position": { + "content": "$1", + "example": "2" + } + } + }, + "newUri": { + "message": "New URI" + }, + "addedItem": { + "message": "Item added" + }, + "editedItem": { + "message": "Item saved" + }, + "deleteItemConfirmation": { + "message": "Do you really want to send to the trash?" + }, + "deletedItem": { + "message": "Item sent to trash" + }, + "overwritePassword": { + "message": "Overwrite password" + }, + "overwritePasswordConfirmation": { + "message": "Are you sure you want to overwrite the current password?" + }, + "overwriteUsername": { + "message": "Overwrite username" + }, + "overwriteUsernameConfirmation": { + "message": "Are you sure you want to overwrite the current username?" + }, + "searchFolder": { + "message": "Search folder" + }, + "searchCollection": { + "message": "Search collection" + }, + "searchType": { + "message": "Search type" + }, + "noneFolder": { + "message": "No folder", + "description": "This is the folder for uncategorized items" + }, + "enableAddLoginNotification": { + "message": "Ask to add login" + }, + "addLoginNotificationDesc": { + "message": "Ask to add an item if one isn't found in your vault." + }, + "showCardsCurrentTab": { + "message": "Show cards on Tab page" + }, + "showCardsCurrentTabDesc": { + "message": "List card items on the Tab page for easy auto-fill." + }, + "showIdentitiesCurrentTab": { + "message": "Show identities on Tab page" + }, + "showIdentitiesCurrentTabDesc": { + "message": "List identity items on the Tab page for easy auto-fill." + }, + "clearClipboard": { + "message": "Clear clipboard", + "description": "Clipboard is the operating system thing where you copy/paste data to on your device." + }, + "clearClipboardDesc": { + "message": "Automatically clear copied values from your clipboard.", + "description": "Clipboard is the operating system thing where you copy/paste data to on your device." + }, + "notificationAddDesc": { + "message": "Should Bitwarden remember this password for you?" + }, + "notificationAddSave": { + "message": "Save" + }, + "enableChangedPasswordNotification": { + "message": "Ask to update existing login" + }, + "changedPasswordNotificationDesc": { + "message": "Ask to update a login's password when a change is detected on a website." + }, + "notificationChangeDesc": { + "message": "Do you want to update this password in Bitwarden?" + }, + "notificationChangeSave": { + "message": "Update" + }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, + "enableContextMenuItem": { + "message": "Show context menu options" + }, + "contextMenuItemDesc": { + "message": "Use a secondary click to access password generation and matching logins for the website. " + }, + "defaultUriMatchDetection": { + "message": "Default URI match detection", + "description": "Default URI match detection for auto-fill." + }, + "defaultUriMatchDetectionDesc": { + "message": "Choose the default way that URI match detection is handled for logins when performing actions such as auto-fill." + }, + "theme": { + "message": "Theme" + }, + "themeDesc": { + "message": "Change the application's color theme." + }, + "dark": { + "message": "Dark", + "description": "Dark color" + }, + "light": { + "message": "Light", + "description": "Light color" + }, + "solarizedDark": { + "message": "Solarized dark", + "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." + }, + "exportVault": { + "message": "Export vault" + }, + "fileFormat": { + "message": "File format" + }, + "warning": { + "message": "WARNING", + "description": "WARNING (should stay in capitalized letters if the language permits)" + }, + "confirmVaultExport": { + "message": "Confirm vault export" + }, + "exportWarningDesc": { + "message": "This export contains your vault data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it." + }, + "encExportKeyWarningDesc": { + "message": "This export encrypts your data using your account's encryption key. If you ever rotate your account's encryption key you should export again since you will not be able to decrypt this export file." + }, + "encExportAccountWarningDesc": { + "message": "Account encryption keys are unique to each Bitwarden user account, so you can't import an encrypted export into a different account." + }, + "exportMasterPassword": { + "message": "Enter your master password to export your vault data." + }, + "shared": { + "message": "Shared" + }, + "learnOrg": { + "message": "Learn about organizations" + }, + "learnOrgConfirmation": { + "message": "Bitwarden allows you to share your vault items with others by using an organization. Would you like to visit the bitwarden.com website to learn more?" + }, + "moveToOrganization": { + "message": "Move to organization" + }, + "share": { + "message": "Share" + }, + "movedItemToOrg": { + "message": "$ITEMNAME$ moved to $ORGNAME$", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Secret Item" + }, + "orgname": { + "content": "$2", + "example": "Company Name" + } + } + }, + "moveToOrgDesc": { + "message": "Choose an organization that you wish to move this item to. Moving to an organization transfers ownership of the item to that organization. You will no longer be the direct owner of this item once it has been moved." + }, + "learnMore": { + "message": "Learn more" + }, + "authenticatorKeyTotp": { + "message": "Authenticator key (TOTP)" + }, + "verificationCodeTotp": { + "message": "Verification code (TOTP)" + }, + "copyVerificationCode": { + "message": "Copy verification code" + }, + "attachments": { + "message": "Attachments" + }, + "deleteAttachment": { + "message": "Delete attachment" + }, + "deleteAttachmentConfirmation": { + "message": "Are you sure you want to delete this attachment?" + }, + "deletedAttachment": { + "message": "Attachment deleted" + }, + "newAttachment": { + "message": "Add new attachment" + }, + "noAttachments": { + "message": "No attachments." + }, + "attachmentSaved": { + "message": "Attachment saved" + }, + "file": { + "message": "File" + }, + "selectFile": { + "message": "Select a file" + }, + "maxFileSize": { + "message": "Maximum file size is 500 MB." + }, + "featureUnavailable": { + "message": "Feature unavailable" + }, + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." + }, + "premiumMembership": { + "message": "Premium membership" + }, + "premiumManage": { + "message": "Manage membership" + }, + "premiumManageAlert": { + "message": "You can manage your membership on the bitwarden.com web vault. Do you want to visit the website now?" + }, + "premiumRefresh": { + "message": "Refresh membership" + }, + "premiumNotCurrentMember": { + "message": "You are not currently a Premium member." + }, + "premiumSignUpAndGet": { + "message": "Sign up for a Premium membership and get:" + }, + "ppremiumSignUpStorage": { + "message": "1 GB encrypted storage for file attachments." + }, + "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." + }, + "ppremiumSignUpTotp": { + "message": "TOTP verification code (2FA) generator for logins in your vault." + }, + "ppremiumSignUpSupport": { + "message": "Priority customer support." + }, + "ppremiumSignUpFuture": { + "message": "All future Premium features. More coming soon!" + }, + "premiumPurchase": { + "message": "Purchase Premium" + }, + "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!" + }, + "premiumCurrentMemberThanks": { + "message": "Thank you for supporting Bitwarden." + }, + "premiumPrice": { + "message": "All for just $PRICE$ /year!", + "placeholders": { + "price": { + "content": "$1", + "example": "$10" + } + } + }, + "refreshComplete": { + "message": "Refresh complete" + }, + "enableAutoTotpCopy": { + "message": "Copy TOTP automatically" + }, + "disableAutoTotpCopyDesc": { + "message": "If a login has an authenticator key, copy the TOTP verification code to your clip-board when you auto-fill the login." + }, + "enableAutoBiometricsPrompt": { + "message": "Ask for biometrics on launch" + }, + "premiumRequired": { + "message": "Premium required" + }, + "premiumRequiredDesc": { + "message": "A Premium membership is required to use this feature." + }, + "enterVerificationCodeApp": { + "message": "Enter the 6 digit verification code from your authenticator app." + }, + "enterVerificationCodeEmail": { + "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", + "placeholders": { + "email": { + "content": "$1", + "example": "example@gmail.com" + } + } + }, + "verificationCodeEmailSent": { + "message": "Verification email sent to $EMAIL$.", + "placeholders": { + "email": { + "content": "$1", + "example": "example@gmail.com" + } + } + }, + "rememberMe": { + "message": "Remember me" + }, + "sendVerificationCodeEmailAgain": { + "message": "Send verification code email again" + }, + "useAnotherTwoStepMethod": { + "message": "Use another two-step login method" + }, + "insertYubiKey": { + "message": "Insert your YubiKey into your computer's USB port, then touch its button." + }, + "insertU2f": { + "message": "Insert your security key into your computer's USB port. If it has a button, touch it." + }, + "webAuthnNewTab": { + "message": "To start the WebAuthn 2FA verification. Click the button below to open a new tab and follow the instructions provided in the new tab." + }, + "webAuthnNewTabOpen": { + "message": "Open new tab" + }, + "webAuthnAuthenticate": { + "message": "Authenticate WebAuthn" + }, + "loginUnavailable": { + "message": "Login unavailable" + }, + "noTwoStepProviders": { + "message": "This account has two-step login set up, however, none of the configured two-step providers are supported by this web browser." + }, + "noTwoStepProviders2": { + "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" + }, + "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." + }, + "recoveryCodeTitle": { + "message": "Recovery code" + }, + "authenticatorAppTitle": { + "message": "Authenticator app" + }, + "authenticatorAppDesc": { + "message": "Use an authenticator app (such as Authy or Google Authenticator) to generate time-based verification codes.", + "description": "'Authy' and 'Google Authenticator' are product names and should not be translated." + }, + "yubiKeyTitle": { + "message": "YubiKey OTP Security Key" + }, + "yubiKeyDesc": { + "message": "Use a YubiKey to access your account. Works with YubiKey 4, 4 Nano, 4C, and NEO devices." + }, + "duoDesc": { + "message": "Verify with Duo Security using the Duo Mobile app, SMS, phone call, or U2F security key.", + "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." + }, + "duoOrganizationDesc": { + "message": "Verify with Duo Security for your organization using the Duo Mobile app, SMS, phone call, or U2F security key.", + "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." + }, + "webAuthnTitle": { + "message": "FIDO2 WebAuthn" + }, + "webAuthnDesc": { + "message": "Use any WebAuthn compatible security key to access your account." + }, + "emailTitle": { + "message": "Email" + }, + "emailDesc": { + "message": "Verification codes will be emailed to you." + }, + "selfHostedEnvironment": { + "message": "Self-hosted environment" + }, + "selfHostedEnvironmentFooter": { + "message": "Specify the base URL of your on-premises hosted Bitwarden installation." + }, + "customEnvironment": { + "message": "Custom environment" + }, + "customEnvironmentFooter": { + "message": "For advanced users. You can specify the base URL of each service independently." + }, + "baseUrl": { + "message": "Server URL" + }, + "apiUrl": { + "message": "API server URL" + }, + "webVaultUrl": { + "message": "Web vault server URL" + }, + "identityUrl": { + "message": "Identity server URL" + }, + "notificationsUrl": { + "message": "Notifications server URL" + }, + "iconsUrl": { + "message": "Icons server URL" + }, + "environmentSaved": { + "message": "Environment URLs saved" + }, + "enableAutoFillOnPageLoad": { + "message": "Auto-fill on page load" + }, + "enableAutoFillOnPageLoadDesc": { + "message": "If a login form is detected, auto-fill when the web page loads." + }, + "experimentalFeature": { + "message": "Compromised or untrusted websites can exploit auto-fill on page load." + }, + "learnMoreAboutAutofill": { + "message": "Learn more about auto-fill" + }, + "defaultAutoFillOnPageLoad": { + "message": "Default autofill setting for login items" + }, + "defaultAutoFillOnPageLoadDesc": { + "message": "You can turn off auto-fill on page load for individual login items from the item's Edit view." + }, + "itemAutoFillOnPageLoad": { + "message": "Auto-fill on page load (if set up in Options)" + }, + "autoFillOnPageLoadUseDefault": { + "message": "Use default setting" + }, + "autoFillOnPageLoadYes": { + "message": "Auto-fill on page load" + }, + "autoFillOnPageLoadNo": { + "message": "Do not auto-fill on page load" + }, + "commandOpenPopup": { + "message": "Open vault popup" + }, + "commandOpenSidebar": { + "message": "Open vault in sidebar" + }, + "commandAutofillDesc": { + "message": "Auto-fill the last used login for the current website" + }, + "commandGeneratePasswordDesc": { + "message": "Generate and copy a new random password to the clipboard" + }, + "commandLockVaultDesc": { + "message": "Lock the vault" + }, + "privateModeWarning": { + "message": "Private mode support is experimental and some features are limited." + }, + "customFields": { + "message": "Custom fields" + }, + "copyValue": { + "message": "Copy value" + }, + "value": { + "message": "Value" + }, + "newCustomField": { + "message": "New custom field" + }, + "dragToSort": { + "message": "Drag to sort" + }, + "cfTypeText": { + "message": "Text" + }, + "cfTypeHidden": { + "message": "Hidden" + }, + "cfTypeBoolean": { + "message": "Boolean" + }, + "cfTypeLinked": { + "message": "Linked", + "description": "This describes a field that is 'linked' (tied) to another field." + }, + "linkedValue": { + "message": "Linked value", + "description": "This describes a value that is 'linked' (tied) to another value." + }, + "popup2faCloseMessage": { + "message": "Clicking outside the popup window to check your email for your verification code will cause this popup to close. Do you want to open this popup in a new window so that it does not close?" + }, + "popupU2fCloseMessage": { + "message": "This browser cannot process U2F requests in this popup window. Do you want to open this popup in a new window so that you can log in using U2F?" + }, + "enableFavicon": { + "message": "Show website icons" + }, + "faviconDesc": { + "message": "Show a recognizable image next to each login." + }, + "enableBadgeCounter": { + "message": "Show badge counter" + }, + "badgeCounterDesc": { + "message": "Indicate how many logins you have for the current web page." + }, + "cardholderName": { + "message": "Cardholder name" + }, + "number": { + "message": "Number" + }, + "brand": { + "message": "Brand" + }, + "expirationMonth": { + "message": "Expiration month" + }, + "expirationYear": { + "message": "Expiration year" + }, + "expiration": { + "message": "Expiration" + }, + "january": { + "message": "January" + }, + "february": { + "message": "February" + }, + "march": { + "message": "March" + }, + "april": { + "message": "April" + }, + "may": { + "message": "May" + }, + "june": { + "message": "June" + }, + "july": { + "message": "July" + }, + "august": { + "message": "August" + }, + "september": { + "message": "September" + }, + "october": { + "message": "October" + }, + "november": { + "message": "November" + }, + "december": { + "message": "December" + }, + "securityCode": { + "message": "Security code" + }, + "ex": { + "message": "ex." + }, + "title": { + "message": "Title" + }, + "mr": { + "message": "Mr" + }, + "mrs": { + "message": "Mrs" + }, + "ms": { + "message": "Ms" + }, + "dr": { + "message": "Dr" + }, + "mx": { + "message": "Mx" + }, + "firstName": { + "message": "First name" + }, + "middleName": { + "message": "Middle name" + }, + "lastName": { + "message": "Last name" + }, + "fullName": { + "message": "Full name" + }, + "identityName": { + "message": "Identity name" + }, + "company": { + "message": "Company" + }, + "ssn": { + "message": "Social Security number" + }, + "passportNumber": { + "message": "Passport number" + }, + "licenseNumber": { + "message": "License number" + }, + "email": { + "message": "Email" + }, + "phone": { + "message": "Phone" + }, + "address": { + "message": "Address" + }, + "address1": { + "message": "Address 1" + }, + "address2": { + "message": "Address 2" + }, + "address3": { + "message": "Address 3" + }, + "cityTown": { + "message": "City / Town" + }, + "stateProvince": { + "message": "State / Province" + }, + "zipPostalCode": { + "message": "Zip / Postal code" + }, + "country": { + "message": "Country" + }, + "type": { + "message": "Type" + }, + "typeLogin": { + "message": "Login" + }, + "typeLogins": { + "message": "Logins" + }, + "typeSecureNote": { + "message": "Secure note" + }, + "typeCard": { + "message": "Card" + }, + "typeIdentity": { + "message": "Identity" + }, + "passwordHistory": { + "message": "Password history" + }, + "back": { + "message": "Back" + }, + "collections": { + "message": "Collections" + }, + "favorites": { + "message": "Favorites" + }, + "popOutNewWindow": { + "message": "Pop out to a new window" + }, + "refresh": { + "message": "Refresh" + }, + "cards": { + "message": "Cards" + }, + "identities": { + "message": "Identities" + }, + "logins": { + "message": "Logins" + }, + "secureNotes": { + "message": "Secure notes" + }, + "clear": { + "message": "Clear", + "description": "To clear something out. example: To clear browser history." + }, + "checkPassword": { + "message": "Check if password has been exposed." + }, + "passwordExposed": { + "message": "This password has been exposed $VALUE$ time(s) in data breaches. You should change it.", + "placeholders": { + "value": { + "content": "$1", + "example": "2" + } + } + }, + "passwordSafe": { + "message": "This password was not found in any known data breaches. It should be safe to use." + }, + "baseDomain": { + "message": "Base domain", + "description": "Domain name. Ex. website.com" + }, + "domainName": { + "message": "Domain name", + "description": "Domain name. Ex. website.com" + }, + "host": { + "message": "Host", + "description": "A URL's host value. For example, the host of https://sub.domain.com:443 is 'sub.domain.com:443'." + }, + "exact": { + "message": "Exact" + }, + "startsWith": { + "message": "Starts with" + }, + "regEx": { + "message": "Regular expression", + "description": "A programming term, also known as 'RegEx'." + }, + "matchDetection": { + "message": "Match detection", + "description": "URI match detection for auto-fill." + }, + "defaultMatchDetection": { + "message": "Default match detection", + "description": "Default URI match detection for auto-fill." + }, + "toggleOptions": { + "message": "Toggle options" + }, + "toggleCurrentUris": { + "message": "Toggle current URIs", + "description": "Toggle the display of the URIs of the currently open tabs in the browser." + }, + "currentUri": { + "message": "Current URI", + "description": "The URI of one of the current open tabs in the browser." + }, + "organization": { + "message": "Organization", + "description": "An entity of multiple related people (ex. a team or business organization)." + }, + "types": { + "message": "Types" + }, + "allItems": { + "message": "All items" + }, + "noPasswordsInList": { + "message": "There are no passwords to list." + }, + "remove": { + "message": "Remove" + }, + "default": { + "message": "Default" + }, + "dateUpdated": { + "message": "Updated", + "description": "ex. Date this item was updated" + }, + "dateCreated": { + "message": "Created", + "description": "ex. Date this item was created" + }, + "datePasswordUpdated": { + "message": "Password updated", + "description": "ex. Date this password was updated" + }, + "neverLockWarning": { + "message": "Are you sure you want to use the \"Never\" option? Setting your lock options to \"Never\" stores your vault's encryption key on your device. If you use this option you should ensure that you keep your device properly protected." + }, + "noOrganizationsList": { + "message": "You do not belong to any organizations. Organizations allow you to securely share items with other users." + }, + "noCollectionsInList": { + "message": "There are no collections to list." + }, + "ownership": { + "message": "Ownership" + }, + "whoOwnsThisItem": { + "message": "Who owns this item?" + }, + "strong": { + "message": "Strong", + "description": "ex. A strong password. Scale: Weak -> Good -> Strong" + }, + "good": { + "message": "Good", + "description": "ex. A good password. Scale: Weak -> Good -> Strong" + }, + "weak": { + "message": "Weak", + "description": "ex. A weak password. Scale: Weak -> Good -> Strong" + }, + "weakMasterPassword": { + "message": "Weak master password" + }, + "weakMasterPasswordDesc": { + "message": "The master password you have chosen is weak. You should use a strong master password (or a passphrase) to properly protect your Bitwarden account. Are you sure you want to use this master password?" + }, + "pin": { + "message": "PIN", + "description": "PIN code. Ex. The short code (often numeric) that you use to unlock a device." + }, + "unlockWithPin": { + "message": "Unlock with PIN" + }, + "setYourPinCode": { + "message": "Set your PIN code for unlocking Bitwarden. Your PIN settings will be reset if you ever fully log out of the application." + }, + "pinRequired": { + "message": "PIN code is required." + }, + "invalidPin": { + "message": "Invalid PIN code." + }, + "unlockWithBiometrics": { + "message": "Unlock with biometrics" + }, + "awaitDesktop": { + "message": "Awaiting confirmation from desktop" + }, + "awaitDesktopDesc": { + "message": "Please confirm using biometrics in the Bitwarden desktop application to set up biometrics for browser." + }, + "lockWithMasterPassOnRestart": { + "message": "Lock with master password on browser restart" + }, + "selectOneCollection": { + "message": "You must select at least one collection." + }, + "cloneItem": { + "message": "Clone item" + }, + "clone": { + "message": "Clone" + }, + "passwordGeneratorPolicyInEffect": { + "message": "One or more organization policies are affecting your generator settings." + }, + "vaultTimeoutAction": { + "message": "Vault timeout action" + }, + "lock": { + "message": "Lock", + "description": "Verb form: to make secure or inaccesible by" + }, + "trash": { + "message": "Trash", + "description": "Noun: a special folder to hold deleted items" + }, + "searchTrash": { + "message": "Search trash" + }, + "permanentlyDeleteItem": { + "message": "Permanently delete item" + }, + "permanentlyDeleteItemConfirmation": { + "message": "Are you sure you want to permanently delete this item?" + }, + "permanentlyDeletedItem": { + "message": "Item permanently deleted" + }, + "restoreItem": { + "message": "Restore item" + }, + "restoredItem": { + "message": "Item restored" + }, + "vaultTimeoutLogOutConfirmation": { + "message": "Logging out will remove all access to your vault and requires online authentication after the timeout period. Are you sure you want to use this setting?" + }, + "vaultTimeoutLogOutConfirmationTitle": { + "message": "Timeout action confirmation" + }, + "autoFillAndSave": { + "message": "Auto-fill and save" + }, + "autoFillSuccessAndSavedUri": { + "message": "Item auto-filled and URI saved" + }, + "autoFillSuccess": { + "message": "Item auto-filled " + }, + "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." + }, + "insecurePageWarningFillPrompt": { + "message": "Do you still wish to fill this login?" + }, + "autofillIframeWarning": { + "message": "The form is hosted by a different domain than the URI of your saved login. Choose OK to auto-fill anyway, or Cancel to stop." + }, + "autofillIframeWarningTip": { + "message": "To prevent this warning in the future, save this URI, $HOSTNAME$, to your Bitwarden login item for this site.", + "placeholders": { + "hostname": { + "content": "$1", + "example": "www.example.com" + } + } + }, + "setMasterPassword": { + "message": "Set master password" + }, + "currentMasterPass": { + "message": "Current master password" + }, + "newMasterPass": { + "message": "New master password" + }, + "confirmNewMasterPass": { + "message": "Confirm new master password" + }, + "masterPasswordPolicyInEffect": { + "message": "One or more organization policies require your master password to meet the following requirements:" + }, + "policyInEffectMinComplexity": { + "message": "Minimum complexity score of $SCORE$", + "placeholders": { + "score": { + "content": "$1", + "example": "4" + } + } + }, + "policyInEffectMinLength": { + "message": "Minimum length of $LENGTH$", + "placeholders": { + "length": { + "content": "$1", + "example": "14" + } + } + }, + "policyInEffectUppercase": { + "message": "Contain one or more uppercase characters" + }, + "policyInEffectLowercase": { + "message": "Contain one or more lowercase characters" + }, + "policyInEffectNumbers": { + "message": "Contain one or more numbers" + }, + "policyInEffectSpecial": { + "message": "Contain one or more of the following special characters $CHARS$", + "placeholders": { + "chars": { + "content": "$1", + "example": "!@#$%^&*" + } + } + }, + "masterPasswordPolicyRequirementsNotMet": { + "message": "Your new master password does not meet the policy requirements." + }, + "acceptPolicies": { + "message": "By checking this box you agree to the following:" + }, + "acceptPoliciesRequired": { + "message": "Terms of Service and Privacy Policy have not been acknowledged." + }, + "termsOfService": { + "message": "Terms of Service" + }, + "privacyPolicy": { + "message": "Privacy Policy" + }, + "hintEqualsPassword": { + "message": "Your password hint cannot be the same as your password." + }, + "ok": { + "message": "Ok" + }, + "desktopSyncVerificationTitle": { + "message": "Desktop sync verification" + }, + "desktopIntegrationVerificationText": { + "message": "Please verify that the desktop application shows this fingerprint: " + }, + "desktopIntegrationDisabledTitle": { + "message": "Browser integration is not set up" + }, + "desktopIntegrationDisabledDesc": { + "message": "Browser integration is not set up in the Bitwarden desktop application. Please set it up in the settings within the desktop application." + }, + "startDesktopTitle": { + "message": "Start the Bitwarden desktop application" + }, + "startDesktopDesc": { + "message": "The Bitwarden desktop application needs to be started before unlock with biometrics can be used." + }, + "errorEnableBiometricTitle": { + "message": "Unable to set up biometrics" + }, + "errorEnableBiometricDesc": { + "message": "Action was canceled by the desktop application" + }, + "nativeMessagingInvalidEncryptionDesc": { + "message": "Desktop application invalidated the secure communication channel. Please retry this operation" + }, + "nativeMessagingInvalidEncryptionTitle": { + "message": "Desktop communication interrupted" + }, + "nativeMessagingWrongUserDesc": { + "message": "The desktop application is logged into a different account. Please ensure both applications are logged into the same account." + }, + "nativeMessagingWrongUserTitle": { + "message": "Account missmatch" + }, + "biometricsNotEnabledTitle": { + "message": "Biometrics not set up" + }, + "biometricsNotEnabledDesc": { + "message": "Browser biometrics requires desktop biometric to be set up in the settings first." + }, + "biometricsNotSupportedTitle": { + "message": "Biometrics not supported" + }, + "biometricsNotSupportedDesc": { + "message": "Browser biometrics is not supported on this device." + }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, + "nativeMessaginPermissionErrorTitle": { + "message": "Permission not provided" + }, + "nativeMessaginPermissionErrorDesc": { + "message": "Without permission to communicate with the Bitwarden Desktop Application we cannot provide biometrics in the browser extension. Please try again." + }, + "nativeMessaginPermissionSidebarTitle": { + "message": "Permission request error" + }, + "nativeMessaginPermissionSidebarDesc": { + "message": "This action cannot be done in the sidebar, please retry the action in the popup or popout." + }, + "personalOwnershipSubmitError": { + "message": "Due to an Enterprise Policy, you are restricted from saving items to your personal vault. Change the Ownership option to an organization and choose from available collections." + }, + "personalOwnershipPolicyInEffect": { + "message": "An organization policy is affecting your ownership options." + }, + "excludedDomains": { + "message": "Excluded domains" + }, + "excludedDomainsDesc": { + "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." + }, + "excludedDomainsInvalidDomain": { + "message": "$DOMAIN$ is not a valid domain", + "placeholders": { + "domain": { + "content": "$1", + "example": "googlecom" + } + } + }, + "send": { + "message": "Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "searchSends": { + "message": "Search Sends", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "addSend": { + "message": "Add Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeText": { + "message": "Text" + }, + "sendTypeFile": { + "message": "File" + }, + "allSends": { + "message": "All Sends", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "maxAccessCountReached": { + "message": "Max access count reached", + "description": "This text will be displayed after a Send has been accessed the maximum amount of times." + }, + "expired": { + "message": "Expired" + }, + "pendingDeletion": { + "message": "Pending deletion" + }, + "passwordProtected": { + "message": "Password protected" + }, + "copySendLink": { + "message": "Copy Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "removePassword": { + "message": "Remove Password" + }, + "delete": { + "message": "Delete" + }, + "removedPassword": { + "message": "Password removed" + }, + "deletedSend": { + "message": "Send deleted", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendLink": { + "message": "Send link", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "disabled": { + "message": "Disabled" + }, + "removePasswordConfirmation": { + "message": "Are you sure you want to remove the password?" + }, + "deleteSend": { + "message": "Delete Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "deleteSendConfirmation": { + "message": "Are you sure you want to delete this Send?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "editSend": { + "message": "Edit Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeHeader": { + "message": "What type of Send is this?", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendNameDesc": { + "message": "A friendly name to describe this Send.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendFileDesc": { + "message": "The file you want to send." + }, + "deletionDate": { + "message": "Deletion date" + }, + "deletionDateDesc": { + "message": "The Send will be permanently deleted on the specified date and time.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "expirationDate": { + "message": "Expiration date" + }, + "expirationDateDesc": { + "message": "If set, access to this Send will expire on the specified date and time.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "oneDay": { + "message": "1 day" + }, + "days": { + "message": "$DAYS$ days", + "placeholders": { + "days": { + "content": "$1", + "example": "2" + } + } + }, + "custom": { + "message": "Custom" + }, + "maximumAccessCount": { + "message": "Maximum Access Count" + }, + "maximumAccessCountDesc": { + "message": "If set, users will no longer be able to access this Send once the maximum access count is reached.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendPasswordDesc": { + "message": "Optionally require a password for users to access this Send.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendNotesDesc": { + "message": "Private notes about this Send.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendDisableDesc": { + "message": "Deactivate this Send so that no one can access it.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendShareDesc": { + "message": "Copy this Send's link to clipboard upon save.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTextDesc": { + "message": "The text you want to send." + }, + "sendHideText": { + "message": "Hide this Send's text by default.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "currentAccessCount": { + "message": "Current access count" + }, + "createSend": { + "message": "New Send", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "newPassword": { + "message": "New password" + }, + "sendDisabled": { + "message": "Send removed", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendDisabledWarning": { + "message": "Due to an enterprise policy, you are only able to delete an existing Send.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "createdSend": { + "message": "Send created", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "editedSend": { + "message": "Send saved", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendLinuxChromiumFileWarning": { + "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." + }, + "sendFirefoxFileWarning": { + "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." + }, + "sendSafariFileWarning": { + "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." + }, + "sendFileCalloutHeader": { + "message": "Before you start" + }, + "sendFirefoxCustomDatePopoutMessage1": { + "message": "To use a calendar style date picker", + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read '**To use a calendar style date picker ** click here to pop out your window.'" + }, + "sendFirefoxCustomDatePopoutMessage2": { + "message": "click here", + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To use a calendar style date picker **click here** to pop out your window.'" + }, + "sendFirefoxCustomDatePopoutMessage3": { + "message": "to pop out your window.", + "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To use a calendar style date picker click here **to pop out your window.**'" + }, + "expirationDateIsInvalid": { + "message": "The expiration date provided is not valid." + }, + "deletionDateIsInvalid": { + "message": "The deletion date provided is not valid." + }, + "expirationDateAndTimeRequired": { + "message": "An expiration date and time are required." + }, + "deletionDateAndTimeRequired": { + "message": "A deletion date and time are required." + }, + "dateParsingError": { + "message": "There was an error saving your deletion and expiration dates." + }, + "hideEmail": { + "message": "Hide my email address from recipients." + }, + "sendOptionsPolicyInEffect": { + "message": "One or more organization policies are affecting your Send options." + }, + "passwordPrompt": { + "message": "Master password re-prompt" + }, + "passwordConfirmation": { + "message": "Master password confirmation" + }, + "passwordConfirmationDesc": { + "message": "This action is protected. To continue, please re-enter your master password to verify your identity." + }, + "emailVerificationRequired": { + "message": "Email verification required" + }, + "emailVerificationRequiredDesc": { + "message": "You must verify your email to use this feature. You can verify your email in the web vault." + }, + "updatedMasterPassword": { + "message": "Updated master password" + }, + "updateMasterPassword": { + "message": "Update master password" + }, + "updateMasterPasswordWarning": { + "message": "Your master password was recently changed by an administrator in your organization. In order to access the vault, you must update it now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." + }, + "updateWeakMasterPasswordWarning": { + "message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." + }, + "resetPasswordPolicyAutoEnroll": { + "message": "Automatic enrollment" + }, + "resetPasswordAutoEnrollInviteWarning": { + "message": "This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password." + }, + "selectFolder": { + "message": "Select folder..." + }, + "ssoCompleteRegistration": { + "message": "In order to complete logging in with SSO, please set a master password to access and protect your vault." + }, + "hours": { + "message": "Hours" + }, + "minutes": { + "message": "Minutes" + }, + "vaultTimeoutPolicyInEffect": { + "message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + }, + "minutes": { + "content": "$2", + "example": "5" + } + } + }, + "vaultTimeoutPolicyWithActionInEffect": { + "message": "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is $HOURS$ hour(s) and $MINUTES$ minute(s). Your vault timeout action is set to $ACTION$.", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + }, + "minutes": { + "content": "$2", + "example": "5" + }, + "action": { + "content": "$3", + "example": "Lock" + } + } + }, + "vaultTimeoutActionPolicyInEffect": { + "message": "Your organization policies have set your vault timeout action to $ACTION$.", + "placeholders": { + "action": { + "content": "$1", + "example": "Lock" + } + } + }, + "vaultTimeoutTooLarge": { + "message": "Your vault timeout exceeds the restrictions set by your organization." + }, + "vaultExportDisabled": { + "message": "Vault export unavailable" + }, + "personalVaultExportPolicyInEffect": { + "message": "One or more organization policies prevents you from exporting your individual vault." + }, + "copyCustomFieldNameInvalidElement": { + "message": "Unable to identify a valid form element. Try inspecting the HTML instead." + }, + "copyCustomFieldNameNotUnique": { + "message": "No unique identifier found." + }, + "convertOrganizationEncryptionDesc": { + "message": "$ORGANIZATION$ is using SSO with a self-hosted key server. A master password is no longer required to log in for members of this organization.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "leaveOrganization": { + "message": "Leave organization" + }, + "removeMasterPassword": { + "message": "Remove master password" + }, + "removedMasterPassword": { + "message": "Master password removed" + }, + "leaveOrganizationConfirmation": { + "message": "Are you sure you want to leave this organization?" + }, + "leftOrganization": { + "message": "You have left the organization." + }, + "toggleCharacterCount": { + "message": "Toggle character count" + }, + "sessionTimeout": { + "message": "Your session has timed out. Please go back and try logging in again." + }, + "exportingPersonalVaultTitle": { + "message": "Exporting individual vault" + }, + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, + "error": { + "message": "Error" + }, + "regenerateUsername": { + "message": "Regenerate username" + }, + "generateUsername": { + "message": "Generate username" + }, + "usernameType": { + "message": "Username type" + }, + "plusAddressedEmail": { + "message": "Plus addressed email", + "description": "Username generator option that appends a random sub-address to the username. For example: address+subaddress@email.com" + }, + "plusAddressedEmailDesc": { + "message": "Use your email provider's sub-addressing capabilities." + }, + "catchallEmail": { + "message": "Catch-all email" + }, + "catchallEmailDesc": { + "message": "Use your domain's configured catch-all inbox." + }, + "random": { + "message": "Random" + }, + "randomWord": { + "message": "Random word" + }, + "websiteName": { + "message": "Website name" + }, + "whatWouldYouLikeToGenerate": { + "message": "What would you like to generate?" + }, + "passwordType": { + "message": "Password type" + }, + "service": { + "message": "Service" + }, + "forwardedEmail": { + "message": "Forwarded email alias" + }, + "forwardedEmailDesc": { + "message": "Generate an email alias with an external forwarding service." + }, + "hostname": { + "message": "Hostname", + "description": "Part of a URL." + }, + "apiAccessToken": { + "message": "API Access Token" + }, + "apiKey": { + "message": "API Key" + }, + "ssoKeyConnectorError": { + "message": "Key connector error: make sure key connector is available and working correctly." + }, + "premiumSubcriptionRequired": { + "message": "Premium subscription required" + }, + "organizationIsDisabled": { + "message": "Organization suspended." + }, + "disabledOrganizationFilterError": { + "message": "Items in suspended Organizations cannot be accessed. Contact your Organization owner for assistance." + }, + "loggingInTo": { + "message": "Logging in to $DOMAIN$", + "placeholders": { + "domain": { + "content": "$1", + "example": "example.com" + } + } + }, + "settingsEdited": { + "message": "Settings have been edited" + }, + "environmentEditedClick": { + "message": "Click here" + }, + "environmentEditedReset": { + "message": "to reset to pre-configured settings" + }, + "serverVersion": { + "message": "Server version" + }, + "selfHostedServer": { + "message": "self-hosted" + }, + "thirdParty": { + "message": "Third-party" + }, + "thirdPartyServerMessage": { + "message": "Connected to third-party server implementation, $SERVERNAME$. Please verify bugs using the official server, or report them to the third-party server.", + "placeholders": { + "servername": { + "content": "$1", + "example": "ThirdPartyServerName" + } + } + }, + "lastSeenOn": { + "message": "last seen on: $DATE$", + "placeholders": { + "date": { + "content": "$1", + "example": "Jun 15, 2015" + } + } + }, + "loginWithMasterPassword": { + "message": "Log in with master password" + }, + "loggingInAs": { + "message": "Logging in as" + }, + "notYou": { + "message": "Not you?" + }, + "newAroundHere": { + "message": "New around here?" + }, + "rememberEmail": { + "message": "Remember email" + }, + "loginWithDevice": { + "message": "Log in with device" + }, + "loginWithDeviceEnabledInfo": { + "message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?" + }, + "fingerprintPhraseHeader": { + "message": "Fingerprint phrase" + }, + "fingerprintMatchInfo": { + "message": "Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device." + }, + "resendNotification": { + "message": "Resend notification" + }, + "viewAllLoginOptions": { + "message": "View all log in options" + }, + "notificationSentDevice": { + "message": "A notification has been sent to your device." + }, + "loginInitiated": { + "message": "Login initiated" + }, + "exposedMasterPassword": { + "message": "Exposed Master Password" + }, + "exposedMasterPasswordDesc": { + "message": "Password found in a data breach. Use a unique password to protect your account. Are you sure you want to use an exposed password?" + }, + "weakAndExposedMasterPassword": { + "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?" + }, + "checkForBreaches": { + "message": "Check known data breaches for this password" + }, + "important": { + "message": "Important:" + }, + "masterPasswordHint": { + "message": "Your master password cannot be recovered if you forget it!" + }, + "characterMinimum": { + "message": "$LENGTH$ character minimum", + "placeholders": { + "length": { + "content": "$1", + "example": "14" + } + } + }, + "autofillPageLoadPolicyActivated": { + "message": "Your organization policies have turned on auto-fill on page load." + }, + "howToAutofill": { + "message": "How to auto-fill" + }, + "autofillSelectInfoWithCommand": { + "message": "Select an item from this page or use the shortcut: $COMMAND$", + "placeholders": { + "command": { + "content": "$1", + "example": "CTRL+Shift+L" + } + } + }, + "autofillSelectInfoWithoutCommand": { + "message": "Select an item from this page or set a shortcut in settings." + }, + "gotIt": { + "message": "Got it" + }, + "autofillSettings": { + "message": "Auto-fill settings" + }, + "autofillShortcut": { + "message": "Auto-fill keyboard shortcut" + }, + "autofillShortcutNotSet": { + "message": "The auto-fill shortcut is not set. Change this in the browser's settings." + }, + "autofillShortcutText": { + "message": "The auto-fill shortcut is: $COMMAND$. Change this in the browser's settings.", + "placeholders": { + "command": { + "content": "$1", + "example": "CTRL+Shift+L" + } + } + }, + "autofillShortcutTextSafari": { + "message": "Default auto-fill shortcut: $COMMAND$.", + "placeholders": { + "command": { + "content": "$1", + "example": "CTRL+Shift+L" + } + } + }, + "loggingInOn": { + "message": "Logging in on" + }, + "opensInANewWindow": { + "message": "Opens in a new window" + }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, + "eu": { + "message": "EU", + "description": "European Union" + }, + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." + } +} diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 69d26333a84..22330901579 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API server URL" }, "webVaultUrl": { "message": "Web vault server URL" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Item restored" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 8ede2fb9389..43a19478bb0 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-utfylling" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generer et passord (kopiert)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Ingen samsvarende innlogginger." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Lås opp hvelvet ditt" }, @@ -338,6 +362,9 @@ "other": { "message": "Annet" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Gi denne utvidelsen en vurdering" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Ja, oppdater nå" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Vis alternativer for kontekstmeny" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Egenskapen er utilgjengelig" }, - "updateKey": { - "message": "Du kan ikke bruke denne funksjonen før du oppdaterer krypteringsnøkkelen din." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium-medlemskap" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Gjenopprett objekt" }, - "restoreItemConfirmation": { - "message": "Er du sikker på at du vil gjenopprette dette elementet?" - }, "restoredItem": { "message": "Gjenopprettet objekt" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Biometri i nettleseren støttes ikke på denne enheten." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Tillatelse er ikke gitt" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Eksporterer personlig hvelv" }, - "exportingPersonalVaultDescription": { - "message": "Bare de personlige hvelv-elementene som er knyttet til $EMAIL$ vil bli eksportert. Organisasjonshvelvets elementer vil ikke bli inkludert.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server Versjon" }, - "selfHosted": { - "message": "Selvbetjent" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Tredjepart" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "Et varsel er sendt til enheten din." }, - "logInInitiated": { - "message": "Innlogging startet" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Eksponert hovedpassord" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 69d26333a84..22330901579 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API server URL" }, "webVaultUrl": { "message": "Web vault server URL" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Item restored" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 68a6ba26b1f..630510f6723 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-invullen" }, + "autoFillLogin": { + "message": "Login automatisch invullen" + }, + "autoFillCard": { + "message": "Kaart automatisch invullen" + }, + "autoFillIdentity": { + "message": "Identiteit automatisch invullen" + }, "generatePasswordCopied": { "message": "Wachtwoord genereren (op klembord)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Geen overeenkomstige logins." }, + "noCards": { + "message": "Geen kaarten" + }, + "noIdentities": { + "message": "Geen identiteiten" + }, + "addLoginMenu": { + "message": "Login toevoegen" + }, + "addCardMenu": { + "message": "Kaart toevoegen" + }, + "addIdentityMenu": { + "message": "Identiteit toevoegen" + }, "unlockVaultMenu": { "message": "Ontgrendel je kluis" }, @@ -338,6 +362,9 @@ "other": { "message": "Overig" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Stel een ontgrendelingsmethode in om je kluis time-out actie te wijzigen." + }, "rateExtension": { "message": "Deze extensie beoordelen" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Ja, nu bijwerken" }, + "notificationUnlockDesc": { + "message": "Ontgrendel je Bitwarden-kluis om het auto-invulverzoek te voltooien." + }, + "notificationUnlock": { + "message": "Ontgrendelen" + }, "enableContextMenuItem": { "message": "Contextmenu-opties weergeven" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Functionaliteit niet beschikbaar" }, - "updateKey": { - "message": "Je kunt deze functie pas gebruiken als je je encryptiesleutel bijwerkt." + "encryptionKeyMigrationRequired": { + "message": "Migratie van de encryptiesleutel vereist. Login via de website om je sleutel te bij te werken." }, "premiumMembership": { "message": "Premium-abonnement" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Item herstellen" }, - "restoreItemConfirmation": { - "message": "Weet je zeker dat je dit item wilt herstellen?" - }, "restoredItem": { "message": "Hersteld item" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Dit apparaat ondersteunt geen browserbiometrie." }, + "biometricsFailedTitle": { + "message": "Biometrie mislukt" + }, + "biometricsFailedDesc": { + "message": "Kan biometrische authenticatie niet voltooien, gebruik een hoofdwachtwoord of log uit. Neem contact op met de ondersteuning van Bitwarden als dit blijft aanhouden." + }, "nativeMessaginPermissionErrorTitle": { "message": "Toestemming niet verleend" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Persoonlijke kluis exporteren" }, - "exportingPersonalVaultDescription": { - "message": "Exporteert alleen de persoonlijke kluis-items gerelateerd aan $EMAIL$. Geen kluis-items van de organisatie.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Serverversie" }, - "selfHosted": { - "message": "Zelfgehost" + "selfHostedServer": { + "message": "zelfgehost" }, "thirdParty": { "message": "van derden" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Er is een melding naar je apparaat verzonden." }, - "logInInitiated": { + "loginInitiated": { "message": "Inloggen gestart" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Regio" + "loggingInOn": { + "message": "Inloggen op" }, "opensInANewWindow": { "message": "Opent in een nieuw venster" }, + "deviceApprovalRequired": { + "message": "Apparaattoestemming vereist. Kies een goedkeuringsoptie hieronder:" + }, + "rememberThisDevice": { + "message": "Dit apparaat onthouden" + }, + "uncheckIfPublicDevice": { + "message": "Uitschakelen als je openbaar apparaat gebruikt" + }, + "approveFromYourOtherDevice": { + "message": "Goedkeuren vanaf je andere apparaat" + }, + "requestAdminApproval": { + "message": "Goedkeuring van beheerder vragen" + }, + "approveWithMasterPassword": { + "message": "Goedkeuren met hoofdwachtwoord" + }, + "ssoIdentifierRequired": { + "message": "Organisatie SSO-identificatie vereist." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Toegang geweigerd. Je hebt geen toestemming om deze pagina te bekijken." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account succesvol aangemaakt!" + }, + "adminApprovalRequested": { + "message": "Goedkeuring van beheerder aangevraagd" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Je verzoek is naar je beheerder verstuurd." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Je krijgt een melding zodra je bent goedgekeurd." + }, + "troubleLoggingIn": { + "message": "Problemen met inloggen?" + }, + "loginApproved": { + "message": "Inloggen goedgekeurd" + }, + "userEmailMissing": { + "message": "Gebruikerse-mailadres ontbreekt" + }, + "deviceTrusted": { + "message": "Vertrouwd apparaat" + }, + "inputRequired": { + "message": "Invoer vereist." + }, + "required": { + "message": "vereist" + }, + "search": { + "message": "Zoeken" + }, + "inputMinLength": { + "message": "Invoer moet minimaal $COUNT$ tekens lang zijn.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Invoer mag niet meer dan $COUNT$ tekens lang zijn.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "De volgende tekens zijn niet toegestaan: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Invoer moet minimaal $MIN$ zijn.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Invoer mag niet hoger zijn dan $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "Een of meer e-mailadressen zijn ongeldig" + }, + "inputTrimValidator": { + "message": "Invoer mag niet alleen witruimte bevatten.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Invoer is geen e-mailadres." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ veld(en) hierboven hebben je aandacht nodig.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Selecteer --" + }, + "multiSelectPlaceholder": { + "message": "-- Type om te filteren --" + }, + "multiSelectLoading": { + "message": "Opties ophalen..." + }, + "multiSelectNotFound": { + "message": "Geen items gevonden" + }, + "multiSelectClearAll": { + "message": "Alles wissen" + }, + "plusNMore": { + "message": "+ $QUANTITY$ meer", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "In-/Uitklappen", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Aliasdomein" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 69d26333a84..22330901579 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API server URL" }, "webVaultUrl": { "message": "Web vault server URL" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Item restored" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 69d26333a84..22330901579 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API server URL" }, "webVaultUrl": { "message": "Web vault server URL" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Item restored" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index ca32eb73aa6..5b16c13ef14 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Autouzupełnianie" }, + "autoFillLogin": { + "message": "Autouzupełnianie logowania" + }, + "autoFillCard": { + "message": "Autouzupełnianie karty" + }, + "autoFillIdentity": { + "message": "Autouzupełnianie tożsamości" + }, "generatePasswordCopied": { "message": "Wygeneruj hasło (do schowka)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Brak pasujących danych logowania" }, + "noCards": { + "message": "Brak kart" + }, + "noIdentities": { + "message": "Brak tożsamości" + }, + "addLoginMenu": { + "message": "Dodaj dane logowania" + }, + "addCardMenu": { + "message": "Dodaj kartę" + }, + "addIdentityMenu": { + "message": "Dodaj tożsamość" + }, "unlockVaultMenu": { "message": "Odblokuj sejf" }, @@ -338,6 +362,9 @@ "other": { "message": "Inne" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Ustaw metodę odblokowania, aby zmienić czas blokowania sejfu." + }, "rateExtension": { "message": "Oceń rozszerzenie" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Zaktualizuj" }, + "notificationUnlockDesc": { + "message": "Odblokuj swój sejf Bitwarden, aby ukończyć żądanie autouzupełniania." + }, + "notificationUnlock": { + "message": "Odblokuj" + }, "enableContextMenuItem": { "message": "Pokaż opcje menu kontekstowego" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Funkcja jest niedostępna" }, - "updateKey": { - "message": "Nie możesz używać tej funkcji, dopóki nie zaktualizujesz klucza szyfrowania." + "encryptionKeyMigrationRequired": { + "message": "Wymagana jest migracja klucza szyfrowania. Zaloguj się przez sejf internetowy, aby zaktualizować klucz szyfrowania." }, "premiumMembership": { "message": "Konto Premium" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Przywróć element" }, - "restoreItemConfirmation": { - "message": "Czy na pewno chcesz przywrócić ten element?" - }, "restoredItem": { "message": "Element został przywrócony" }, @@ -1471,7 +1501,7 @@ "message": "Formularz jest hostowany przez inną domenę niż zapisany adres URI dla tego loginu. Wybierz OK, aby i tak automatycznie wypełnić lub anuluj aby zatrzymać." }, "autofillIframeWarningTip": { - "message": "To prevent this warning in the future, save this URI, $HOSTNAME$, to your Bitwarden login item for this site.", + "message": "Aby zapobiec temu ostrzeżeniu w przyszłości, zapisz ten URI, $HOSTNAME$, dla tej witryny.", "placeholders": { "hostname": { "content": "$1", @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Dane biometryczne przeglądarki nie są obsługiwane na tym urządzeniu." }, + "biometricsFailedTitle": { + "message": "Dane biometryczne są błędne" + }, + "biometricsFailedDesc": { + "message": "Dane biometryczne nie mogę być użyte, rozważ użycie hasła głównego lub wylogowanie. Jeśli się to powtarza, skontaktuj się z pomocą techniczną Bitwarden." + }, "nativeMessaginPermissionErrorTitle": { "message": "Uprawnienie nie zostało przyznane" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Eksportowanie osobistego sejfu" }, - "exportingPersonalVaultDescription": { - "message": "Tylko osobiste elementy sejfu powiązane z adresem $EMAIL$ zostaną wyeksportowane. Elementy sejfu należące do organizacji nie będą uwzględnione.", + "exportingIndividualVaultDescription": { + "message": "Z sejfu zostaną wyeksportowane tylko elementy powiązane z $EMAIL$. Elementy z sejfu organizacji nie będą uwzględnione. Tylko informacje o elemencie zostaną wyeksportowane i nie będą zawierać powiązanych załączników.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Wersja serwera" }, - "selfHosted": { - "message": "Samodzielnie hostowany" + "selfHostedServer": { + "message": "samodzielnie hostowany" }, "thirdParty": { "message": "Inny dostawca" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Powiadomienie zostało wysłane na urządzenie." }, - "logInInitiated": { + "loginInitiated": { "message": "Logowanie rozpoczęte" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logowanie do" }, "opensInANewWindow": { "message": "Otwiera w nowym oknie" }, + "deviceApprovalRequired": { + "message": "Wymagane zatwierdzenie urządzenia. Wybierz opcję zatwierdzenia poniżej:" + }, + "rememberThisDevice": { + "message": "Zapamiętaj to urządzenie" + }, + "uncheckIfPublicDevice": { + "message": "Odznacz jeśli używasz publicznego urządzenia" + }, + "approveFromYourOtherDevice": { + "message": "Zatwierdź z innego twojego urządzenia" + }, + "requestAdminApproval": { + "message": "Poproś administratora o zatwierdzenie" + }, + "approveWithMasterPassword": { + "message": "Zatwierdź przy użyciu hasła głównego" + }, + "ssoIdentifierRequired": { + "message": "Identyfikator organizacji jest wymagany." + }, "eu": { "message": "UE", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Odmowa dostępu. Nie masz uprawnień do przeglądania tej strony." + }, + "general": { + "message": "Ogólne" + }, + "display": { + "message": "Wyświetl" + }, + "accountSuccessfullyCreated": { + "message": "Konto pomyślnie utworzone!" + }, + "adminApprovalRequested": { + "message": "Poproszono administratora o zatwierdzenie" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Twoja prośba została wysłana do Twojego administratora." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Zostaniesz powiadomiony po zatwierdzeniu." + }, + "troubleLoggingIn": { + "message": "Problem z zalogowaniem?" + }, + "loginApproved": { + "message": "Logowanie zatwierdzone" + }, + "userEmailMissing": { + "message": "Brak adresu e-mail użytkownika" + }, + "deviceTrusted": { + "message": "Zaufano urządzeniu" + }, + "inputRequired": { + "message": "Dane wejściowe są wymagane." + }, + "required": { + "message": "wymagane" + }, + "search": { + "message": "Szukaj" + }, + "inputMinLength": { + "message": "Dane wejściowe muszą zawierać co najmniej $COUNT$ znaki(-ów).", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Dane wejściowe nie mogą przekraczać długości $COUNT$ znaków.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Następujące znaki są niedozwolone: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Wartość wejściowa musi wynosić co najmniej $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Wartość wejściowa nie może przekraczać $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "Co najmniej 1 e-mail jest nieprawidłowy" + }, + "inputTrimValidator": { + "message": "Tekst nie może zawierać tylko spacji.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Dane wejściowe nie są adresem e-mail." + }, + "fieldsNeedAttention": { + "message": "Pola powyżej wymagające Twojej uwagi: $COUNT$.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Wybierz --" + }, + "multiSelectPlaceholder": { + "message": "-- Pisz, aby filtrować --" + }, + "multiSelectLoading": { + "message": "Pobieranie opcji..." + }, + "multiSelectNotFound": { + "message": "Nie znaleziono żadnych pozycji" + }, + "multiSelectClearAll": { + "message": "Wyczyść wszystko" + }, + "plusNMore": { + "message": "+ $QUANTITY$ więcej", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Podmenu" + }, + "toggleCollapse": { + "message": "Zwiń/rozwiń", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Domena aliasu" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Elementy z pytaniem o hasło głównege nie mogą być automatycznie wypełniane przy wczytywaniu strony. Automatyczne wypełnianie po wczytywania strony zostało wyłączone.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Automatyczne wypełnianie przy wczytywaniu strony zostało ustawione, aby używać ustawień domyślnych.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Wyłącz prośbę o podanie hasła głównego, aby edytować to pole", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 87585d561d0..7578ae170ca 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Autopreencher" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Gerar Senha (copiada)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Sem credenciais correspondentes." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Desbloqueie seu cofre" }, @@ -202,7 +226,7 @@ "message": "Explore os fóruns da comunidade" }, "contactSupport": { - "message": "Contact Bitwarden support" + "message": "Contate o suporte Bitwarden" }, "sync": { "message": "Sincronizar" @@ -338,6 +362,9 @@ "other": { "message": "Outros" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Avaliar a Extensão" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Atualizar" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Mostrar opções de menu de contexto" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Funcionalidade Indisponível" }, - "updateKey": { - "message": "Você não pode usar este recurso, até você atualizar sua chave de criptografia." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Assinatura Premium" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restaurar Item" }, - "restoreItemConfirmation": { - "message": "Você tem certeza que deseja restaurar esse item?" - }, "restoredItem": { "message": "Item Restaurado" }, @@ -1489,7 +1519,7 @@ "message": "New master password" }, "confirmNewMasterPass": { - "message": "Confirm new master password" + "message": "Confirme a nova senha mestre" }, "masterPasswordPolicyInEffect": { "message": "Uma ou mais políticas da organização exigem que a sua senha mestra cumpra aos seguintes requisitos:" @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "A biometria com o navegador não é suportada neste dispositivo." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permissão não fornecida" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exportando o Cofre Pessoal" }, - "exportingPersonalVaultDescription": { - "message": "Apenas os itens pessoais do cofre associados com $EMAIL$ serão exportados. Os itens do cofre da organização não serão incluídos.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Versão do servidor" }, - "selfHosted": { - "message": "Auto-hospedado" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Terceiros" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "Uma notificação foi enviada para seu dispositivo." }, - "logInInitiated": { - "message": "Login iniciado" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Senha Mestra comprometida" @@ -2189,7 +2225,7 @@ } }, "autofillSelectInfoWithoutCommand": { - "message": "Select an item from this page or set a shortcut in settings." + "message": "Selecione um item desta página ou defina um atalho nas configurações." }, "gotIt": { "message": "Entendi" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { - "message": "Opens in a new window" + "message": "Abrir em uma nova janela" + }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Acesso negado. Você não tem permissão para ver esta página." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index a151ff0fa88..1495b64e453 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -3,7 +3,7 @@ "message": "Bitwarden" }, "extName": { - "message": "Bitwarden - Free Password Manager", + "message": "Bitwarden - Gestor de Palavras-passe", "description": "Extension name, MUST be less than 40 characters (Safari restriction)" }, "extDesc": { @@ -20,7 +20,7 @@ "message": "Iniciar sessão" }, "enterpriseSingleSignOn": { - "message": "Início de Sessão Único da Empresa" + "message": "Início de sessão único para empresas" }, "cancel": { "message": "Cancelar" @@ -32,10 +32,10 @@ "message": "Submeter" }, "emailAddress": { - "message": "Endereço de Email" + "message": "Endereço de e-mail" }, "masterPass": { - "message": "Palavra-passe Mestra" + "message": "Palavra-passe mestra" }, "masterPassDesc": { "message": "A palavra-passe mestra é a palavra-passe que utiliza para aceder ao seu cofre. É muito importante que não se esqueça da sua palavra-passe mestra. Não há forma de recuperar a palavra-passe no caso de a esquecer." @@ -44,10 +44,10 @@ "message": "Uma dica da palavra-passe mestra pode ajudá-lo a lembrar-se da sua palavra-passe, caso se esqueça dela." }, "reTypeMasterPass": { - "message": "Re-digite a palavra-passe mestra" + "message": "Reintroduza a palavra-passe mestra" }, "masterPassHint": { - "message": "Dica da Palavra-passe Mestra (opcional)" + "message": "Dica da palavra-passe mestra (opcional)" }, "tab": { "message": "Separador" @@ -68,10 +68,10 @@ "message": "Definições" }, "currentTab": { - "message": "Separador Atual" + "message": "Separador atual" }, "copyPassword": { - "message": "Copiar Palavra-passe" + "message": "Copiar palavra-passe" }, "copyNote": { "message": "Copiar nota" @@ -83,7 +83,7 @@ "message": "Copiar nome de utilizador" }, "copyNumber": { - "message": "Copiar Número" + "message": "Copiar número" }, "copySecurityCode": { "message": "Copiar código de segurança" @@ -91,35 +91,59 @@ "autoFill": { "message": "Preenchimento automático" }, + "autoFillLogin": { + "message": "Preenchimento automático da credencial" + }, + "autoFillCard": { + "message": "Preenchimento automático do cartão" + }, + "autoFillIdentity": { + "message": "Preenchimento automático da identidade" + }, "generatePasswordCopied": { - "message": "Gerar Palavra-passe (copiada)" + "message": "Gerar palavra-passe (copiada)" }, "copyElementIdentifier": { "message": "Copiar nome do campo personalizado" }, "noMatchingLogins": { - "message": "Sem credencias correspondidas." + "message": "Sem credenciais correspondentes" + }, + "noCards": { + "message": "Sem cartões" + }, + "noIdentities": { + "message": "Sem identidades" + }, + "addLoginMenu": { + "message": "Adicionar credencial" + }, + "addCardMenu": { + "message": "Adicionar cartão" + }, + "addIdentityMenu": { + "message": "Adicionar identidade" }, "unlockVaultMenu": { - "message": "Desbloqueie o seu cofre" + "message": "Desbloquear o cofre" }, "loginToVaultMenu": { "message": "Inicie sessão para abrir o seu cofre" }, "autoFillInfo": { - "message": "Não existem credenciais disponíveis para auto-preencher para o separador de navegador atual." + "message": "Não existem credenciais disponíveis para preenchimento automático no separador atual do navegador." }, "addLogin": { "message": "Adicionar uma credencial" }, "addItem": { - "message": "Adicionar Item" + "message": "Adicionar item" }, "passwordHint": { "message": "Dica da palavra-passe" }, "enterEmailToGetHint": { - "message": "Introduza o endereço de email da sua conta para receber a dica da sua palavra-passe mestra." + "message": "Introduza o endereço de e-mail da sua conta para receber a dica da sua palavra-passe mestra." }, "getMasterPasswordHint": { "message": "Obter dica da palavra-passe mestra" @@ -131,13 +155,13 @@ "message": "Enviar um código de verificação para o seu e-mail" }, "sendCode": { - "message": "Enviar o código" + "message": "Enviar código" }, "codeSent": { "message": "Código enviado" }, "verificationCode": { - "message": "Código de Verificação" + "message": "Código de verificação" }, "confirmIdentity": { "message": "Confirme a sua identidade para continuar." @@ -146,24 +170,24 @@ "message": "Conta" }, "changeMasterPassword": { - "message": "Alterar Palavra-passe Mestra" + "message": "Alterar palavra-passe mestra" }, "fingerprintPhrase": { "message": "Frase de impressão digital", "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." }, "yourAccountsFingerprint": { - "message": "A frase de impressão digital da sua conta", + "message": "Frase de impressão digital da sua conta", "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." }, "twoStepLogin": { "message": "Verificação de dois passos" }, "logOut": { - "message": "Terminar Sessão" + "message": "Terminar sessão" }, "about": { - "message": "Acerca" + "message": "Acerca de" }, "version": { "message": "Versão" @@ -175,13 +199,13 @@ "message": "Mover" }, "addFolder": { - "message": "Adicionar Pasta" + "message": "Adicionar pasta" }, "name": { "message": "Nome" }, "editFolder": { - "message": "Editar Pasta" + "message": "Editar pasta" }, "deleteFolder": { "message": "Eliminar pasta" @@ -208,7 +232,7 @@ "message": "Sincronizar" }, "syncVaultNow": { - "message": "Sincronizar cofre agora" + "message": "Sincronizar o cofre agora" }, "lastSync": { "message": "Última sincronização:" @@ -254,7 +278,7 @@ "message": "Números (0-9)" }, "specialCharacters": { - "message": "Caracteres Especiais (!@#$%^&*)" + "message": "Caracteres especiais (!@#$%^&*)" }, "numWords": { "message": "Número de palavras" @@ -273,7 +297,7 @@ "message": "Números mínimos" }, "minSpecial": { - "message": "Especiais minímos" + "message": "Caracteres especiais minímos" }, "avoidAmbChar": { "message": "Evitar caracteres ambíguos" @@ -291,7 +315,7 @@ "message": "Não existem itens para listar." }, "itemInformation": { - "message": "Informação do item" + "message": "Informações do item" }, "username": { "message": "Nome de utilizador" @@ -300,7 +324,7 @@ "message": "Palavra-passe" }, "passphrase": { - "message": "Frase-passe" + "message": "Frase de acesso" }, "favorite": { "message": "Favorito" @@ -318,7 +342,7 @@ "message": "Pasta" }, "deleteItem": { - "message": "Apagar item" + "message": "Eliminar item" }, "viewItem": { "message": "Ver item" @@ -327,7 +351,7 @@ "message": "Iniciar" }, "website": { - "message": "Website" + "message": "Site" }, "toggleVisibility": { "message": "Alternar visibilidade" @@ -338,20 +362,23 @@ "other": { "message": "Outros" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Configure um método de desbloqueio para alterar a ação de tempo limite do seu cofre." + }, "rateExtension": { "message": "Avaliar a extensão" }, "rateExtensionDesc": { - "message": "Por favor considere ajudar-nos com uma boa análise!" + "message": "Por favor, considere ajudar-nos com uma boa avaliação!" }, "browserNotSupportClipboard": { - "message": "O seu navegador web não suporta cópia fácil da área de transferência. Em alternativa, copie manualmente." + "message": "O seu navegador Web não suporta a cópia fácil da área de transferência. Em vez disso, copie manualmente." }, "verifyIdentity": { "message": "Verificar identidade" }, "yourVaultIsLocked": { - "message": "O seu cofre está bloqueado. Verifique a sua palavra-passe mestra para continuar." + "message": "O seu cofre está bloqueado. Verifique a sua identidade para continuar." }, "unlock": { "message": "Desbloquear" @@ -373,7 +400,7 @@ "message": "Palavra-passe mestra inválida" }, "vaultTimeout": { - "message": "Expiração do cofre" + "message": "Tempo limite do cofre" }, "lockNow": { "message": "Bloquear agora" @@ -412,10 +439,10 @@ "message": "4 horas" }, "onLocked": { - "message": "Quando o sistema está bloqueado" + "message": "No bloqueio do sistema" }, "onRestart": { - "message": "Ao reiniciar o sistema" + "message": "Ao reiniciar o navegador" }, "never": { "message": "Nunca" @@ -427,10 +454,10 @@ "message": "Ocorreu um erro" }, "emailRequired": { - "message": "O endereço de email é requerido." + "message": "É necessário o endereço de e-mail." }, "invalidEmail": { - "message": "Endereço de email inválido." + "message": "Endereço de e-mail inválido." }, "masterPasswordRequired": { "message": "É necessária a palavra-passe mestra." @@ -439,7 +466,7 @@ "message": "É necessário reescrever a palavra-passe mestra." }, "masterPasswordMinlength": { - "message": "Master password must be at least $VALUE$ characters long.", + "message": "A palavra-passe mestra deve ter pelo menos $VALUE$ caracteres.", "description": "The Master Password must be at least a specific number of characters long.", "placeholders": { "value": { @@ -452,19 +479,19 @@ "message": "A confirmação da palavra-passe mestra não corresponde." }, "newAccountCreated": { - "message": "A sua nova conta foi criada! Agora pode iniciar sessão." + "message": "A sua nova conta foi criada! Pode agora iniciar sessão." }, "masterPassSent": { - "message": "Enviámos-lhe um email com a dica da sua palavra-passe mestra." + "message": "Enviámos-lhe um e-mail com a dica da sua palavra-passe mestra." }, "verificationCodeRequired": { - "message": "O código de verificação é requerido." + "message": "É necessário o código de verificação." }, "invalidVerificationCode": { "message": "Código de verificação inválido" }, "valueCopied": { - "message": "$VALUE$ copiado(a)", + "message": "$VALUE$ copiado", "description": "Value has been copied to the clipboard.", "placeholders": { "value": { @@ -474,7 +501,7 @@ } }, "autofillError": { - "message": "Não é possível auto-preencher o item selecionado nesta página. Em alternativa, copie e cole a informação." + "message": "Não é possível preencher automaticamente o item selecionado nesta página. Em vez disso, copie e cole as informações." }, "loggedOut": { "message": "Sessão terminada" @@ -495,7 +522,7 @@ "message": "Ocorreu um erro inesperado." }, "nameRequired": { - "message": "O nome é requerido." + "message": "É necessário o nome." }, "addedFolder": { "message": "Pasta adicionada" @@ -504,13 +531,13 @@ "message": "Alterar palavra-passe mestra" }, "changeMasterPasswordConfirmation": { - "message": "Pode alterar a sua palavra-passe mestra no cofre web bitwarden.com. Pretende visitar o website agora?" + "message": "Pode alterar o seu endereço de e-mail no cofre do site bitwarden.com. Deseja visitar o site agora?" }, "twoStepLoginConfirmation": { "message": "A verificação de dois passos torna a sua conta mais segura, exigindo que verifique o seu início de sessão com outro dispositivo, como uma chave de segurança, aplicação de autenticação, SMS, chamada telefónica ou e-mail. A verificação de dois passos pode ser configurada em bitwarden.com. Pretende visitar o site agora?" }, "editedFolder": { - "message": "Pasta editada" + "message": "Pasta guardada" }, "deleteFolderConfirmation": { "message": "Tem a certeza de que pretende eliminar esta pasta?" @@ -522,13 +549,13 @@ "message": "Tutorial de introdução" }, "gettingStartedTutorialVideo": { - "message": "Veja o nosso tutorial de introdução e saiba como tirar o máximo partido da extensão de navegador." + "message": "Veja o nosso tutorial de introdução para saber como tirar o máximo partido da extensão do navegador." }, "syncingComplete": { - "message": "Sincronização completada" + "message": "Sincronização concluída" }, "syncingFailed": { - "message": "Sincronização falhada" + "message": "Falha na sincronização" }, "passwordCopied": { "message": "Palavra-passe copiada" @@ -553,101 +580,107 @@ "message": "Item adicionado" }, "editedItem": { - "message": "Item editado" + "message": "Item guardado" }, "deleteItemConfirmation": { - "message": "Tem a certeza de que pretende apagar este item?" + "message": "Tem a certeza de que pretende eliminar este item?" }, "deletedItem": { - "message": "Item enviado para o lixo" + "message": "Item movido para o lixo" }, "overwritePassword": { - "message": "Sobreescrever palavra-passe" + "message": "Substituir palavra-passe" }, "overwritePasswordConfirmation": { - "message": "Tem a certeza de que pretende sobreescrever a palavra-passe atual?" + "message": "Tem a certeza de que pretende substituir a palavra-passe atual?" }, "overwriteUsername": { - "message": "Sobrescrever nome de utilizador" + "message": "Substituir nome de utilizador" }, "overwriteUsernameConfirmation": { - "message": "Tem a certeza de que deseja sobrescrever o nome de utilizador atual?" + "message": "Tem a certeza de que pretende substituir o nome de utilizador atual?" }, "searchFolder": { - "message": "Pesquisar pasta" + "message": "Procurar na pasta" }, "searchCollection": { - "message": "Pesquisar coleção" + "message": "Procurar na coleção" }, "searchType": { - "message": "Pesquisar tipo" + "message": "Procurar no tipo" }, "noneFolder": { - "message": "Em nenhuma pasta", + "message": "Sem pasta", "description": "This is the folder for uncategorized items" }, "enableAddLoginNotification": { - "message": "Ask to add login" + "message": "Pedir para adicionar credencial" }, "addLoginNotificationDesc": { - "message": "A \"notificação de adicionar credencial\" solicita-lhe automaticamente a guardar novas credenciais para o seu cofre quando inicia sessão nas mesmas pela primeira vez." + "message": "Pedir para adicionar um item se não o encontrar no seu cofre." }, "showCardsCurrentTab": { - "message": "Show cards on Tab page" + "message": "Mostrar cartões na página Separador" }, "showCardsCurrentTabDesc": { - "message": "List card items on the Tab page for easy auto-fill." + "message": "Listar itens de cartões na página Separador para facilitar o preenchimento automático." }, "showIdentitiesCurrentTab": { - "message": "Show identities on Tab page" + "message": "Mostrar identidades na página Separador" }, "showIdentitiesCurrentTabDesc": { - "message": "List identity items on the Tab page for easy auto-fill." + "message": "Listar itens de identidades na página Separador para facilitar o preenchimento automático." }, "clearClipboard": { "message": "Limpar área de transferência", "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "clearClipboardDesc": { - "message": "Limpar automaticamente valores copiados da sua área de transferência.", + "message": "Limpar automaticamente os valores copiados da sua área de transferência.", "description": "Clipboard is the operating system thing where you copy/paste data to on your device." }, "notificationAddDesc": { - "message": "Deve o Bitwarden memorizar esta palavra-passe para si?" + "message": "Deve o Bitwarden memorizar esta palavra-passe por si?" }, "notificationAddSave": { - "message": "Sim, guardar agora" + "message": "Guardar" }, "enableChangedPasswordNotification": { - "message": "Ask to update existing login" + "message": "Pedir para atualizar credencial existente" }, "changedPasswordNotificationDesc": { - "message": "Ask to update a login's password when a change is detected on a website." + "message": "Pedir para atualizar a palavra-passe de uma credencial quando for detetada uma alteração num site." }, "notificationChangeDesc": { "message": "Pretende atualizar esta palavra-passe no Bitwarden?" }, "notificationChangeSave": { - "message": "Sim, atualizar agora" + "message": "Atualizar" + }, + "notificationUnlockDesc": { + "message": "Desbloqueie o seu cofre Bitwarden para completar o pedido de preenchimento automático." + }, + "notificationUnlock": { + "message": "Desbloquear" }, "enableContextMenuItem": { - "message": "Show context menu options" + "message": "Mostrar opções do menu de contexto" }, "contextMenuItemDesc": { - "message": "Use a secondary click to access password generation and matching logins for the website. " + "message": "Utilize um clique secundário para aceder à geração de palavras-passe e às credenciais correspondentes do site. " }, "defaultUriMatchDetection": { "message": "Deteção de correspondência de URI predefinida", "description": "Default URI match detection for auto-fill." }, "defaultUriMatchDetectionDesc": { - "message": "Escolha a maneira predefinida pela qual a deteção de correspondência de URI é manuseada para credenciais ao realizar ações como auto-preenchimento." + "message": "Escolha a forma predefinida como a deteção de correspondência de URI é tratada para credenciais ao executar ações como o preenchimento automático." }, "theme": { "message": "Tema" }, "themeDesc": { - "message": "Altere o tema de cor da aplicação." + "message": "Alterar o tema de cores da aplicação." }, "dark": { "message": "Escuro", @@ -658,7 +691,7 @@ "description": "Light color" }, "solarizedDark": { - "message": "Solarized escuro", + "message": "Solarized (escuro)", "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, "exportVault": { @@ -672,16 +705,16 @@ "description": "WARNING (should stay in capitalized letters if the language permits)" }, "confirmVaultExport": { - "message": "Confirmar exportação de cofre" + "message": "Confirmar a exportação do cofre" }, "exportWarningDesc": { - "message": "Esta exportação contém os seus dados do cofre num formato desencriptado. Não deve armazenar ou enviar o ficheiro exportado através de canais inseguros (como email). Apague-a imediatamente após a utilizar." + "message": "Esta exportação contém os dados do seu cofre num formato não encriptado. Não deve armazenar ou enviar o ficheiro exportado através de canais não seguros (como o e-mail). Elimine-o imediatamente após terminar a sua utilização." }, "encExportKeyWarningDesc": { - "message": "Esta exportação cifra os seus dados utilizando a chave de cifragem da sua conta. Se alguma vez mudar a chave de cifragem da sua conta, deve fazer a exportação novamente, já que não conseguirá decifrar este ficheiro de exportação." + "message": "Esta exportação encripta os seus dados utilizando a chave de encriptação da sua conta. Se alguma vez regenerar a chave de encriptação da sua conta, deve exportar novamente, uma vez que não conseguirá desencriptar este ficheiro de exportação." }, "encExportAccountWarningDesc": { - "message": "As chaves de encriptação de conta são únicas para cada conta de utilizador Bitwarden, pelo que não se pode importar uma exportação encriptada para uma conta diferente." + "message": "As chaves de encriptação da conta são únicas para cada conta de utilizador Bitwarden, pelo que não é possível importar uma exportação encriptada para uma conta diferente." }, "exportMasterPassword": { "message": "Introduza a sua palavra-passe mestra para exportar os dados do seu cofre." @@ -690,13 +723,13 @@ "message": "Partilhado" }, "learnOrg": { - "message": "Saiba mais sobre as Organizações" + "message": "Saiba mais sobre as organizações" }, "learnOrgConfirmation": { - "message": "O Bitwarden permite-lhe partilhar os itens do seu cofre com outras pessoas ao usar uma organização. Gostaria de visitar o site bitwarden.com para saber mais?" + "message": "O Bitwarden permite-lhe partilhar os seus itens do cofre com outras pessoas através da utilização de uma organização. Gostaria de visitar o site bitwarden.com para saber mais?" }, "moveToOrganization": { - "message": "Mudança para Organização" + "message": "Mover para a organização" }, "share": { "message": "Partilhar" @@ -715,13 +748,13 @@ } }, "moveToOrgDesc": { - "message": "Escolha uma organização para a qual deseja mover este item. A mudança para uma organização transfere a propriedade do item para essa organização. Deixará de ser o proprietário directo deste item uma vez que tenha sido movido." + "message": "Escolha uma organização para a qual pretende mover este item. Mover para uma organização transfere a propriedade do item para essa organização. Deixará de ser o proprietário direto deste item depois de este ter sido movido." }, "learnMore": { "message": "Saber mais" }, "authenticatorKeyTotp": { - "message": "Chave de autenticador (TOTP)" + "message": "Chave de autenticação (TOTP)" }, "verificationCodeTotp": { "message": "Código de verificação (TOTP)" @@ -736,10 +769,10 @@ "message": "Eliminar anexo" }, "deleteAttachmentConfirmation": { - "message": "Tem a certeza de que deseja eliminar este anexo?" + "message": "Tem a certeza de que pretende eliminar este anexo?" }, "deletedAttachment": { - "message": "Anexo apagado" + "message": "Anexo eliminado" }, "newAttachment": { "message": "Adicionar novo anexo" @@ -748,13 +781,13 @@ "message": "Sem anexos." }, "attachmentSaved": { - "message": "O anexo foi guardado." + "message": "Anexo guardado" }, "file": { "message": "Ficheiro" }, "selectFile": { - "message": "Selecione um ficheiro." + "message": "Selecionar um ficheiro" }, "maxFileSize": { "message": "O tamanho máximo do ficheiro é de 500 MB." @@ -762,35 +795,35 @@ "featureUnavailable": { "message": "Funcionalidade indisponível" }, - "updateKey": { - "message": "Não pode utilizar esta funcionalidade até atualizar a sua chave de encriptação." + "encryptionKeyMigrationRequired": { + "message": "É necessária a migração da chave de encriptação. Inicie sessão através do cofre Web para atualizar a sua chave de encriptação." }, "premiumMembership": { - "message": "Adesão Premium" + "message": "Subscrição Premium" }, "premiumManage": { - "message": "Gerir adesão" + "message": "Gerir subscrição" }, "premiumManageAlert": { - "message": "Pode gerir a sua adesão premium no cofre web bitwarden.com. Pretende visitar o website agora?" + "message": "Pode gerir a sua subscrição no cofre web em bitwarden.com. Pretende visitar o site agora?" }, "premiumRefresh": { - "message": "Atualizar adesão" + "message": "Atualizar subscrição" }, "premiumNotCurrentMember": { - "message": "Não é atualmente um membro premium." + "message": "Atualmente, não é um membro Premium." }, "premiumSignUpAndGet": { - "message": "Registe-se para uma adesão premium e obtenha:" + "message": "Subscreva uma subscrição Premium e obtenha:" }, "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 das contas, e relatórios de brechas de dados para manter o seu cofre seguro." + "message": "Higiene de palavras-passe, saúde da conta e relatórios de violação de dados para manter o seu cofre seguro." }, "ppremiumSignUpTotp": { "message": "Gerador de códigos de verificação TOTP (2FA) para credenciais no seu cofre." @@ -799,16 +832,16 @@ "message": "Prioridade no apoio ao cliente." }, "ppremiumSignUpFuture": { - "message": "Todas as funcionalidades premium futuras. Mais a chegar brevemente!" + "message": "Todas as futuras funcionalidades Premium. Mais em breve!" }, "premiumPurchase": { - "message": "Comprar Premium" + "message": "Adquirir Premium" }, "premiumPurchaseAlert": { - "message": "Pode comprar adesão premium no cofre web bitwarden.com. Pretende visitar o website agora?" + "message": "Pode adquirir uma subscrição Premium no cofre web em bitwarden.com. Pretende visitar o site agora?" }, "premiumCurrentMember": { - "message": "É um membro premium!" + "message": "É um membro Premium!" }, "premiumCurrentMemberThanks": { "message": "Obrigado por apoiar o Bitwarden." @@ -823,28 +856,28 @@ } }, "refreshComplete": { - "message": "Atualização completada" + "message": "Atualização concluída" }, "enableAutoTotpCopy": { - "message": "Copy TOTP automatically" + "message": "Copiar TOTP automaticamente" }, "disableAutoTotpCopyDesc": { - "message": "Se o seu início de sessão tem uma chave de autenticador anexada ao mesmo, o código de verificação TOTP é copiado automaticamente para a sua área de transferência quando quer que auto-preencha o início de sessão." + "message": "Se uma credencial tiver uma chave de autenticação, copie o código de verificação TOTP para a sua área de transferência quando preencher automaticamente o início de sessão." }, "enableAutoBiometricsPrompt": { - "message": "Ask for biometrics on launch" + "message": "Pedir biometria ao iniciar" }, "premiumRequired": { - "message": "Premium requerido" + "message": "É necessária uma subscrição Premium" }, "premiumRequiredDesc": { - "message": "É requerida uma adesão premium para utilizar esta funcionalidade." + "message": "É necessária uma subscrição Premium para utilizar esta funcionalidade." }, "enterVerificationCodeApp": { - "message": "Introduza o código de verificação de 6 dígitos da sua aplicação de autenticador." + "message": "Introduza o código de verificação de 6 dígitos da sua aplicação de autenticação." }, "enterVerificationCodeEmail": { - "message": "Introduza o código de verificação de 6 dígitos que foi enviado por email para $EMAIL$.", + "message": "Introduza o código de verificação de 6 dígitos que foi enviado por e-mail para $EMAIL$.", "placeholders": { "email": { "content": "$1", @@ -853,7 +886,7 @@ } }, "verificationCodeEmailSent": { - "message": "Email de verificação enviado para $EMAIL$.", + "message": "E-mail de verificação enviado para $EMAIL$.", "placeholders": { "email": { "content": "$1", @@ -862,10 +895,10 @@ } }, "rememberMe": { - "message": "Memorizar-me" + "message": "Memorizar" }, "sendVerificationCodeEmailAgain": { - "message": "Enviar código de verificação novamente" + "message": "Enviar e-mail com o código de verificação novamente" }, "useAnotherTwoStepMethod": { "message": "Utilizar outro método de verificação de dois passos" @@ -877,7 +910,7 @@ "message": "Introduza a sua chave de segurança na porta USB do seu computador. Se tiver um botão, toque no mesmo." }, "webAuthnNewTab": { - "message": "Para iniciar a verificação WebAuthn 2FA. Clique no botão abaixo para abrir um novo separador e siga as instruções fornecidas no novo separador." + "message": "Para iniciar a verificação do WebAuthn 2FA, clique no botão abaixo para abrir um novo separador e siga as instruções fornecidas no novo separador." }, "webAuthnNewTabOpen": { "message": "Abrir novo separador" @@ -889,64 +922,64 @@ "message": "Início de sessão indisponível" }, "noTwoStepProviders": { - "message": "Esta conta tem a verificação de dois passos configurada, no entanto, nenhum dos fornecedores de dois passos configurados é suportado por este navegador web." + "message": "Esta conta tem a verificação de dois passos configurada, no entanto, nenhum dos fornecedores da verificação de dois passos configurada é suportado por este navegador Web." }, "noTwoStepProviders2": { - "message": "Por favor utilize um navegador web suportado (tal como o Chrome) e/ou adicione provedores adicionais que são melhor suportados entre navegadores web (tal como uma aplicação de autenticador)." + "message": "Por favor, utilize um navegador Web suportado (como o Chrome) e/ou adicione fornecedores adicionais que sejam mais bem suportados nos navegadores web (como uma aplicação de autenticação)." }, "twoStepOptions": { "message": "Opções de verificação de dois passos" }, "recoveryCodeDesc": { - "message": "Perdeu o acesso a todos os seus provedores de dois passos? Utilize o seu código de recuperação para desativar todos os provedores de dois passos da sua conta." + "message": "Perdeu o acesso a todos os seus fornecedores de verificação de dois passos? Utilize o seu código de recuperação para desativar todos os fornecedores de verificação de dois passos da sua conta." }, "recoveryCodeTitle": { "message": "Código de recuperação" }, "authenticatorAppTitle": { - "message": "Aplicação de autenticador" + "message": "Aplicação de autenticação" }, "authenticatorAppDesc": { - "message": "Utilize uma aplicação de autenticador (tal como Authy ou Google Authenticator) para gerar códigos de verificação baseados na hora.", + "message": "Utilize uma aplicação de autenticação (como o Authy ou o Google Authenticator) para gerar códigos de verificação baseados no tempo.", "description": "'Authy' and 'Google Authenticator' are product names and should not be translated." }, "yubiKeyTitle": { "message": "Chave de segurança YubiKey OTP" }, "yubiKeyDesc": { - "message": "Utilize uma YubiKey para aceder à sua conta. Funciona com YubiKey 4, 4 Nano, 4C, e dispositivos NEO." + "message": "Utilize uma YubiKey para aceder à sua conta. Funciona com os dispositivos YubiKey 4, 4 Nano, 4C e NEO." }, "duoDesc": { - "message": "Verifique com Duo Security utilizando a aplicação Duo Mobile, SMS, chamada telefónica, ou chave de segurança U2F.", + "message": "Verifique com a Duo Security utilizando a aplicação Duo Mobile, SMS, chamada telefónica ou chave de segurança U2F.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "duoOrganizationDesc": { - "message": "Verifique com Duo Security para a sua organização utilizando a aplicação Duo Mobile, SMS, chamada telefónica, ou chave de segurança U2F.", + "message": "Proteja a sua organização com a Duo Security utilizando a aplicação Duo Mobile, SMS, chamada telefónica ou uma chave de segurança U2F.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, "webAuthnTitle": { "message": "FIDO2 WebAuthn" }, "webAuthnDesc": { - "message": "Utilize qualquer chave de segurança ativada pela WebAuthn para aceder à sua conta." + "message": "Utilize qualquer chave de segurança compatível com o WebAuthn para aceder à sua conta." }, "emailTitle": { - "message": "Email" + "message": "E-mail" }, "emailDesc": { - "message": "Os códigos de verificação vão ser enviados para si." + "message": "Os códigos de verificação ser-lhe-ão enviados por e-mail." }, "selfHostedEnvironment": { "message": "Ambiente auto-hospedado" }, "selfHostedEnvironmentFooter": { - "message": "Especifique o URL de base da sua instalação local do Bitwarden alojada nas suas premissas." + "message": "Especifique o URL de base da sua instalação Bitwarden hospedada no local." }, "customEnvironment": { "message": "Ambiente personalizado" }, "customEnvironmentFooter": { - "message": "Para utilizadores avançados. Pode especificar o URL de base de cada serviço independentemente." + "message": "Para utilizadores avançados. Pode especificar o URL de base de cada serviço de forma independente." }, "baseUrl": { "message": "URL do servidor" @@ -955,7 +988,7 @@ "message": "URL do servidor da API" }, "webVaultUrl": { - "message": "URL do servidor do cofre web" + "message": "URL do servidor do cofre Web" }, "identityUrl": { "message": "URL do servidor de identidade" @@ -967,46 +1000,46 @@ "message": "URL do servidor de ícones" }, "environmentSaved": { - "message": "Os URLs de ambiente foram guardados." + "message": "URLs de ambiente guardados" }, "enableAutoFillOnPageLoad": { - "message": "Ativar auto-preenchimento no carregar da página" + "message": "Preencher automaticamente ao carregar a página" }, "enableAutoFillOnPageLoadDesc": { - "message": "Se um formulário de início de sessão foram detetado, realizar automaticamente um auto-preenchimento quando a página web carregar." + "message": "Se for detetado um formulário de início de sessão, o preenchimento automático é efetuado quando a página Web é carregada." }, "experimentalFeature": { - "message": "Compromised or untrusted websites can exploit auto-fill on page load." + "message": "Os sites comprometidos ou não confiáveis podem explorar o preenchimento automático ao carregar a página." }, "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "message": "Saber mais sobre o preenchimento automático" }, "defaultAutoFillOnPageLoad": { - "message": "Predefinição padrão de preenchimento automático para itens de login" + "message": "Definição de preenchimento automático predefinido para itens de início de sessão" }, "defaultAutoFillOnPageLoadDesc": { - "message": "Depois de activar o preenchimento automático no carregamento da página, pode activar ou desactivar a função de itens de início de sessão individuais. Esta é a configuração padrão para os itens de início de sessão que não estão configurados separadamente." + "message": "Pode desativar o preenchimento automático ao carregar a página para itens de início de sessão individuais a partir da vista Editar do item." }, "itemAutoFillOnPageLoad": { - "message": "Preenchimento automático no carregamento da página (se ativado em Opções)" + "message": "Preenchimento automático ao carregar a página (se configurado nas Opções)" }, "autoFillOnPageLoadUseDefault": { - "message": "Usar padrão" + "message": "Utilizar a predefinição" }, "autoFillOnPageLoadYes": { - "message": "Preenchimento automático na carga da página" + "message": "Preencher automaticamente ao carregar a página" }, "autoFillOnPageLoadNo": { - "message": "Não preencher automaticamente no carregamento da página" + "message": "Não preencher automaticamente ao carregar a página" }, "commandOpenPopup": { - "message": "Abrir popup do cofre" + "message": "Abrir o pop-up do cofre" }, "commandOpenSidebar": { - "message": "Abrir cofre na barra lateral" + "message": "Abrir o cofre na barra lateral" }, "commandAutofillDesc": { - "message": "Auto-preencher o último início de sessão utilizado para o website atual" + "message": "Preencher automaticamente o último início de sessão utilizado no site atual" }, "commandGeneratePasswordDesc": { "message": "Gerar e copiar uma nova palavra-passe aleatória para a área de transferência" @@ -1015,7 +1048,7 @@ "message": "Bloquear o cofre" }, "privateModeWarning": { - "message": "O suporte do modo privado é experimental e alguns recursos são limitados." + "message": "O suporte do modo privado é experimental e algumas funcionalidades são limitadas." }, "customFields": { "message": "Campos personalizados" @@ -1036,36 +1069,36 @@ "message": "Texto" }, "cfTypeHidden": { - "message": "Ocultado" + "message": "Oculto" }, "cfTypeBoolean": { "message": "Booleano" }, "cfTypeLinked": { - "message": "Ligado", + "message": "Associado", "description": "This describes a field that is 'linked' (tied) to another field." }, "linkedValue": { - "message": "Valor vinculado", + "message": "Valor associado", "description": "This describes a value that is 'linked' (tied) to another value." }, "popup2faCloseMessage": { - "message": "Clicar fora da janela popup para verificar o seu email pelo código de verificação irá causar com que este popup feche. Deseja abrir este popup numa nova janela para que este não se feche?" + "message": "Ao clicar fora da janela pop-up para verificar o código de verificação no seu e-mail fará com que este pop-up se feche. Pretende abrir esta janela pop-up numa nova janela para que não se feche?" }, "popupU2fCloseMessage": { - "message": "Este navegador não pode processar solicitações U2F nesta janela popup. Pretende abrir este popup numa nova janela para que inicie sessão utilizando U2F?" + "message": "Este navegador não pode processar pedidos U2F nesta janela pop-up. Pretende abrir este pop-up numa nova janela para poder iniciar sessão utilizando o U2F?" }, "enableFavicon": { - "message": "Show website icons" + "message": "Mostrar ícones do site" }, "faviconDesc": { - "message": "Show a recognizable image next to each login." + "message": "Mostrar uma imagem reconhecível junto a cada credencial." }, "enableBadgeCounter": { - "message": "Show badge counter" + "message": "Mostrar distintivo de contador" }, "badgeCounterDesc": { - "message": "Indicate how many logins you have for the current web page." + "message": "Indica quantas credenciais tem para a página Web atual." }, "cardholderName": { "message": "Titular do cartão" @@ -1077,10 +1110,10 @@ "message": "Marca" }, "expirationMonth": { - "message": "Mês de expiração" + "message": "Mês de validade" }, "expirationYear": { - "message": "Ano de expiração" + "message": "Ano de validade" }, "expiration": { "message": "Expiração" @@ -1131,28 +1164,28 @@ "message": "Título" }, "mr": { - "message": "Sr" + "message": "Sr." }, "mrs": { - "message": "Sra" + "message": "Sr.ª" }, "ms": { - "message": "Sra" + "message": "Menina" }, "dr": { - "message": "Dr" + "message": "Dr." }, "mx": { - "message": "Mx" + "message": "Neutro" }, "firstName": { - "message": "Primeiro nome" + "message": "Nome próprio" }, "middleName": { - "message": "Nome do meio" + "message": "Segundo nome" }, "lastName": { - "message": "Último nome" + "message": "Apelido" }, "fullName": { "message": "Nome completo" @@ -1173,7 +1206,7 @@ "message": "Número da licença" }, "email": { - "message": "Email" + "message": "E-mail" }, "phone": { "message": "Telefone" @@ -1191,10 +1224,10 @@ "message": "Endereço 3" }, "cityTown": { - "message": "Cidade / localidade" + "message": "Cidade / Localidade" }, "stateProvince": { - "message": "Estado / província" + "message": "Estado / Província" }, "zipPostalCode": { "message": "Código postal" @@ -1233,7 +1266,7 @@ "message": "Favoritos" }, "popOutNewWindow": { - "message": "Enviar para uma nova janela" + "message": "Abrir numa nova janela" }, "refresh": { "message": "Atualizar" @@ -1255,10 +1288,10 @@ "description": "To clear something out. example: To clear browser history." }, "checkPassword": { - "message": "Verifica se a palavra-passe foi exposta." + "message": "Verificar se a palavra-passe foi exposta." }, "passwordExposed": { - "message": "Esta palavra-passe foi exposta $VALUE$ vez(es) em brechas de dados. Deve alterá-la.", + "message": "Esta palavra-passe foi exposta $VALUE$ vez(es) em violações de dados. Deve alterá-la.", "placeholders": { "value": { "content": "$1", @@ -1267,10 +1300,10 @@ } }, "passwordSafe": { - "message": "Esta palavra-passe não foi encontrada em nenhuma brecha de dados conhecida. Esta deve ser segura de utilizar." + "message": "Esta palavra-passe não foi encontrada em nenhuma violação de dados conhecida. A sua utilização deve ser segura." }, "baseDomain": { - "message": "Domínio base", + "message": "Domínio de base", "description": "Domain name. Ex. website.com" }, "domainName": { @@ -1278,7 +1311,7 @@ "description": "Domain name. Ex. website.com" }, "host": { - "message": "Servidor", + "message": "Domínio", "description": "A URL's host value. For example, the host of https://sub.domain.com:443 is 'sub.domain.com:443'." }, "exact": { @@ -1334,15 +1367,15 @@ "description": "ex. Date this item was updated" }, "dateCreated": { - "message": "Criado", + "message": "Criado a", "description": "ex. Date this item was created" }, "datePasswordUpdated": { - "message": "Palavra-passe atualizada", + "message": "Palavra-passe atualizada a", "description": "ex. Date this password was updated" }, "neverLockWarning": { - "message": "Tem a certeza de que pretende utilizar a opção \"Nunca\"? Definir as suas opções de bloqueio para \"Nunca\" armazena a chave de encriptação do seu cofre no seu dispositivo. Se utilizar esta opção deve assegurar-se de que mantém o seu dispositivo devidamente protegido." + "message": "Tem a certeza de que deseja utilizar a opção \"Nunca\"? Ao definir as opções de bloqueio para \"Nunca\" armazena a chave de encriptação do seu cofre no seu dispositivo. Se utilizar esta opção deve assegurar-se de que mantém o seu dispositivo devidamente protegido." }, "noOrganizationsList": { "message": "Não pertence a nenhuma organização. As organizações permitem-lhe partilhar itens em segurança com outros utilizadores." @@ -1372,7 +1405,7 @@ "message": "Palavra-passe mestra fraca" }, "weakMasterPasswordDesc": { - "message": "A palavra-passe mestra que escolheu é fraca. Deve utilizar uma palavra-passe mestra forte (ou uma frase-passe) para proteger adequadamente a sua conta Bitwarden. Tem a certeza de que pretende utilizar esta palavra-passe mestra?" + "message": "A palavra-passe mestra que escolheu é fraca. Deve utilizar uma palavra-passe mestra forte (ou uma frase de acesso) para proteger adequadamente a sua conta Bitwarden. Tem a certeza de que pretende utilizar esta palavra-passe mestra?" }, "pin": { "message": "PIN", @@ -1382,40 +1415,40 @@ "message": "Desbloquear com PIN" }, "setYourPinCode": { - "message": "Defina o seu código PIN para desbloquear o Bitwarden. As suas definições PIN serão redefinidas se terminar sessão completamente da aplicação." + "message": "Defina o seu código PIN para desbloquear o Bitwarden. As suas definições de PIN serão redefinidas se alguma vez terminar sessão completamente da aplicação." }, "pinRequired": { - "message": "O código PIN é requerido." + "message": "É necessário o código PIN." }, "invalidPin": { "message": "Código PIN inválido." }, "unlockWithBiometrics": { - "message": "Desbloquear com biométricos" + "message": "Desbloquear com biometria" }, "awaitDesktop": { - "message": "A aguardar confirmação do seu computador" + "message": "A aguardar confirmação da aplicação para computador" }, "awaitDesktopDesc": { - "message": "Por favor, confirme o uso de dados biométricos na aplicação Bitwarden Desktop para habilitar os dados biométricos do navegador." + "message": "Por favor, confirme a utilização da biometria na aplicação para computador Bitwarden para configurar a biometria no navegador." }, "lockWithMasterPassOnRestart": { - "message": "Bloquear com palavra-passe mestra quando reiniciar o navegador" + "message": "Bloquear com a palavra-passe mestra ao reiniciar o navegador" }, "selectOneCollection": { - "message": "Tem de selecionar pelo menos uma coleção." + "message": "Deve selecionar pelo menos uma coleção." }, "cloneItem": { - "message": "Clonar item" + "message": "Duplicar item" }, "clone": { - "message": "Clonar" + "message": "Duplicar" }, "passwordGeneratorPolicyInEffect": { - "message": "Uma ou mais políticas de organização estão a afetar as suas definições do gerador." + "message": "Uma ou mais políticas da organização estão a afetar as suas definições do gerador." }, "vaultTimeoutAction": { - "message": "Ação de expiração do cofre" + "message": "Ação de tempo limite do cofre" }, "lock": { "message": "Bloquear", @@ -1426,13 +1459,13 @@ "description": "Noun: a special folder to hold deleted items" }, "searchTrash": { - "message": "Pesquisar lixo" + "message": "Procurar no lixo" }, "permanentlyDeleteItem": { "message": "Eliminar item permanentemente" }, "permanentlyDeleteItemConfirmation": { - "message": "Tem a certeza de que pretende eliminar este item permanentemente?" + "message": "Tem a certeza de que pretende eliminar permanentemente este item?" }, "permanentlyDeletedItem": { "message": "Item eliminado permanentemente" @@ -1440,38 +1473,35 @@ "restoreItem": { "message": "Restaurar item" }, - "restoreItemConfirmation": { - "message": "Tem a certeza de que pretende restaurar este item?" - }, "restoredItem": { "message": "Item restaurado" }, "vaultTimeoutLogOutConfirmation": { - "message": "Terminar sessão irá remover todos os acessos ao seu cofre e requer autenticação online após o período de expiração. Tem a certeza de que pretende utilizar esta definição?" + "message": "Ao terminar sessão removerá todo o acesso ao seu cofre e requer autenticação online após o período de tempo limite. Tem a certeza de que pretende utilizar esta definição?" }, "vaultTimeoutLogOutConfirmationTitle": { - "message": "Confirmação de expiração do cofre" + "message": "Confirmação da ação de tempo limite" }, "autoFillAndSave": { - "message": "Auto-preencher e guardar" + "message": "Preencher automaticamente e guardar" }, "autoFillSuccessAndSavedUri": { - "message": "Item auto-preenchido e URI guardado" + "message": "Item preenchido automaticamente e URI guardado" }, "autoFillSuccess": { - "message": "Item auto-preenchido" + "message": "Item preenchido automaticamente " }, "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": "Aviso: Esta é uma página HTTP não segura, e qualquer informação que submeta pode ser vista e alterada por outros. Esta credencial foi originalmente guardada numa página segura (HTTPS)." }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "Ainda deseja preencher este início de sessão?" }, "autofillIframeWarning": { - "message": "The form is hosted by a different domain than the URI of your saved login. Choose OK to auto-fill anyway, or Cancel to stop." + "message": "O formulário está alojado num domínio diferente do URI da sua credencial guardada. Selecione OK para preencher automaticamente na mesma ou Cancelar para parar." }, "autofillIframeWarningTip": { - "message": "To prevent this warning in the future, save this URI, $HOSTNAME$, to your Bitwarden login item for this site.", + "message": "Para evitar este aviso no futuro, guarde este URI, $HOSTNAME$, no seu item de início de sessão do Bitwarden deste site.", "placeholders": { "hostname": { "content": "$1", @@ -1483,16 +1513,16 @@ "message": "Definir palavra-passe mestra" }, "currentMasterPass": { - "message": "Current master password" + "message": "Palavra-passe mestra atual" }, "newMasterPass": { - "message": "New master password" + "message": "Nova palavra-passe mestra" }, "confirmNewMasterPass": { - "message": "Confirm new master password" + "message": "Confirmar a nova palavra-passe mestra" }, "masterPasswordPolicyInEffect": { - "message": "Uma ou mais políticas da organização requerem que a sua palavra-passe mestra cumpra aos seguintes requisitos:" + "message": "Uma ou mais políticas da organização exigem que a sua palavra-passe mestra cumpra os seguintes requisitos:" }, "policyInEffectMinComplexity": { "message": "Pontuação mínima de complexidade de $SCORE$", @@ -1537,91 +1567,97 @@ "message": "Ao marcar esta caixa concorda com o seguinte:" }, "acceptPoliciesRequired": { - "message": "Os Termos de Serviço e a Política de Privacidade não foram reconhecidos." + "message": "Os Termos de utilização e a Política de privacidade não foram aceites." }, "termsOfService": { - "message": "Termos de serviço" + "message": "Termos de utilização" }, "privacyPolicy": { "message": "Política de privacidade" }, "hintEqualsPassword": { - "message": "A dica da sua senha não pode ser igual à senha." + "message": "A dica da sua palavra-passe não pode ser igual à sua palavra-passe." }, "ok": { "message": "Ok" }, "desktopSyncVerificationTitle": { - "message": "Verificação de sincronização do ambiente de trabalho" + "message": "Verificação da sincronização da aplicação para computador" }, "desktopIntegrationVerificationText": { - "message": "Por favor, verifique se a aplicação no computador mostra esta impressão digital: " + "message": "Verifique se a aplicação para computador apresenta esta impressão digital: " }, "desktopIntegrationDisabledTitle": { - "message": "Integração com o navegador não está ativada" + "message": "A integração do navegador não está configurada" }, "desktopIntegrationDisabledDesc": { - "message": "A integração com o navegador não está habilitada no aplicativo Bitwarden Desktop. Por favor, habilite-o nas configurações da aplicação para computador." + "message": "A integração do navegador não está configurada na aplicação para computador Bitwarden. Por favor, configure-a nas definições da aplicação para computador." }, "startDesktopTitle": { - "message": "Iniciar a aplicação Bitwarden Desktop" + "message": "Iniciar a aplicação para computador Bitwarden" }, "startDesktopDesc": { - "message": "The Bitwarden desktop application needs to be started before unlock with biometrics can be used." + "message": "A aplicação para computador do Bitwarden tem de ser iniciada antes de se poder utilizar o desbloqueio com biometria." }, "errorEnableBiometricTitle": { - "message": "Unable to set up biometrics" + "message": "Não é possível configurar a biometria" }, "errorEnableBiometricDesc": { - "message": "Action was canceled by the desktop application" + "message": "A ação foi cancelada pela aplicação para computador" }, "nativeMessagingInvalidEncryptionDesc": { - "message": "Desktop application invalidated the secure communication channel. Please retry this operation" + "message": "A aplicação para computador invalidou o canal de comunicação seguro. Por favor, tente novamente esta operação" }, "nativeMessagingInvalidEncryptionTitle": { - "message": "Desktop communication interrupted" + "message": "Interrupção da comunicação com o computador" }, "nativeMessagingWrongUserDesc": { - "message": "The desktop application is logged into a different account. Please ensure both applications are logged into the same account." + "message": "A aplicação para computador tem a sessão iniciada numa conta diferente. Por favor, certifique-se de que ambas as aplicações têm a sessão iniciada na mesma conta." }, "nativeMessagingWrongUserTitle": { - "message": "Account missmatch" + "message": "Incompatibilidade de contas" }, "biometricsNotEnabledTitle": { - "message": "Biometrics not set up" + "message": "Biometria não configurada" }, "biometricsNotEnabledDesc": { - "message": "Browser biometrics requires desktop biometric to be set up in the settings first." + "message": "A biometria do navegador requer que a biometria do computador seja primeiro configurada nas definições." }, "biometricsNotSupportedTitle": { - "message": "Biometrics not supported" + "message": "Biometria não suportada" }, "biometricsNotSupportedDesc": { - "message": "Browser biometrics is not supported on this device." + "message": "A biometria do navegador não é suportada neste dispositivo." + }, + "biometricsFailedTitle": { + "message": "Falha na biometria" + }, + "biometricsFailedDesc": { + "message": "A biometria não pode ser concluída, considere a possibilidade de utilizar uma palavra-passe mestra ou terminar a sessão. Se o problema persistir, contacte a assistência do Bitwarden." }, "nativeMessaginPermissionErrorTitle": { - "message": "Permission not provided" + "message": "Autorização não concedida" }, "nativeMessaginPermissionErrorDesc": { - "message": "Without permission to communicate with the Bitwarden Desktop Application we cannot provide biometrics in the browser extension. Please try again." + "message": "Sem autorização para comunicar com a aplicação para computador do Bitwarden, não podemos fornecer dados biométricos na extensão do navegador. Por favor, tente novamente." }, "nativeMessaginPermissionSidebarTitle": { - "message": "Permission request error" + "message": "Erro no pedido de autorização" }, "nativeMessaginPermissionSidebarDesc": { - "message": "This action cannot be done in the sidebar, please retry the action in the popup or popout." + "message": "Esta ação não pode ser realizada na barra lateral. Por favor, repita a ação no pop-up ou no popout." }, "personalOwnershipSubmitError": { - "message": "Due to an Enterprise Policy, you are restricted from saving items to your personal vault. Change the Ownership option to an organization and choose from available collections." + "message": "Devido a uma política empresarial, está impedido de guardar itens no seu cofre pessoal. Altere a opção Propriedade para uma organização e escolha entre as coleções disponíveis." }, "personalOwnershipPolicyInEffect": { - "message": "An organization policy is affecting your ownership options." + "message": "Uma política da organização está a afetar as suas opções de propriedade." }, "excludedDomains": { - "message": "Excluded domains" + "message": "Domínios excluídos" }, "excludedDomainsDesc": { - "message": "Bitwarden will not ask to save login details for these domains. You must refresh the page for changes to take effect." + "message": "O Bitwarden não pedirá para guardar os detalhes de início de sessão destes domínios. É necessário atualizar a página para que as alterações tenham efeito." }, "excludedDomainsInvalidDomain": { "message": "$DOMAIN$ não é um domínio válido", @@ -1633,15 +1669,15 @@ } }, "send": { - "message": "Envio", + "message": "Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "searchSends": { - "message": "Pesquisar Envios", + "message": "Procurar Sends", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "addSend": { - "message": "Adicionar Envio", + "message": "Adicionar Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeText": { @@ -1651,24 +1687,24 @@ "message": "Ficheiro" }, "allSends": { - "message": "Todos os Envios", + "message": "Todos os Sends", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "maxAccessCountReached": { - "message": "Número de acessos máximo atingido", + "message": "Número máximo de acessos atingido", "description": "This text will be displayed after a Send has been accessed the maximum amount of times." }, "expired": { "message": "Expirado" }, "pendingDeletion": { - "message": "Remoção pendente" + "message": "Eliminação pendente" }, "passwordProtected": { - "message": "Protegido por senha" + "message": "Protegido por palavra-passe" }, "copySendLink": { - "message": "Copiar Send link", + "message": "Copiar link do Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "removePassword": { @@ -1678,40 +1714,40 @@ "message": "Eliminar" }, "removedPassword": { - "message": "Senha removida" + "message": "Palavra-passe removida" }, "deletedSend": { - "message": "Envio eliminado", + "message": "Send eliminado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendLink": { - "message": "Link de Envio", + "message": "Link do Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "disabled": { - "message": "Desabilitado" + "message": "Desativado" }, "removePasswordConfirmation": { - "message": "Tem a certeza que pretende remover a senha?" + "message": "Tem a certeza de que pretende remover a palavra-passe?" }, "deleteSend": { - "message": "Eliminar Envio", + "message": "Eliminar Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deleteSendConfirmation": { - "message": "Tem a certeza que pretende eliminar este Envio?", + "message": "Tem a certeza de que pretende eliminar este Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editSend": { - "message": "Editar Envio", + "message": "Editar Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTypeHeader": { - "message": "Que tipo de Envio é este?", + "message": "Que tipo de Send é este?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendNameDesc": { - "message": "Um nome amigável para descrever este Envio.", + "message": "Um nome simpático para descrever este Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendFileDesc": { @@ -1721,21 +1757,21 @@ "message": "Data de eliminação" }, "deletionDateDesc": { - "message": "The Send will be permanently deleted on the specified date and time.", + "message": "O Send será permanentemente eliminado na data e hora especificadas.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "expirationDate": { - "message": "Expiration date" + "message": "Data de validade" }, "expirationDateDesc": { - "message": "If set, access to this Send will expire on the specified date and time.", + "message": "Se definido, o acesso a este Send expirará na data e hora especificadas.", "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 dia" }, "days": { - "message": "$DAYS$ days", + "message": "$DAYS$ dias", "placeholders": { "days": { "content": "$1", @@ -1747,75 +1783,75 @@ "message": "Personalizado" }, "maximumAccessCount": { - "message": "Maximum Access Count" + "message": "Número máximo de acessos" }, "maximumAccessCountDesc": { - "message": "If set, users will no longer be able to access this Send once the maximum access count is reached.", + "message": "Se definido, os utilizadores deixarão de poder aceder a este Send quando a contagem máxima de acessos for atingida.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDesc": { - "message": "Optionally require a password for users to access this Send.", + "message": "Opcionalmente, exigir uma palavra-passe para os utilizadores acederem a este Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendNotesDesc": { - "message": "Private notes about this Send.", + "message": "Notas privadas sobre este Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendDisableDesc": { - "message": "Deactivate this Send so that no one can access it.", + "message": "Desative este Send para que ninguém possa aceder ao mesmo.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendShareDesc": { - "message": "Copy this Send's link to clipboard upon save.", + "message": "Copiar o link deste Send para a área de transferência ao guardar.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTextDesc": { - "message": "The text you want to send." + "message": "O texto que deseja enviar." }, "sendHideText": { - "message": "Hide this Send's text by default.", + "message": "Ocultar o texto deste Send por defeito.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "currentAccessCount": { - "message": "Current access count" + "message": "Número de acessos atual" }, "createSend": { - "message": "New Send", + "message": "Novo Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newPassword": { "message": "Nova palavra-passe" }, "sendDisabled": { - "message": "Send removed", + "message": "Send removido", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendDisabledWarning": { - "message": "Due to an enterprise policy, you are only able to delete an existing Send.", + "message": "Devido a uma política da empresa, só é possível eliminar um Send existente.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSend": { - "message": "Send created", + "message": "Send criado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "editedSend": { - "message": "Send saved", + "message": "Send editado", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendLinuxChromiumFileWarning": { - "message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner." + "message": "Para escolher um ficheiro, abra a extensão na barra lateral (se possível) ou abra uma nova janela clicando neste banner." }, "sendFirefoxFileWarning": { - "message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner." + "message": "Para escolher um ficheiro utilizando o Firefox, abra a extensão na barra lateral ou abra uma nova janela clicando neste banner." }, "sendSafariFileWarning": { - "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." + "message": "Para escolher um ficheiro utilizando o Safari, abra uma nova janela clicando neste banner." }, "sendFileCalloutHeader": { "message": "Antes de começar" }, "sendFirefoxCustomDatePopoutMessage1": { - "message": "Para usar um seletor de data no estilo de calendário", + "message": "Para utilizar um seletor de datas do tipo calendário,", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read '**To use a calendar style date picker ** click here to pop out your window.'" }, "sendFirefoxCustomDatePopoutMessage2": { @@ -1823,68 +1859,68 @@ "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To use a calendar style date picker **click here** to pop out your window.'" }, "sendFirefoxCustomDatePopoutMessage3": { - "message": "Para abrir em janela.", + "message": "para abrir a janela.", "description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To use a calendar style date picker click here **to pop out your window.**'" }, "expirationDateIsInvalid": { - "message": "A data de validade fornecida não é válida." + "message": "São necessárias uma data e uma hora de validade." }, "deletionDateIsInvalid": { "message": "A data de eliminação fornecida não é válida." }, "expirationDateAndTimeRequired": { - "message": "Uma data de validade e uma hora são obrigatórias." + "message": "São necessárias uma data e uma hora de validade." }, "deletionDateAndTimeRequired": { - "message": "Uma data de eliminação e uma hora são obrigatórias." + "message": "São necessárias uma data e uma hora de eliminação." }, "dateParsingError": { - "message": "Ocorreu um erro ao guardar a sua exclusão e datas de validade." + "message": "Ocorreu um erro ao guardar as suas datas de eliminação e validade." }, "hideEmail": { - "message": "Ocultar o meu endereço de correio eletrónico dos destinatários." + "message": "Ocultar o meu endereço de e-mail dos destinatários." }, "sendOptionsPolicyInEffect": { - "message": "One or more organization policies are affecting your Send options." + "message": "Uma ou mais políticas da organização estão a afetar as suas opções do Send." }, "passwordPrompt": { - "message": "Master password re-prompt" + "message": "Pedir novamente a palavra-passe mestra" }, "passwordConfirmation": { - "message": "Master password confirmation" + "message": "Confirmação da palavra-passe mestra" }, "passwordConfirmationDesc": { - "message": "This action is protected. To continue, please re-enter your master password to verify your identity." + "message": "Esta ação está protegida. Para continuar, por favor, reintroduza a sua palavra-passe mestra para verificar a sua identidade." }, "emailVerificationRequired": { - "message": "Email verification required" + "message": "Verificação de e-mail necessária" }, "emailVerificationRequiredDesc": { - "message": "You must verify your email to use this feature. You can verify your email in the web vault." + "message": "Tem de verificar o seu e-mail para utilizar esta funcionalidade. Pode verificar o seu e-mail no cofre Web." }, "updatedMasterPassword": { "message": "Palavra-passe mestra atualizada" }, "updateMasterPassword": { - "message": "Atualizar Senha Mestra" + "message": "Atualizar palavra-passe mestra" }, "updateMasterPasswordWarning": { - "message": "Your master password was recently changed by an administrator in your organization. In order to access the vault, you must update it now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." + "message": "A sua palavra-passe mestra foi recentemente alterada por um administrador da sua organização. Para aceder ao cofre, tem de atualizar a sua palavra-passe mestra agora. Ao prosseguir, terminará a sua sessão atual e terá de iniciar sessão novamente. As sessões ativas noutros dispositivos poderão continuar ativas até uma hora." }, "updateWeakMasterPasswordWarning": { - "message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." + "message": "A sua palavra-passe mestra não cumpre uma ou mais políticas da sua organização. Para aceder ao cofre, tem de atualizar a sua palavra-passe mestra agora. Ao prosseguir, terminará a sua sessão atual e terá de iniciar sessão novamente. As sessões ativas noutros dispositivos poderão continuar ativas até uma hora." }, "resetPasswordPolicyAutoEnroll": { - "message": "Inscrição Automática" + "message": "Inscrição automática" }, "resetPasswordAutoEnrollInviteWarning": { - "message": "This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password." + "message": "Esta organização tem uma política empresarial que o inscreverá automaticamente na redefinição de palavra-passe. A inscrição permitirá que os administradores da organização alterem a sua palavra-passe mestra." }, "selectFolder": { - "message": "Seleccionar pasta..." + "message": "Selecionar pasta..." }, "ssoCompleteRegistration": { - "message": "In order to complete logging in with SSO, please set a master password to access and protect your vault." + "message": "Para concluir o início de sessão com SSO, por favor, defina uma palavra-passe mestra para aceder e proteger o seu cofre." }, "hours": { "message": "Horas" @@ -1893,7 +1929,7 @@ "message": "Minutos" }, "vaultTimeoutPolicyInEffect": { - "message": "Your organization policies have set your maximum allowed vault timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", + "message": "As políticas da sua organização definiram o tempo limite máximo permitido do cofre de $HOURS$ hora(s) e $MINUTES$ minuto(s).", "placeholders": { "hours": { "content": "$1", @@ -1906,7 +1942,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is $HOURS$ hour(s) and $MINUTES$ minute(s). Your vault timeout action is set to $ACTION$.", + "message": "As políticas da sua organização estão a afetar o tempo limite do cofre. O tempo limite máximo permitido do cofre é de $HOURS$ hora(s) e $MINUTES$ minuto(s). A sua ação de tempo limite do cofre está definida para $ACTION$.", "placeholders": { "hours": { "content": "$1", @@ -1923,7 +1959,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "Your organization policies have set your vault timeout action to $ACTION$.", + "message": "As políticas da sua organização definiram a ação de tempo limite do cofre para $ACTION$.", "placeholders": { "action": { "content": "$1", @@ -1932,22 +1968,22 @@ } }, "vaultTimeoutTooLarge": { - "message": "Your vault timeout exceeds the restrictions set by your organization." + "message": "O tempo limite do seu cofre excede as restrições definidas pela sua organização." }, "vaultExportDisabled": { - "message": "Vault export unavailable" + "message": "Exportação de cofre indisponível" }, "personalVaultExportPolicyInEffect": { - "message": "One or more organization policies prevents you from exporting your individual vault." + "message": "Uma ou mais políticas da organização impedem-no de exportar o seu cofre pessoal." }, "copyCustomFieldNameInvalidElement": { - "message": "Unable to identify a valid form element. Try inspecting the HTML instead." + "message": "Não foi possível identificar um elemento de formulário válido. Em alternativa, tente inspecionar o HTML." }, "copyCustomFieldNameNotUnique": { - "message": "No unique identifier found." + "message": "Não foi encontrado um identificador único." }, "convertOrganizationEncryptionDesc": { - "message": "$ORGANIZATION$ is using SSO with a self-hosted key server. A master password is no longer required to log in for members of this organization.", + "message": "A $ORGANIZATION$ está a utilizar o SSO com um servidor de chaves auto-hospedado. Já não é necessária uma palavra-passe mestra para iniciar sessão para os membros desta organização.", "placeholders": { "organization": { "content": "$1", @@ -1956,31 +1992,31 @@ } }, "leaveOrganization": { - "message": "Deixar a Organização" + "message": "Deixar a organização" }, "removeMasterPassword": { - "message": "Remover Senha Mestra" + "message": "Remover palavra-passe mestra" }, "removedMasterPassword": { - "message": "Senha mestra removida." + "message": "Palavra-passe mestra removida" }, "leaveOrganizationConfirmation": { - "message": "Tem a certeza de que pretende sair desta organização?" + "message": "Tem a certeza de que pretende deixar esta organização?" }, "leftOrganization": { "message": "Saiu da organização." }, "toggleCharacterCount": { - "message": "Toggle character count" + "message": "Mostrar/ocultar contagem de caracteres" }, "sessionTimeout": { "message": "A sua sessão expirou. Por favor, volte atrás e tente iniciar sessão novamente." }, "exportingPersonalVaultTitle": { - "message": "A exportar cofre pessoal" + "message": "A exportar o cofre pessoal" }, - "exportingPersonalVaultDescription": { - "message": "Apenas os itens do cofre pessoal associado a $EMAIL$ serão exportados. Os itens do cofre da organização não serão incluídos.", + "exportingIndividualVaultDescription": { + "message": "Apenas os itens de cofre individuais associados a $EMAIL$ serão exportados. Os itens do cofre da organização não serão incluídos. Apenas serão exportadas as informações do item do cofre e não serão incluídos os anexos associados.", "placeholders": { "email": { "content": "$1", @@ -1992,7 +2028,7 @@ "message": "Erro" }, "regenerateUsername": { - "message": "Regerar nome de utilizador" + "message": "Regenerar nome de utilizador" }, "generateUsername": { "message": "Gerar nome de utilizador" @@ -2001,17 +2037,17 @@ "message": "Tipo de nome de utilizador" }, "plusAddressedEmail": { - "message": "Plus addressed email", + "message": "E-mail com subendereço", "description": "Username generator option that appends a random sub-address to the username. For example: address+subaddress@email.com" }, "plusAddressedEmailDesc": { - "message": "Use your email provider's sub-addressing capabilities." + "message": "Utilize as capacidades de subendereçamento do seu fornecedor de e-mail." }, "catchallEmail": { - "message": "Catch-all email" + "message": "E-mail de captura geral" }, "catchallEmailDesc": { - "message": "Use your domain's configured catch-all inbox." + "message": "Utilize a caixa de entrada de captura geral configurada para o seu domínio." }, "random": { "message": "Aleatório" @@ -2032,32 +2068,32 @@ "message": "Serviço" }, "forwardedEmail": { - "message": "Forwarded email alias" + "message": "Alias de e-mail reencaminhado" }, "forwardedEmailDesc": { - "message": "Generate an email alias with an external forwarding service." + "message": "Gerar um alias de e-mail com um serviço de reencaminhamento externo." }, "hostname": { - "message": "Hostname", + "message": "Nome de domínio", "description": "Part of a URL." }, "apiAccessToken": { - "message": "Token de acesso da API" + "message": "Token de acesso à API" }, "apiKey": { "message": "Chave da API" }, "ssoKeyConnectorError": { - "message": "Key connector error: make sure key connector is available and working correctly." + "message": "Erro no Key Connector: certifique-se de que o Key Connector está disponível e a funcionar corretamente." }, "premiumSubcriptionRequired": { - "message": "Subscrição premium necessária" + "message": "É necessária uma subscrição Premium" }, "organizationIsDisabled": { - "message": "Organization suspended." + "message": "Organização suspensa." }, "disabledOrganizationFilterError": { - "message": "Items in suspended Organizations cannot be accessed. Contact your Organization owner for assistance." + "message": "Não é possível aceder aos itens de organizações suspensas. Contacte o proprietário da organização para obter assistência." }, "loggingInTo": { "message": "A iniciar sessão em $DOMAIN$", @@ -2078,16 +2114,16 @@ "message": "para voltar às definições predefinidas" }, "serverVersion": { - "message": "Server version" + "message": "Versão do servidor" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "auto-hospedado" }, "thirdParty": { - "message": "Third-party" + "message": "De terceiros" }, "thirdPartyServerMessage": { - "message": "Connected to third-party server implementation, $SERVERNAME$. Please verify bugs using the official server, or report them to the third-party server.", + "message": "Ligado à implementação de um servidor de terceiros, $SERVERNAME$. Por favor, verifique os erros utilizando o servidor oficial ou reporte-os ao servidor de terceiros.", "placeholders": { "servername": { "content": "$1", @@ -2096,7 +2132,7 @@ } }, "lastSeenOn": { - "message": "last seen on: $DATE$", + "message": "visto pela última vez em: $DATE$", "placeholders": { "date": { "content": "$1", @@ -2117,55 +2153,55 @@ "message": "É novo por cá?" }, "rememberEmail": { - "message": "Lembrar e-mail" + "message": "Memorizar e-mail" }, "loginWithDevice": { - "message": "Log in with device" + "message": "Iniciar sessão com o dispositivo" }, "loginWithDeviceEnabledInfo": { - "message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?" + "message": "O início de sessão com o dispositivo deve ser ativado nas definições da aplicação Bitwarden. Precisa de outra opção?" }, "fingerprintPhraseHeader": { - "message": "Fingerprint phrase" + "message": "Frase de impressão digital" }, "fingerprintMatchInfo": { - "message": "Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device." + "message": "Por favor, certifique-se de que o cofre está desbloqueado e que a frase de impressão digital corresponde à do outro dispositivo." }, "resendNotification": { - "message": "Resend notification" + "message": "Reenviar notificação" }, "viewAllLoginOptions": { - "message": "View all log in options" + "message": "Ver todas as opções de início de sessão" }, "notificationSentDevice": { - "message": "A notification has been sent to your device." + "message": "Foi enviada uma notificação para o seu dispositivo." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "A preparar o início de sessão" }, "exposedMasterPassword": { - "message": "Exposed Master Password" + "message": "Palavra-passe mestra exposta" }, "exposedMasterPasswordDesc": { - "message": "Password found in a data breach. Use a unique password to protect your account. Are you sure you want to use an exposed password?" + "message": "Palavra-passe encontrada numa violação de dados. Utilize uma palavra-passe única para proteger a sua conta. Tem a certeza de que pretende utilizar uma palavra-passe exposta?" }, "weakAndExposedMasterPassword": { - "message": "Weak and Exposed Master Password" + "message": "Palavra-passe mestra fraca e exposta" }, "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": "Palavra-passe fraca identificada e encontrada numa violação de dados. Utilize uma palavra-passe forte e única para proteger a sua conta. Tem a certeza de que pretende utilizar esta palavra-passe?" }, "checkForBreaches": { - "message": "Check known data breaches for this password" + "message": "Verificar violações de dados conhecidas para esta palavra-passe" }, "important": { - "message": "Important:" + "message": "Importante:" }, "masterPasswordHint": { - "message": "Your master password cannot be recovered if you forget it!" + "message": "A sua palavra-passe mestra não pode ser recuperada se a esquecer!" }, "characterMinimum": { - "message": "$LENGTH$ character minimum", + "message": "$LENGTH$ caracteres no mínimo", "placeholders": { "length": { "content": "$1", @@ -2174,13 +2210,13 @@ } }, "autofillPageLoadPolicyActivated": { - "message": "Your organization policies have turned on auto-fill on page load." + "message": "As políticas da sua organização ativaram o preenchimento automático ao carregar a página." }, "howToAutofill": { - "message": "How to auto-fill" + "message": "Como preencher automaticamente" }, "autofillSelectInfoWithCommand": { - "message": "Select an item from this page or use the shortcut: $COMMAND$", + "message": "Selecione um item desta página ou utilize o atalho: $COMMAND$", "placeholders": { "command": { "content": "$1", @@ -2189,22 +2225,22 @@ } }, "autofillSelectInfoWithoutCommand": { - "message": "Select an item from this page or set a shortcut in settings." + "message": "Selecione um item desta página ou defina um atalho nas definições." }, "gotIt": { - "message": "Got it" + "message": "Percebido" }, "autofillSettings": { - "message": "Auto-fill settings" + "message": "Definições de preenchimento automático" }, "autofillShortcut": { - "message": "Auto-fill keyboard shortcut" + "message": "Atalho de teclado de preenchimento automático" }, "autofillShortcutNotSet": { - "message": "The auto-fill shortcut is not set. Change this in the browser's settings." + "message": "O atalho de preenchimento automático não está definido. Altere-o nas definições do navegador." }, "autofillShortcutText": { - "message": "The auto-fill shortcut is: $COMMAND$. Change this in the browser's settings.", + "message": "O atalho de preenchimento automático é: $COMMAND$. Altere-o nas definições do navegador.", "placeholders": { "command": { "content": "$1", @@ -2213,7 +2249,7 @@ } }, "autofillShortcutTextSafari": { - "message": "Default auto-fill shortcut: $COMMAND$.", + "message": "Atalho de preenchimento automático predefinido: $COMMAND$.", "placeholders": { "command": { "content": "$1", @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Região" + "loggingInOn": { + "message": "A iniciar sessão em" }, "opensInANewWindow": { - "message": "Opens in a new window" + "message": "Abrir numa nova janela" + }, + "deviceApprovalRequired": { + "message": "É necessária a aprovação do dispositivo. Selecione uma opção de aprovação abaixo:" + }, + "rememberThisDevice": { + "message": "Lembrar este dispositivo" + }, + "uncheckIfPublicDevice": { + "message": "Desmarcar se estiver a utilizar um dispositivo público" + }, + "approveFromYourOtherDevice": { + "message": "Aprovar a partir do seu outro dispositivo" + }, + "requestAdminApproval": { + "message": "Pedir aprovação do administrador" + }, + "approveWithMasterPassword": { + "message": "Aprovar com a palavra-passe mestra" + }, + "ssoIdentifierRequired": { + "message": "É necessário o identificador de SSO da organização." }, "eu": { "message": "UE", "description": "European Union" }, - "us": { - "message": "EUA", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Acesso negado. Não tem permissão para visualizar esta página." + }, + "general": { + "message": "Geral" + }, + "display": { + "message": "Ecrã" + }, + "accountSuccessfullyCreated": { + "message": "Conta criada com sucesso!" + }, + "adminApprovalRequested": { + "message": "Aprovação do administrador pedida" + }, + "adminApprovalRequestSentToAdmins": { + "message": "O seu pedido foi enviado ao seu administrador." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Será notificado quando for aprovado." + }, + "troubleLoggingIn": { + "message": "Problemas a iniciar sessão?" + }, + "loginApproved": { + "message": "Início de sessão aprovado" + }, + "userEmailMissing": { + "message": "E-mail do utilizador em falta" + }, + "deviceTrusted": { + "message": "Dispositivo de confiança" + }, + "inputRequired": { + "message": "Campo necessário." + }, + "required": { + "message": "necessário" + }, + "search": { + "message": "Procurar" + }, + "inputMinLength": { + "message": "O campo deve ter pelo menos $COUNT$ caracteres.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "O campo não pode exceder os $COUNT$ caracteres de comprimento.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Não são permitidos os seguintes caracteres: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "O valor do campo tem de ser, pelo menos, $MIN$ caracteres.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "O valor do campo não pode exceder os $MAX$ caracteres.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 ou mais e-mails são inválidos" + }, + "inputTrimValidator": { + "message": "O campo não deve conter apenas espaços em branco.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "O campo não é um endereço de e-mail." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ campo(s) acima precisa(m) da sua atenção.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Selecionar --" + }, + "multiSelectPlaceholder": { + "message": "-- Escreva para filtrar --" + }, + "multiSelectLoading": { + "message": "A recuperar opções..." + }, + "multiSelectNotFound": { + "message": "Nenhum item encontrado" + }, + "multiSelectClearAll": { + "message": "Limpar tudo" + }, + "plusNMore": { + "message": "+ $QUANTITY$", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Alternar colapso", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias de domínio" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Os itens que voltem a pedir a palavra-passe mestra não podem ser preenchidos automaticamente no carregamento da página. Preenchimento automático no carregamento da página desativado.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Preenchimento automático no carregamento da página definido para utilizar a predefinição.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Desativar o pedido para reintroduzir a palavra-passe mestra para editar este campo", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 47a684aa1ae..085951a93f1 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-completare" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generare parolă (s-a copiat)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Nu există potrivire de autentificări" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Deblocați-vă seiful" }, @@ -196,13 +220,13 @@ "message": "Ajutor și feedback" }, "helpCenter": { - "message": "Bitwarden Help center" + "message": "Centrul de Ajutor Bitwarden" }, "communityForums": { - "message": "Explore Bitwarden community forums" + "message": "Explorați forumurile comunității Bitwarden" }, "contactSupport": { - "message": "Contact Bitwarden support" + "message": "Contactați asistența Bitwarden" }, "sync": { "message": "Sincronizare" @@ -338,6 +362,9 @@ "other": { "message": "Altele" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Configurați metoda de deblocare care să schimbe acțiunea de expirare a seifului." + }, "rateExtension": { "message": "Evaluare extensie" }, @@ -439,7 +466,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": { @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Actualizare" }, + "notificationUnlockDesc": { + "message": "Deblocați seiful Bitwarden pentru a finaliza solicitarea de auto-completare." + }, + "notificationUnlock": { + "message": "Deblocare" + }, "enableContextMenuItem": { "message": "Afișați opțiunile meniului contextual" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Funcție indisponibilă" }, - "updateKey": { - "message": "Nu puteți utiliza această caracteristică înainte de a actualiza cheia de criptare." + "encryptionKeyMigrationRequired": { + "message": "Este necesară migrarea cheilor de criptare. Autentificați-vă prin intermediul seifului web pentru a vă actualiza cheia de criptare." }, "premiumMembership": { "message": "Abonament Premium" @@ -786,8 +819,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": "Opțiuni brevetate de conectare cu doi factori, cum ar fi YubiKey și Duo." }, "ppremiumSignUpReports": { "message": "Rapoarte privind igiena parolelor, sănătatea contului și breșele de date pentru a vă păstra seiful în siguranță." @@ -976,10 +1009,10 @@ "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 profita de auto-completarea la încărcare." }, "learnMoreAboutAutofill": { - "message": "Learn more about auto-fill" + "message": "Mai multe informații despre auto-completare" }, "defaultAutoFillOnPageLoad": { "message": "Setarea implicită de completare automată pentru articole de conectare" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restabilire articol" }, - "restoreItemConfirmation": { - "message": "Sigur doriți să restabiliți acest articol?" - }, "restoredItem": { "message": "Articol restabilit" }, @@ -1462,16 +1492,16 @@ "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: Acesta este un site HTTP nesecurizat. Orice informație transmisă poate fi vizualizată și modificată de alte persoane. Această autentificare a fost salvată inițial pe un site securizat (HTTPS)." }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "Încă mai doriți să completați acest login?" }, "autofillIframeWarning": { - "message": "The form is hosted by a different domain than the URI of your saved login. Choose OK to auto-fill anyway, or Cancel to stop." + "message": "Formularul este găzduit pe un alt domeniu decât adresa URI de autentificare salvată. Alegeți OK pentru completarea automată oricum sau Anulare pentru a opri." }, "autofillIframeWarningTip": { - "message": "To prevent this warning in the future, save this URI, $HOSTNAME$, to your Bitwarden login item for this site.", + "message": "Pe viitor, pentru a evita acest avertisment, înregistrați acest URI, $HOSTNAME$, în login-ul Bitwarden pentru acest site.", "placeholders": { "hostname": { "content": "$1", @@ -1483,13 +1513,13 @@ "message": "Setare parolă principală" }, "currentMasterPass": { - "message": "Current master password" + "message": "Parola principală actuală" }, "newMasterPass": { - "message": "New master password" + "message": "Noua parolă principală" }, "confirmNewMasterPass": { - "message": "Confirm new master password" + "message": "Confirmați noua parolă principală" }, "masterPasswordPolicyInEffect": { "message": "Una sau mai multe politici ale organizației necesită ca parola principală să îndeplinească următoarele cerințe:" @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Biometria browserului nu este acceptată pe acest dispozitiv." }, + "biometricsFailedTitle": { + "message": "Biometrica a eșuat" + }, + "biometricsFailedDesc": { + "message": "Verificarea biometrică nu poate fi finalizată. Încercați parola principală sau deconectați-vă. Dacă problema persistă, vă rugăm să contactați serviciul de asistență Bitwarden." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permisiunea nu a fost furnizată" }, @@ -1872,7 +1908,7 @@ "message": "Parola principală a fost schimbată recent de către un administrator din organizație. Pentru a accesa seiful, trebuie să o actualizați acum. Continuarea vă va deconecta de la sesiunea curentă, cerându-vă să vă conectați din nou. Sesiunile active de pe alte dispozitive pot continua să rămână active timp de până la o oră." }, "updateWeakMasterPasswordWarning": { - "message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." + "message": "Parola dvs. principală nu respectă una sau mai multe politici ale organizației. Pentru a accesa seiful, parola principală trebuie actualizată acum. În cazul în care continuați, veți fi deconectat din sesiunea curentă și va trebui să vă conectați din nou. Sesiunile active de pe alte dispozitive pot rămâne active timp de până la o oră." }, "resetPasswordPolicyAutoEnroll": { "message": "Înscrierea automată" @@ -1893,7 +1929,7 @@ "message": "Minute" }, "vaultTimeoutPolicyInEffect": { - "message": "Politicile organizației dvs vă afectează expirarea seifului. Timpul maxim permis de expirare a seifului este $HOURS$ oră (ore) și $MINUTES$ minut(e)", + "message": "Politicile organizației dvs. au stabilit timpul maxim de expirare permis pentru seif la $HOURS$ oră/ore și $MINUTES$ de minut(e).", "placeholders": { "hours": { "content": "$1", @@ -1906,7 +1942,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is $HOURS$ hour(s) and $MINUTES$ minute(s). Your vault timeout action is set to $ACTION$.", + "message": "Politicile organizației dvs. afectează timpul de expirare al seifului. Timpul maxim de așteptare permis pentru seif este de $HOURS$ oră(e) și $MINUTES$ minut(e). Acțiunea de temporizare a seifului este setată la $ACTION$.", "placeholders": { "hours": { "content": "$1", @@ -1923,7 +1959,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "Your organization policies have set your vault timeout action to $ACTION$.", + "message": "Politicile organizației dvs. au setat acțiunea de expirare a seifului la $ACTION$.", "placeholders": { "action": { "content": "$1", @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exportul seifului individual" }, - "exportingPersonalVaultDescription": { - "message": "Numai articolele de seif individuale asociate cu $EMAIL$ vor fi exportate. Articolele de seif ale organizației nu vor fi incluse.", + "exportingIndividualVaultDescription": { + "message": "Se exportă numai intrările din seiful personal asociate cu $EMAIL$. Nu sunt incluse intrările de seif ale organizației. Se exportă numai informațiile despre intrările din seif. Acestea nu includ atașamentele asociate.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Versiune server" }, - "selfHosted": { - "message": "Autogăzduit" + "selfHostedServer": { + "message": "auto-găzduit" }, "thirdParty": { "message": "Parte terță" @@ -2120,52 +2156,52 @@ "message": "Memorare e-mail" }, "loginWithDevice": { - "message": "Log in with device" + "message": "Conectați-vă cu dispozitivul" }, "loginWithDeviceEnabledInfo": { - "message": "Log in with device must be set up in the settings of the Bitwarden app. Need another option?" + "message": "Conectarea cu dispozitivul trebuie să fie configurată în setările aplicației Bitwarden. Aveți nevoie de o altă opțiune?" }, "fingerprintPhraseHeader": { - "message": "Fingerprint phrase" + "message": "Fraza amprentă" }, "fingerprintMatchInfo": { - "message": "Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device." + "message": "Asigurați-vă că seiful este deblocat și că fraza amprentă se potrivește cu cea de pe celălalt dispozitiv." }, "resendNotification": { - "message": "Resend notification" + "message": "Reîntoarceți notificarea" }, "viewAllLoginOptions": { - "message": "View all log in options" + "message": "Afișați toate opțiunile de conectare" }, "notificationSentDevice": { - "message": "A notification has been sent to your device." + "message": "O notificare a fost trimisă pe dispozitivul dvs." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Conectare inițiată" }, "exposedMasterPassword": { - "message": "Exposed Master Password" + "message": "Parolă principală compromisă" }, "exposedMasterPasswordDesc": { - "message": "Password found in a data breach. Use a unique password to protect your account. Are you sure you want to use an exposed password?" + "message": "Parola găsită în scurgerea de date. Folosiți o parolă unică pentru a vă proteja contul. Sunteți sigur că doriți să folosiți o parolă compromisă?" }, "weakAndExposedMasterPassword": { - "message": "Weak and Exposed Master Password" + "message": "Parolă principală slabă și compromisă" }, "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": "Parolă slabă identificată și găsită într-o scurgere de date. Folosiți o parolă puternică și unică pentru a vă proteja contul. Sunteți sigur că doriți să utilizați această parolă?" }, "checkForBreaches": { - "message": "Check known data breaches for this password" + "message": "Verificați scurgerile de date cunoscute pentru această parolă" }, "important": { "message": "Important:" }, "masterPasswordHint": { - "message": "Your master password cannot be recovered if you forget it!" + "message": "Parola principală nu poate fi recuperată dacă este uitată!" }, "characterMinimum": { - "message": "$LENGTH$ character minimum", + "message": "Minim $LENGTH$ caractere", "placeholders": { "length": { "content": "$1", @@ -2174,13 +2210,13 @@ } }, "autofillPageLoadPolicyActivated": { - "message": "Your organization policies have turned on auto-fill on page load." + "message": "Politicile organizației dvs. au activat auto-completarea la încărcarea paginii." }, "howToAutofill": { - "message": "How to auto-fill" + "message": "Instrucțiuni de auto-completare" }, "autofillSelectInfoWithCommand": { - "message": "Select an item from this page or use the shortcut: $COMMAND$", + "message": "Selectați un element din această pagină sau utilizați comanda rapidă: $COMMAND$", "placeholders": { "command": { "content": "$1", @@ -2189,22 +2225,22 @@ } }, "autofillSelectInfoWithoutCommand": { - "message": "Select an item from this page or set a shortcut in settings." + "message": "Selectați un element din această pagină sau setați o comandă rapidă în setări." }, "gotIt": { - "message": "Got it" + "message": "Am înțeles" }, "autofillSettings": { - "message": "Auto-fill settings" + "message": "Setări de auto-completare" }, "autofillShortcut": { - "message": "Auto-fill keyboard shortcut" + "message": "Scurtătură de tastatură pentru auto-completare" }, "autofillShortcutNotSet": { - "message": "The auto-fill shortcut is not set. Change this in the browser's settings." + "message": "Scurtătura de auto-completare nu este setată. Modificați acest lucru în setările browserului." }, "autofillShortcutText": { - "message": "The auto-fill shortcut is: $COMMAND$. Change this in the browser's settings.", + "message": "Scurtătura de auto-completare este: $COMMAND$. Modificați acest lucru în setările browserului.", "placeholders": { "command": { "content": "$1", @@ -2213,7 +2249,7 @@ } }, "autofillShortcutTextSafari": { - "message": "Default auto-fill shortcut: $COMMAND$.", + "message": "Scurtătură implicită de auto-completare: $COMMAND$.", "placeholders": { "command": { "content": "$1", @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Conectare la" }, "opensInANewWindow": { - "message": "Opens in a new window" + "message": "Se deschide într-o nouă fereastră" + }, + "deviceApprovalRequired": { + "message": "Este necesară aprobarea dispozitivului. Selectați o opțiune de autorizare de mai jos:" + }, + "rememberThisDevice": { + "message": "Memorizează acest dispozitiv" + }, + "uncheckIfPublicDevice": { + "message": "Debifați dacă utilizați un dispozitiv public" + }, + "approveFromYourOtherDevice": { + "message": "Aprobați de pe celălalt dispozitiv" + }, + "requestAdminApproval": { + "message": "Cereți aprobarea administratorului" + }, + "approveWithMasterPassword": { + "message": "Aprobați cu parola principală" + }, + "ssoIdentifierRequired": { + "message": "Identificatorul SSO al organizației este necesar." }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Acces refuzat. Nu aveți permisiunea de a vizualiza această pagină." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Afișare" + }, + "accountSuccessfullyCreated": { + "message": "Cont creat cu succes!" + }, + "adminApprovalRequested": { + "message": "Autorizație administrativă solicitată" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Cererea dvs. a fost trimisă administratorului." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Veți primi o notificare după aprobare." + }, + "troubleLoggingIn": { + "message": "Aveți probleme la logare?" + }, + "loginApproved": { + "message": "Autentificare aprobată" + }, + "userEmailMissing": { + "message": "Lipsește e-mailul utilizatorului" + }, + "deviceTrusted": { + "message": "Dispozitiv de încredere" + }, + "inputRequired": { + "message": "Este necesară o intrare." + }, + "required": { + "message": "necesar" + }, + "search": { + "message": "Căutare" + }, + "inputMinLength": { + "message": "Intrarea trebuie să aibă o lungime de cel puțin $COUNT$ caractere.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Intrarea nu trebuie să fie mai lungă de $COUNT$ caractere.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Următoarele caractere nu sunt permise: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Valoarea de intrare trebuie să fie cel puțin $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Valoarea de intrare nu trebuie să depășească $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 sau mai multe e-mailuri sunt invalide" + }, + "inputTrimValidator": { + "message": "Datele introduse nu trebuie să conțină numai spații.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Intrarea nu este o adresă de e-mail." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ câmp(uri) de mai sus necesită atenție.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Selectați --" + }, + "multiSelectPlaceholder": { + "message": "-- Scrieți pentru a filtra --" + }, + "multiSelectLoading": { + "message": "Recuperarea opțiunilor..." + }, + "multiSelectNotFound": { + "message": "Niciun element găsit" + }, + "multiSelectClearAll": { + "message": "Ștergeți tot" + }, + "plusNMore": { + "message": "+ $QUANTITY$ mai mult", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submeniu" + }, + "toggleCollapse": { + "message": "Comutare restrângere", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Elementele în care parola principală este solicitată din nou nu pot fi completate automat la încărcarea paginii. Completarea automată la încărcarea paginii este dezactivată.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Completarea automată la încărcarea paginii este setată la valoarea implicită.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Dezactivați reintroducerea parolei principale pentru a edita acest câmp", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 9da7f861dc8..40ecd382bd5 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Автозаполнение" }, + "autoFillLogin": { + "message": "Автозаполнение логина" + }, + "autoFillCard": { + "message": "Автозаполнение карты" + }, + "autoFillIdentity": { + "message": "Автозаполнение личности" + }, "generatePasswordCopied": { "message": "Сгенерировать пароль (с копированием)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Нет подходящих логинов." }, + "noCards": { + "message": "Нет карт" + }, + "noIdentities": { + "message": "Нет личностей" + }, + "addLoginMenu": { + "message": "Добавить логин" + }, + "addCardMenu": { + "message": "Добавить карту" + }, + "addIdentityMenu": { + "message": "Добавить личность" + }, "unlockVaultMenu": { "message": "Разблокировать хранилище" }, @@ -338,6 +362,9 @@ "other": { "message": "Прочее" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Настройте способ разблокировки для изменения действия по тайм-ауту хранилища." + }, "rateExtension": { "message": "Оценить расширение" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Обновить" }, + "notificationUnlockDesc": { + "message": "Разблокируйте свое хранилище Bitwarden чтобы выполнить автозаполнение." + }, + "notificationUnlock": { + "message": "Разблокировать" + }, "enableContextMenuItem": { "message": "Показать опции контекстного меню" }, @@ -662,7 +695,7 @@ "description": "'Solarized' is a noun and the name of a color scheme. It should not be translated." }, "exportVault": { - "message": "Экспортировать хранилище" + "message": "Экспорт хранилища" }, "fileFormat": { "message": "Формат файла" @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Функция недоступна" }, - "updateKey": { - "message": "Вы не можете использовать эту функцию, пока не обновите свой ключ шифрования." + "encryptionKeyMigrationRequired": { + "message": "Требуется миграция ключа шифрования. Чтобы обновить ключ шифрования, войдите через веб-хранилище." }, "premiumMembership": { "message": "Премиум" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "1 ГБ зашифрованного хранилища для вложенных файлов." }, - "ppremiumSignUpTwoStep": { - "message": "Дополнительные варианты двухэтапной аутентификации, такие как YubiKey, FIDO U2F и Duo." + "premiumSignUpTwoStepOptions": { + "message": "Проприетарные варианты двухэтапной аутентификации, такие как YubiKey или Duo." }, "ppremiumSignUpReports": { "message": "Гигиена паролей, здоровье аккаунта и отчеты об утечках данных для обеспечения безопасности вашего хранилища." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Восстановить элемент" }, - "restoreItemConfirmation": { - "message": "Вы уверены, что хотите восстановить этот элемент?" - }, "restoredItem": { "message": "Элемент восстановлен" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Биометрия в браузере не поддерживается этом устройстве." }, + "biometricsFailedTitle": { + "message": "Сбой биометрии" + }, + "biometricsFailedDesc": { + "message": "Не удалось выполнить биометрическую идентификацию, попробуйте использовать мастер-пароль или выполнить выход. Если ситуация не изменится, обратитесь в службу поддержки Bitwarden." + }, "nativeMessaginPermissionErrorTitle": { "message": "Разрешение не представлено" }, @@ -1872,7 +1908,7 @@ "message": "Мастер-пароль недавно был изменен администратором вашей организации. Чтобы получить доступ к хранилищу, вы должны обновить его сейчас. В результате текущий сеанс будет завершен, потребуется повторный вход. Сеансы на других устройствах могут оставаться активными в течение одного часа." }, "updateWeakMasterPasswordWarning": { - "message": "Ваш мастер-пароль не соответствует требованиям политики вашей организации. Для доступа к хранилищу вы должны обновить свой мастер-пароль прямо сейчас. При этом текущая сессия будет завершена и потребуется повторная авторизация. Сессии на других устройствах могут оставаться активными в течение часа." + "message": "Ваш мастер-пароль не соответствует требованиям политики вашей организации. Для доступа к хранилищу вы должны обновить свой мастер-пароль прямо сейчас. При этом текущий сеанс будет завершен и потребуется повторная авторизация. Сеансы на других устройствах могут оставаться активными в течение часа." }, "resetPasswordPolicyAutoEnroll": { "message": "Автоматическое развертывание" @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Экспорт личного хранилища" }, - "exportingPersonalVaultDescription": { - "message": "Будут экспортированы только личные элементы хранилища, связанные с $EMAIL$. Элементы хранилища организации включены не будут.", + "exportingIndividualVaultDescription": { + "message": "Будут экспортированы только отдельные элементы хранилища, связанные с $EMAIL$. Элементы хранилища организации включены не будут. Экспортируется только информация об элементах хранилища, не включая связанные вложения.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Версия сервера" }, - "selfHosted": { - "message": "Собственный хостинг" + "selfHostedServer": { + "message": "собственный хостинг" }, "thirdParty": { "message": "Сторонний" @@ -2129,7 +2165,7 @@ "message": "Фраза отпечатка" }, "fingerprintMatchInfo": { - "message": "Убедитесь, что ваше хранилище разблокировано и фраза отпечатка пальца совпадает на другом устройстве." + "message": "Убедитесь, что ваше хранилище разблокировано и фраза отпечатка совпадает на другом устройстве." }, "resendNotification": { "message": "Отправить уведомление повторно" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "На ваше устройство отправлено уведомление." }, - "logInInitiated": { + "loginInitiated": { "message": "Вход инициирован" }, "exposedMasterPassword": { @@ -2156,7 +2192,7 @@ "message": "Обнаружен слабый пароль, найденный в утечке данных. Используйте надежный и уникальный пароль для защиты вашего аккаунта. Вы уверены, что хотите использовать этот пароль?" }, "checkForBreaches": { - "message": "Проверьте известные случаи утечки данных для этого пароля" + "message": "Проверять известные случаи утечки данных для этого пароля" }, "important": { "message": "Важно:" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Регион" + "loggingInOn": { + "message": "Войти на" }, "opensInANewWindow": { "message": "Откроется в новом окне" }, + "deviceApprovalRequired": { + "message": "Требуется одобрение устройства. Выберите вариант ниже:" + }, + "rememberThisDevice": { + "message": "Запомнить это устройство" + }, + "uncheckIfPublicDevice": { + "message": "Снимите флажок, если используете общедоступное устройство" + }, + "approveFromYourOtherDevice": { + "message": "Одобрить с другого устройства" + }, + "requestAdminApproval": { + "message": "Запросить одобрение администратора" + }, + "approveWithMasterPassword": { + "message": "Одобрить с мастер-паролем" + }, + "ssoIdentifierRequired": { + "message": "Требуется идентификатор SSO организации." + }, "eu": { "message": "Европа", "description": "European Union" }, - "us": { - "message": "США", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Доступ запрещен. У вас нет разрешения на просмотр этой страницы." + }, + "general": { + "message": "Основное" + }, + "display": { + "message": "Отображение" + }, + "accountSuccessfullyCreated": { + "message": "Аккаунт успешно создан!" + }, + "adminApprovalRequested": { + "message": "Запрошено одобрение администратора" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Ваш запрос был отправлен администратору." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Вас уведомят об одобрении." + }, + "troubleLoggingIn": { + "message": "Не удалось войти?" + }, + "loginApproved": { + "message": "Вход одобрен" + }, + "userEmailMissing": { + "message": "Отсутствует email пользователя" + }, + "deviceTrusted": { + "message": "Доверенное устройство" + }, + "inputRequired": { + "message": "Необходимо ввести данные." + }, + "required": { + "message": "обязательно" + }, + "search": { + "message": "Поиск" + }, + "inputMinLength": { + "message": "Вводимые данные должны содержать не менее $COUNT$ символов.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Длина вводимых данных не должна превышать $COUNT$ символов.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Не допускаются следующие символы: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Вводимое значение должно быть не менее $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Вводимое значение не должно превышать $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "Один или несколько адресов email недействительны" + }, + "inputTrimValidator": { + "message": "Введенные данные не должны содержать только пробелы.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Введенные данные не являются адресом email." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ поля(ей) выше требуют вашего внимания.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Выбрать --" + }, + "multiSelectPlaceholder": { + "message": "-- Введите для фильтрации --" + }, + "multiSelectLoading": { + "message": "Получение параметров..." + }, + "multiSelectNotFound": { + "message": "Элементов не найдено" + }, + "multiSelectClearAll": { + "message": "Очистить все" + }, + "plusNMore": { + "message": "еще + $QUANTITY$", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Подменю" + }, + "toggleCollapse": { + "message": "Свернуть/развернуть", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Псевдоним домена" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Элементы с повторным запросом мастер-пароля не могут быть автоматически заполнены при загрузке страницы. Автозаполнение при загрузке страницы выключено.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Автозаполнение при загрузке страницы использует настройку по умолчанию.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Для редактирования этого поля отключите повторный запрос мастер-пароля", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 94baba9b6da..8ea364cb6d1 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "ස්වයං-පිරවීම" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "මුරපදය ජනනය (පිටපත්)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "ගැලපෙන පිවිසුම් නොමැත." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "වෙනත්" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "දිගුව අනුපාතය" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "යාවත්කාල" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "විශේෂාංගය ලබාගත නොහැක" }, - "updateKey": { - "message": "ඔබ ඔබේ සංකේතාංකන යතුර යාවත්කාලීන කරන තුරු ඔබට මෙම අංගය භාවිතා කළ නොහැක." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "වාරික සාමාජිකත්වය" @@ -786,8 +819,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 නය වාර්තා කරයි." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "අයිතමය යළි පිහිටුවන්න" }, - "restoreItemConfirmation": { - "message": "ඔබට මෙම අයිතමය යථා තත්වයට පත් කිරීමට අවශ්ය බව ඔබට විශ්වාසද?" - }, "restoredItem": { "message": "ප්රතිෂ්ඨාපනය අයිතමය" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "බ්රව්සර් biometrics මෙම උපාංගය මත සහය නොදක්වයි." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "අවසර ලබා දී නැත" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 2fa04daf3df..d04f2ccf05a 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Automatické vypĺňanie" }, + "autoFillLogin": { + "message": "Automatické vyplnenie prihlasovacích údajov" + }, + "autoFillCard": { + "message": "Automatické vyplnenie karty" + }, + "autoFillIdentity": { + "message": "Automatické vyplnenie identity" + }, "generatePasswordCopied": { "message": "Vygenerovať heslo (skopírované)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Žiadne zodpovedajúce prihlasovacie údaje." }, + "noCards": { + "message": "Žiadne karty" + }, + "noIdentities": { + "message": "Žiadne identity" + }, + "addLoginMenu": { + "message": "Pridať prihlasovacie údaje" + }, + "addCardMenu": { + "message": "Pridať kartu" + }, + "addIdentityMenu": { + "message": "Pridať identitu" + }, "unlockVaultMenu": { "message": "Odomknúť trezor" }, @@ -338,6 +362,9 @@ "other": { "message": "Ostatné" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Nastavte metódu odomknutia, aby ste zmenili akciu pri vypršaní času trezoru." + }, "rateExtension": { "message": "Ohodnotiť rozšírenie" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Aktualizovať" }, + "notificationUnlockDesc": { + "message": "Odomknite svoj Bitwarden trezor a dokončite žiadosť o automatické vyplnenie." + }, + "notificationUnlock": { + "message": "Odomknúť" + }, "enableContextMenuItem": { "message": "Zobraziť možnosti kontextovej ponuky" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Funkcia nie je k dispozícii" }, - "updateKey": { - "message": "Túto funkciu nemožno použiť, pokým neaktualizujete svoj šifrovací kľúč." + "encryptionKeyMigrationRequired": { + "message": "Vyžaduje sa migrácia šifrovacieho kľúča. Na aktualizáciu šifrovacieho kľúča sa prihláste cez webový trezor." }, "premiumMembership": { "message": "Prémiové členstvo" @@ -786,8 +819,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čí." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Obnoviť položku" }, - "restoreItemConfirmation": { - "message": "Naozaj chcete obnoviť túto položku?" - }, "restoredItem": { "message": "Obnovená položka" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Biometria v prehliadači nie je podporovaná na tomto zariadení." }, + "biometricsFailedTitle": { + "message": "Biometria zlyhala" + }, + "biometricsFailedDesc": { + "message": "Biometria nebola vykonaná. Zvážte použitie hlavného hesla, alebo sa odhláste. Ak tento problém pretrváva, kontaktujte podporu Bitwarden." + }, "nativeMessaginPermissionErrorTitle": { "message": "Povolenie nebolo udelené" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exportovanie osobného trezora" }, - "exportingPersonalVaultDescription": { - "message": "Exportované budú iba položy osobného trezora spojené s $EMAIL$. Položky trezora organizácie nebudú zahrnuté.", + "exportingIndividualVaultDescription": { + "message": "Exportované budú iba položky súvisiace s $EMAIL$. Položky z trezora organizácie nebudú zahrnuté v exporte. Export bude obsahovať iba informácie z položiek v trezore, súvisiace prílohy nebudú súčasťou exportu.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Verzia servera" }, - "selfHosted": { - "message": "Vlastný hosting" + "selfHostedServer": { + "message": "vlastný hosting" }, "thirdParty": { "message": "Tretia strana" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Do vášho zariadenia bolo odoslané upozornenie." }, - "logInInitiated": { + "loginInitiated": { "message": "Iniciované prihlásenie" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Región" + "loggingInOn": { + "message": "Prihlásenie na" }, "opensInANewWindow": { "message": "Otvárať v novom okne" }, + "deviceApprovalRequired": { + "message": "Vyžaduje sa schválenie zariadenia. Vyberte možnosť schválenia nižšie:" + }, + "rememberThisDevice": { + "message": "Zapamätať si toto zariadenie" + }, + "uncheckIfPublicDevice": { + "message": "Odčiarknite, ak používate verejné zariadenie" + }, + "approveFromYourOtherDevice": { + "message": "Schváliť z iného zariadenia" + }, + "requestAdminApproval": { + "message": "Žiadosť o schválenie správcom" + }, + "approveWithMasterPassword": { + "message": "Schváliť pomocou hlavného hesla" + }, + "ssoIdentifierRequired": { + "message": "Pole identifikátora SSO je povinné." + }, "eu": { "message": "EÚ", "description": "European Union" }, - "us": { - "message": "USA", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Prístup zamietnutý. Nemáte oprávnenie na zobrazenie tejto stránky." + }, + "general": { + "message": "Všeobecné" + }, + "display": { + "message": "Zobrazenie" + }, + "accountSuccessfullyCreated": { + "message": "Účet bol úspešne vytvorený!" + }, + "adminApprovalRequested": { + "message": "Vyžaduje sa schválenie správcom" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Vaša žiadosť bola odoslaná správcovi." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Po schválení budete informovaný." + }, + "troubleLoggingIn": { + "message": "Máte problémy s prihlásením?" + }, + "loginApproved": { + "message": "Schválené prihlásenie" + }, + "userEmailMissing": { + "message": "Chýba e-mail používateľa" + }, + "deviceTrusted": { + "message": "Dôveryhodné zariadenie" + }, + "inputRequired": { + "message": "Vstup je povinný." + }, + "required": { + "message": "povinné" + }, + "search": { + "message": "Hladať" + }, + "inputMinLength": { + "message": "Vstup musí mať aspoň $COUNT$ znakov.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Nemôžete zadať viac než $COUNT$ znakov.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Tieto znaky nie sú povolené: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Hodnota musí byť aspoň $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Zadaná hodnota nesmie prekročiť $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 alebo viac e-mailov je neplatných" + }, + "inputTrimValidator": { + "message": "Vstup nesmie obsahovať iba prázdne znaky.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Vstupom nie je e-mailová adresa." + }, + "fieldsNeedAttention": { + "message": "Niektoré polia ($COUNT$) vyžadujú vašu pozornosť.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Vyberte --" + }, + "multiSelectPlaceholder": { + "message": "-- Začnite písať na filtrovanie --" + }, + "multiSelectLoading": { + "message": "Načítavajú sa možnosti..." + }, + "multiSelectNotFound": { + "message": "Nenašli sa žiadne položky" + }, + "multiSelectClearAll": { + "message": "Vyčistiť všetko" + }, + "plusNMore": { + "message": "+ $QUANTITY$ ďalších", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Podponuka" + }, + "toggleCollapse": { + "message": "Prepnúť zbalenie", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias doména" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Položky, ktoré vyžadujú opätovné zadanie hlavného hesla sa nedajú automaticky vyplniť pri načítaní stránky. Automatické vypĺňanie pri načítaní stránky je vypnuté.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Automatické vypĺňanie pri načítaní stránky nastavené na pôvodnú predvoľbu.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Vypnite výzvu na opätovné zadanie hlavného hesla na úpravu tohto poľa", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 53c32c686c5..5f859e9e5f3 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Samodejno izpolnjevanje" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generiraj geslo (kopirano)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Ni ustreznih prijav." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Odkleni svoj trezor" }, @@ -338,6 +362,9 @@ "other": { "message": "Drugo" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Ocenite to razširitev" }, @@ -464,7 +491,7 @@ "message": "Neveljavna koda za preverjanje" }, "valueCopied": { - "message": "$VALUE$ kopirano", + "message": "$VALUE$ kopirana", "description": "Value has been copied to the clipboard.", "placeholders": { "value": { @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Da, posodobi zdaj" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Prikaži možnosti kontekstnega menuja" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Funkcija ni na voljo." }, - "updateKey": { - "message": "To funkcijo lahko uporabite šele, ko posodobite svoj šifrirni ključ." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium članstvo" @@ -786,8 +819,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." @@ -1042,11 +1075,11 @@ "message": "Logična vrednost" }, "cfTypeLinked": { - "message": "Linked", + "message": "Povezano polje", "description": "This describes a field that is 'linked' (tied) to another field." }, "linkedValue": { - "message": "Linked value", + "message": "Povezana vrednost", "description": "This describes a value that is 'linked' (tied) to another value." }, "popup2faCloseMessage": { @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Obnovi element" }, - "restoreItemConfirmation": { - "message": "Ste prepričani, da želite obnoviti ta element?" - }, "restoredItem": { "message": "Element obnovljen" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Dovoljenje manjka" }, @@ -1762,18 +1798,18 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendDisableDesc": { - "message": "Onemogoči to pošiljko, da nihče ne more dostopati do nje", + "message": "Onemogoči to pošiljko, da nihče ne more dostopati do nje.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendShareDesc": { - "message": "Kopiraj povezavo te pošiljke v odložišče po shranjevanju", + "message": "Kopiraj povezavo te pošiljke v odložišče, ko shranim.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendTextDesc": { "message": "Besedilo, ki ga želite poslati" }, "sendHideText": { - "message": "Privzeto skrij besedilo te pošiljke", + "message": "Privzeto skrij besedilo te pošiljke.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "currentAccessCount": { @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Izvoženi bodo samo posamezni elementi trezorja, ki so povezani z $EMAIL$. Elementi trezorjev organizacij ne bodo izvoženi.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Verzija strežnika" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Odpre se v novem oknu" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 952ae901451..d55a5bfe197 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Аутоматско допуњавање" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Генериши Лозинку (копирано)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Нема одговарајућих пријављивања." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Откључај свој сеф" }, @@ -338,6 +362,9 @@ "other": { "message": "Остало" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Подесите метод откључавања да бисте променили радњу временског ограничења сефа." + }, "rateExtension": { "message": "Оцени овај додатак" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Ажурирај" }, + "notificationUnlockDesc": { + "message": "Откључати Bitwarden сеф да би извршили ауто-пуњење." + }, + "notificationUnlock": { + "message": "Откључај" + }, "enableContextMenuItem": { "message": "Прикажи контекстни мени" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Функција је недоступна" }, - "updateKey": { - "message": "Не можете да користите ову способност док не промените Ваш кључ за шифровање." + "encryptionKeyMigrationRequired": { + "message": "Потребна је миграција кључа за шифровање. Пријавите се преко веб сефа да бисте ажурирали кључ за шифровање." }, "premiumMembership": { "message": "Премијум чланство" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "1ГБ шифровано складиште за прилоге." }, - "ppremiumSignUpTwoStep": { - "message": "Додатне опције пријаве у два корака као што су YubiKey, FIDO U2F, и Duo." + "premiumSignUpTwoStepOptions": { + "message": "Приоритарне опције пријаве у два корака као што су YubiKey и Duo." }, "ppremiumSignUpReports": { "message": "Извештаји о хигијени лозинки, здравственом стању налога и кршењу података да бисте заштитили сеф." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Врати ставку" }, - "restoreItemConfirmation": { - "message": "Да ли сте сигурни да желите да вратите ову ставку?" - }, "restoredItem": { "message": "Ставка враћена" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Биометрија прегледача није подржана на овом уређају." }, + "biometricsFailedTitle": { + "message": "Биометрија није успела" + }, + "biometricsFailedDesc": { + "message": "Биометрија се не може завршити, размислите о коришћењу главне лозинке или одјавите се. Ако се ово настави, контактирајте подршку Bitwarden-а." + }, "nativeMessaginPermissionErrorTitle": { "message": "Дозвола није дата" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Извоз личног сефа" }, - "exportingPersonalVaultDescription": { - "message": "Само предмети личног сефа повезани са $EMAIL$ биће извезени. Ставке организационог сефа неће бити укључене.", + "exportingIndividualVaultDescription": { + "message": "Само појединачне ставке сефа повезане са $EMAIL$ ће бити извењене. Ставке организационог сефа неће бити укључене. Само информације о ставкама из сефа ће бити извезене и неће укључивати повезане прилоге.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Верзија сервера" }, - "selfHosted": { - "message": "Личан хостинг" + "selfHostedServer": { + "message": "личан хостинг" }, "thirdParty": { "message": "Трећа страна" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Обавештење је послато на ваш уређај." }, - "logInInitiated": { + "loginInitiated": { "message": "Пријава је покренута" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Регион" + "loggingInOn": { + "message": "Пријављено на" }, "opensInANewWindow": { "message": "Отвара се у новом прозору" }, + "deviceApprovalRequired": { + "message": "Потребно је одобрење уређаја. Изаберите опцију одобрења испод:" + }, + "rememberThisDevice": { + "message": "Запамти овај уређај" + }, + "uncheckIfPublicDevice": { + "message": "Искључите ако се користи јавни уређај" + }, + "approveFromYourOtherDevice": { + "message": "Одобри са мојим другим уређајем" + }, + "requestAdminApproval": { + "message": "Затражити одобрење администратора" + }, + "approveWithMasterPassword": { + "message": "Одобрити са главном лозинком" + }, + "ssoIdentifierRequired": { + "message": "Потребан је SSO идентификатор организације." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Одбијен приступ. Немате дозволу да видите ову страницу." + }, + "general": { + "message": "Опште" + }, + "display": { + "message": "Приказ" + }, + "accountSuccessfullyCreated": { + "message": "Налог је успешно креиран!" + }, + "adminApprovalRequested": { + "message": "Захтевано је одобрење администратора" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Ваш захтев је послат вашем администратору." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Бићете обавештени када буде одобрено." + }, + "troubleLoggingIn": { + "message": "Имате проблема са пријављивањем?" + }, + "loginApproved": { + "message": "Пријава је одобрена" + }, + "userEmailMissing": { + "message": "Недостаје имејл корисника" + }, + "deviceTrusted": { + "message": "Уређај поуздан" + }, + "inputRequired": { + "message": "Унос је потребан." + }, + "required": { + "message": "обавезно" + }, + "search": { + "message": "Тражи" + }, + "inputMinLength": { + "message": "Унос трба имати најмање $COUNT$ слова.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Унос не сме бити већи од $COUNT$ карактера.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Следећи знакови нису дозвољени: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Вредност мора бити најмање $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Вредност не сме бити већа од $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 или више имејлова су неважећи" + }, + "inputTrimValidator": { + "message": "Унос не сме да садржи само размак.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Унос није имејл." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ поље(а) изнад захтевај(у) вашу пажњу.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Одабрати --" + }, + "multiSelectPlaceholder": { + "message": "-- Тип за филтрирање --" + }, + "multiSelectLoading": { + "message": "Преузимање опција..." + }, + "multiSelectNotFound": { + "message": "Нема предмета" + }, + "multiSelectClearAll": { + "message": "Обриши све" + }, + "plusNMore": { + "message": "+ још $QUANTITY$", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Под-мени" + }, + "toggleCollapse": { + "message": "Промени проширење", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Домен алијаса" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Ставке са упитом за поновно постављање главне лозинке не могу се ауто-попунити при учитавању странице. Ауто-попуњавање при учитавању странице је искључено.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Ауто-попуњавање при учитавању странице је подешено да користи подразумевано подешавање.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Искључите поновни упит главне лозинке да бисте уредили ово поље", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 79afc8729d5..ca0b8de6580 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -11,7 +11,7 @@ "description": "Extension description" }, "loginOrCreateNewAccount": { - "message": "Logga in eller skapa ett nytt konto för att komma åt dina lösenord." + "message": "Logga in eller skapa ett nytt konto för att komma åt ditt säkra valv." }, "createAccount": { "message": "Skapa konto" @@ -91,6 +91,15 @@ "autoFill": { "message": "Fyll i automatiskt" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Skapa lösenord (kopierad)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Inga matchande inloggningar" }, + "noCards": { + "message": "Inga kort" + }, + "noIdentities": { + "message": "Inga identiteter" + }, + "addLoginMenu": { + "message": "Lägg till inloggning" + }, + "addCardMenu": { + "message": "Lägg till kort" + }, + "addIdentityMenu": { + "message": "Lägg till identitet" + }, "unlockVaultMenu": { "message": "Lås upp ditt valv" }, @@ -338,11 +362,14 @@ "other": { "message": "Annat" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Betygsätt tillägget" }, "rateExtensionDesc": { - "message": "Överväg gärna att hjälpa oss genom att ge oss en bra recension!" + "message": "Överväg gärna att skriva en recension om oss!" }, "browserNotSupportClipboard": { "message": "Din webbläsare har inte stöd för att enkelt kopiera till urklipp. Kopiera till urklipp manuellt istället." @@ -370,7 +397,7 @@ } }, "invalidMasterPassword": { - "message": "Felaktigt huvudlösenord" + "message": "Ogiltigt huvudlösenord" }, "vaultTimeout": { "message": "Valvets tidsgräns" @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Uppdatera" }, + "notificationUnlockDesc": { + "message": "Lås upp ditt Bitwarden-valv för att slutföra begäran om automatisk ifyllnad." + }, + "notificationUnlock": { + "message": "Lås upp" + }, "enableContextMenuItem": { "message": "Visa alternativ för snabbmenyn" }, @@ -730,7 +763,7 @@ "message": "Kopiera verifieringskod" }, "attachments": { - "message": "Bifogade filer" + "message": "Bilagor" }, "deleteAttachment": { "message": "Radera bilaga" @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Funktion ej tillgänglig" }, - "updateKey": { - "message": "Du kan inte använda denna funktion förrän du uppdaterar din krypteringsnyckel." + "encryptionKeyMigrationRequired": { + "message": "Migrering av krypteringsnyckel krävs. Logga in på webbvalvet för att uppdatera din krypteringsnyckel." }, "premiumMembership": { "message": "Premium-medlemskap" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server-URL" }, "apiUrl": { - "message": "API server-URL" + "message": "API-server-URL" }, "webVaultUrl": { "message": "Webbvalvsserver-URL" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Återställ objekt" }, - "restoreItemConfirmation": { - "message": "Är du säker på att du vill återställa detta objekt?" - }, "restoredItem": { "message": "Återställde objekt" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Biometri i webbläsaren stöds inte på den här enheten." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Behörighet ej beviljad" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporterar individuellt valv" }, - "exportingPersonalVaultDescription": { - "message": "Endast de personliga valvobjekt som är associerade med $EMAIL$ kommer att exporteras. Organisationens valvobjekt kommer inte att inkluderas.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Serverversion" }, - "selfHosted": { - "message": "Lokalt installerad" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Tredje part" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "En avisering har skickats till din enhet." }, - "logInInitiated": { + "loginInitiated": { "message": "Inloggning påbörjad" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logga in på" }, "opensInANewWindow": { "message": "Öppnas i ett nytt fönster" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Kom ihåg denna enhet" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Godkänn med huvudlösenord" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Du kommer att meddelas vid godkännande." + }, + "troubleLoggingIn": { + "message": "Problem med att logga in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "obligatoriskt" + }, + "search": { + "message": "Sök" + }, + "inputMinLength": { + "message": "Inmatningen måste innehålla minst $COUNT$ tecken.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Följande tecken är inte tillåtna: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "En eller flera e-postadresser är ogiltiga" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Inmatningen är inte en e-postadress." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ fält ovan kräver din uppmärksamhet.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Välj --" + }, + "multiSelectPlaceholder": { + "message": "-- Skriv för att filtrera --" + }, + "multiSelectLoading": { + "message": "Hämtar alternativ..." + }, + "multiSelectNotFound": { + "message": "Inga objekt hittades" + }, + "multiSelectClearAll": { + "message": "Rensa alla" + }, + "plusNMore": { + "message": "+ $QUANTITY$ till", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Undermeny" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Aliasdomän" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 69d26333a84..22330901579 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Auto-fill" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "No matching logins" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Unlock your vault" }, @@ -338,6 +362,9 @@ "other": { "message": "Other" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Update" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "Show context menu options" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" @@ -786,8 +819,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." @@ -952,7 +985,7 @@ "message": "Server URL" }, "apiUrl": { - "message": "API Server URL" + "message": "API server URL" }, "webVaultUrl": { "message": "Web vault server URL" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Restore item" }, - "restoreItemConfirmation": { - "message": "Are you sure you want to restore this item?" - }, "restoredItem": { "message": "Item restored" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 1e43987ab06..20702a1de4a 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "กรอกข้อมูลอัตโนมัติ" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Generate Password (copied)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "ไม่พบข้อมูลล็อกอินที่ตรงกัน" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "ปลดล็อกกตู้นิรภัยของคุณ" }, @@ -338,6 +362,9 @@ "other": { "message": "อื่น ๆ" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Rate the Extension" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Yes, Update Now" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "Unlock" + }, "enableContextMenuItem": { "message": "แสดงตัวเลือกเมนูบริบท" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Feature Unavailable" }, - "updateKey": { - "message": "คุณไม่สามารถใช้คุณลักษณะนี้ได้จนกว่าคุณจะปรับปรุงคีย์การเข้ารหัสลับของคุณ" + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium Membership" @@ -786,8 +819,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": "สุขอนามัยของรหัสผ่าน ความสมบูรณ์ของบัญชี และรายงานการละเมิดข้อมูลเพื่อให้ตู้นิรภัยของคุณปลอดภัย" @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "กู้คืนรายการ" }, - "restoreItemConfirmation": { - "message": "คุณแน่ใจหรือไม่ว่าต้องการกู้คืนรายการนี้" - }, "restoredItem": { "message": "คืนค่ารายการแล้ว" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Browser biometrics is not supported on this device." }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Permission not provided" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Server version" }, - "selfHosted": { - "message": "Self-hosted" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "Third-party" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "A notification has been sent to your device." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Exposed Master Password" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index cdc8e109fe4..e9763386aa6 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Otomatik doldur" }, + "autoFillLogin": { + "message": "Hesabı otomatik doldur" + }, + "autoFillCard": { + "message": "Kartı otomatik doldur" + }, + "autoFillIdentity": { + "message": "Kimliği otomatik doldur" + }, "generatePasswordCopied": { "message": "Parola oluştur (ve kopyala)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Eşleşen hesap yok" }, + "noCards": { + "message": "Kart yok" + }, + "noIdentities": { + "message": "Kimlik yok" + }, + "addLoginMenu": { + "message": "Hesap ekle" + }, + "addCardMenu": { + "message": "Kart ekle" + }, + "addIdentityMenu": { + "message": "Kimlik ekle" + }, "unlockVaultMenu": { "message": "Kasanızın kilidini açın" }, @@ -338,6 +362,9 @@ "other": { "message": "Diğer" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Kasa zaman aşımı eyleminizi değiştirmek için kilit açma yönteminizi ayarlayın." + }, "rateExtension": { "message": "Uzantıyı değerlendirin" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Güncelle" }, + "notificationUnlockDesc": { + "message": "Otomatik doldurma isteğini tamamlamak için Bitwarden kasanızın kilidini açın." + }, + "notificationUnlock": { + "message": "Kilidi aç" + }, "enableContextMenuItem": { "message": "Bağlam menüsü seçeneklerini göster" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Özellik kullanılamıyor" }, - "updateKey": { - "message": "Şifreleme anahtarınızı güncellemeden bu özelliği kullanamazsınız." + "encryptionKeyMigrationRequired": { + "message": "Şifreleme anahtarınızın güncellenmesi gerekiyor. Şifreleme anahtarınızı güncellemek için lütfen web kasasına giriş yapın." }, "premiumMembership": { "message": "Premium üyelik" @@ -786,8 +819,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ı." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Kaydı geri yükle" }, - "restoreItemConfirmation": { - "message": "Bu kaydı geri yüklemek istediğinizden emin misiniz?" - }, "restoredItem": { "message": "Kayıt geri yüklendi" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Tarayıcı biyometrisi bu cihazda desteklenmiyor." }, + "biometricsFailedTitle": { + "message": "Biyometri doğrulanamadı" + }, + "biometricsFailedDesc": { + "message": "Biyometri doğrulaması tamamlanamadı. Ana parolanızı kullanabilir veya çıkış yapabilirsiniz. Sorun devam ederse Bitwarden destek ekibiyle iletişime geçin." + }, "nativeMessaginPermissionErrorTitle": { "message": "İzin verilmedi" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Kişisel kasa dışa aktarılıyor" }, - "exportingPersonalVaultDescription": { - "message": "Yalnızca $EMAIL$ ile ilişkili kişisel kasadaki kayıtlar dışa aktarılacaktır. Kuruluş kasasındaki kayıtlar dahil edilmeyecektir.", + "exportingIndividualVaultDescription": { + "message": "Yalnızca $EMAIL$ ile ilişkili kasa kayıtları dışa aktarılacaktır. Kuruluş kasasındaki kayıtlar dahil edilmeyecektir. Yalnızca kasa kayıt bilgileri dışa aktarılacak, kayıtlara eklenen dosyalar aktarılmayacaktır.", "placeholders": { "email": { "content": "$1", @@ -2042,7 +2078,7 @@ "description": "Part of a URL." }, "apiAccessToken": { - "message": "API erişim anahtarı" + "message": "API erişim token'ı" }, "apiKey": { "message": "API anahtarı" @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Sunucu sürümü" }, - "selfHosted": { - "message": "Barındırılan" + "selfHostedServer": { + "message": "şirket içinde barındırılan" }, "thirdParty": { "message": "Üçüncü taraf" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Cihazınıza bir bildirim gönderildi." }, - "logInInitiated": { + "loginInitiated": { "message": "Giriş başlatıldı" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Bölge" + "loggingInOn": { + "message": "Giriş yapılan konum" }, "opensInANewWindow": { "message": "Yeni pencerede açılır" }, + "deviceApprovalRequired": { + "message": "Cihaz onayı gerekiyor. Lütfen onay yönteminizi seçin:" + }, + "rememberThisDevice": { + "message": "Bu cihazı hatırla" + }, + "uncheckIfPublicDevice": { + "message": "Paylaşılan bir cihaz kullanıyorsanız işaretlemeyin" + }, + "approveFromYourOtherDevice": { + "message": "Diğer cihazımdan onayla" + }, + "requestAdminApproval": { + "message": "Yönetici onayı iste" + }, + "approveWithMasterPassword": { + "message": "Ana parola ile onayla" + }, + "ssoIdentifierRequired": { + "message": "Kuruluş SSO tanımlayıcısı gereklidir." + }, "eu": { "message": "AB", "description": "European Union" }, - "us": { - "message": "ABD", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Erişim engellendi. Bu sayfayı görüntüleme iznine sahip değilsiniz." + }, + "general": { + "message": "Genel" + }, + "display": { + "message": "Görünüm" + }, + "accountSuccessfullyCreated": { + "message": "Hesap başarıyla oluşturuldu!" + }, + "adminApprovalRequested": { + "message": "Yönetici onayı istendi" + }, + "adminApprovalRequestSentToAdmins": { + "message": "İsteğiniz yöneticinize gönderildi." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Onaylandıktan sonra bilgilendirileceksiniz." + }, + "troubleLoggingIn": { + "message": "Giriş yaparken sorun mu yaşıyorsunuz?" + }, + "loginApproved": { + "message": "Giriş onaylandı" + }, + "userEmailMissing": { + "message": "Kullanıcının e-postası eksik" + }, + "deviceTrusted": { + "message": "Cihaza güvenildi" + }, + "inputRequired": { + "message": "Girdi gerekli." + }, + "required": { + "message": "gerekli" + }, + "search": { + "message": "Ara" + }, + "inputMinLength": { + "message": "Girdi en az $COUNT$ karakter uzunluğunda olmalıdır.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Girdi $COUNT$ karakter uzunluğunu geçmemelidir.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Şu karakterlere izin verilmez: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Girdi değeri en az $MIN$ olmalı.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Girdi değeri en fazla $MAX$ olmalı.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "Bir veya daha fazla e-posta geçersiz" + }, + "inputTrimValidator": { + "message": "Girdi yalnızca boşluktan ibaret olamaz.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Girdi bir e-posta adresi değil." + }, + "fieldsNeedAttention": { + "message": "Yukarıdaki $COUNT$ alanla ilgilenmeniz gerekiyor.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Seçin --" + }, + "multiSelectPlaceholder": { + "message": "-- Filtrelemek için yazın --" + }, + "multiSelectLoading": { + "message": "Seçenekler alınıyor..." + }, + "multiSelectNotFound": { + "message": "Hiç kayıt bulunamadı" + }, + "multiSelectClearAll": { + "message": "Tümünü temizle" + }, + "plusNMore": { + "message": "+ $QUANTITY$ tane daha", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Alt menü" + }, + "toggleCollapse": { + "message": "Daraltmayı aç/kapat", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias alan adı" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Ana parolayı yeniden isteyen kayıtlar sayfa yüklendiğinde otomatik olarak doldurulamaz. Sayfa yüklendiğinde otomatik doldurma kapatıldı.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Sayfa yüklendiğinde otomatik doldurma, varsayılan ayarı kullanacak şekilde ayarlandı.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Bu alanı düzenlemek için ana parolayı yeniden istemeyi kapatın", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 1969bc33624..666870146d4 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Автозаповнення" }, + "autoFillLogin": { + "message": "Автозаповнення входу" + }, + "autoFillCard": { + "message": "Автозаповнення картки" + }, + "autoFillIdentity": { + "message": "Автозаповнення особистих даних" + }, "generatePasswordCopied": { "message": "Генерувати пароль (з копіюванням)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Немає відповідних записів" }, + "noCards": { + "message": "Немає карток" + }, + "noIdentities": { + "message": "Немає особистих даних" + }, + "addLoginMenu": { + "message": "Додати запис входу" + }, + "addCardMenu": { + "message": "Додати картку" + }, + "addIdentityMenu": { + "message": "Додати особисті дані" + }, "unlockVaultMenu": { "message": "Розблокуйте сховище" }, @@ -285,7 +309,7 @@ "message": "Змінити" }, "view": { - "message": "Перегляд" + "message": "Переглянути" }, "noItemsInList": { "message": "Немає записів." @@ -321,7 +345,7 @@ "message": "Видалити запис" }, "viewItem": { - "message": "Перегляд запису" + "message": "Переглянути запис" }, "launch": { "message": "Перейти" @@ -338,6 +362,9 @@ "other": { "message": "Інше" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Налаштуйте спосіб розблокування, щоб змінити час очікування сховища." + }, "rateExtension": { "message": "Оцінити розширення" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Оновити" }, + "notificationUnlockDesc": { + "message": "Розблокуйте своє сховище Bitwarden, щоб завершити запит автозаповнення." + }, + "notificationUnlock": { + "message": "Розблокувати" + }, "enableContextMenuItem": { "message": "Показувати в контекстному меню" }, @@ -675,7 +708,7 @@ "message": "Підтвердити експорт сховища" }, "exportWarningDesc": { - "message": "Експортовані дані вашого сховища знаходяться в незашифрованому вигляді. Вам не слід зберігати чи надсилати їх через незахищені канали (наприклад, е-поштою). Після використання негайно видаліть їх." + "message": "Ваші експортовані дані сховища незашифровані. Не зберігайте і не надсилайте їх незахищеними каналами (як-от електронна пошта). Після використання негайно видаліть їх." }, "encExportKeyWarningDesc": { "message": "Цей експорт шифрує ваші дані за допомогою ключа шифрування облікового запису. Якщо ви коли-небудь оновите ключ шифрування облікового запису, необхідно виконати експорт знову, оскільки не зможете розшифрувати цей файл експорту." @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Функція недоступна" }, - "updateKey": { - "message": "Ви не можете використовувати цю функцію доки не оновите свій ключ шифрування." + "encryptionKeyMigrationRequired": { + "message": "Потрібно перенести ключ шифрування. Увійдіть у вебсховище та оновіть свій ключ шифрування." }, "premiumMembership": { "message": "Преміум статус" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "1 ГБ зашифрованого сховища для файлів." }, - "ppremiumSignUpTwoStep": { - "message": "Додаткові можливості двоетапної перевірки, наприклад, YubiKey, FIDO U2F та Duo." + "premiumSignUpTwoStepOptions": { + "message": "Додаткові можливості двоетапної авторизації, як-от YubiKey та Duo." }, "ppremiumSignUpReports": { "message": "Гігієна паролів, здоров'я облікового запису, а також звіти про вразливості даних, щоб зберігати ваше сховище в безпеці." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Відновити запис" }, - "restoreItemConfirmation": { - "message": "Ви дійсно хочете відновити цей запис?" - }, "restoredItem": { "message": "Запис відновлено" }, @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Біометрія в браузері не підтримується на цьому пристрої." }, + "biometricsFailedTitle": { + "message": "Збій біометрії" + }, + "biometricsFailedDesc": { + "message": "Неможливо виконати біометрію. Скористайтеся головним паролем або вийдіть із системи. Якщо проблема не зникне, зверніться до служби підтримки Bitwarden." + }, "nativeMessaginPermissionErrorTitle": { "message": "Дозвіл не надано" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Експортування особистого сховища" }, - "exportingPersonalVaultDescription": { - "message": "Будуть експортовані лише записи особистого сховища, пов'язані з $EMAIL$. Записи сховища організації не буде включено.", + "exportingIndividualVaultDescription": { + "message": "Будуть експортовані лише записи особистого сховища, пов'язані з $EMAIL$. Записи сховища організації не буде включено. Експортуються лише дані записів сховища без пов'язаних вкладень.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "Версія сервера" }, - "selfHosted": { - "message": "Власне розміщення" + "selfHostedServer": { + "message": "власне розміщення" }, "thirdParty": { "message": "Сторонній" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "Сповіщення було надіслано на ваш пристрій." }, - "logInInitiated": { + "loginInitiated": { "message": "Ініційовано вхід" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Регіон" + "loggingInOn": { + "message": "Увійти на" }, "opensInANewWindow": { "message": "Відкривається у новому вікні" }, + "deviceApprovalRequired": { + "message": "Необхідне підтвердження пристрою. Виберіть варіант підтвердження нижче:" + }, + "rememberThisDevice": { + "message": "Запам'ятати цей пристрій" + }, + "uncheckIfPublicDevice": { + "message": "Зніміть позначку, якщо використовуєте загальнодоступний пристрій" + }, + "approveFromYourOtherDevice": { + "message": "Затвердіть з іншого пристрою" + }, + "requestAdminApproval": { + "message": "Запит підтвердження адміністратора" + }, + "approveWithMasterPassword": { + "message": "Затвердити з головним паролем" + }, + "ssoIdentifierRequired": { + "message": "Потрібен SSO-ідентифікатор організації." + }, "eu": { "message": "ЄС", "description": "European Union" }, - "us": { - "message": "США", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Доступ заборонено. У вас немає дозволу на перегляд цієї сторінки." + }, + "general": { + "message": "Загальні" + }, + "display": { + "message": "Екран" + }, + "accountSuccessfullyCreated": { + "message": "Обліковий запис успішно створено!" + }, + "adminApprovalRequested": { + "message": "Запитано затвердження адміністратором" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Ваш запит відправлено адміністратору." + }, + "youWillBeNotifiedOnceApproved": { + "message": "Ви отримаєте сповіщення після затвердження." + }, + "troubleLoggingIn": { + "message": "Проблема під час входу?" + }, + "loginApproved": { + "message": "Вхід затверджено" + }, + "userEmailMissing": { + "message": "Немає адреси електронної пошти" + }, + "deviceTrusted": { + "message": "Довірений пристрій" + }, + "inputRequired": { + "message": "Необхідно ввести дані." + }, + "required": { + "message": "обов'язково" + }, + "search": { + "message": "Пошук" + }, + "inputMinLength": { + "message": "Введені дані мають бути довжиною принаймні $COUNT$ символів.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Вхідне значення не повинно перевищувати $COUNT$ символів.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "Вказані символи заборонені: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Значення має бути принаймні $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Значення не може перевищувати $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 або більше адрес е-пошти недійсні" + }, + "inputTrimValidator": { + "message": "Введене значення не повинно містити лише пробіл.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Введені дані не є адресою е-пошти." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ поле (поля) вище потребують вашої уваги.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Оберіть--" + }, + "multiSelectPlaceholder": { + "message": "-- Введіть для фільтрування --" + }, + "multiSelectLoading": { + "message": "Параметри отримання..." + }, + "multiSelectNotFound": { + "message": "Нічого не знайдено" + }, + "multiSelectClearAll": { + "message": "Очистити все" + }, + "plusNMore": { + "message": "+ ще $QUANTITY$", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Підменю" + }, + "toggleCollapse": { + "message": "Згорнути/розгорнути", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Псевдонім домену" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Записи з повторним запитом головного пароля не можна автоматично заповнювати під час завантаження сторінки. Автозаповнення на сторінці вимкнено.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Автозаповнення на сторінці налаштовано з типовими параметрами.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Вимкніть повторний запит головного пароля, щоб редагувати це поле", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index fee4c66f3a9..caedbb8b917 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "Tự động điền" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "Tạo mật khẩu (đã sao chép)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "Không có thông tin đăng nhập phù hợp." }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "Mở khoá kho lưu trữ của bạn" }, @@ -338,6 +362,9 @@ "other": { "message": "Khác" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "Đánh giá tiện ích mở rộng" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "Cập nhật" }, + "notificationUnlockDesc": { + "message": "Vui lòng mở khóa Kho Bitwarden của bạn để hoàn thành quá trình tự động điền." + }, + "notificationUnlock": { + "message": "Mở khóa" + }, "enableContextMenuItem": { "message": "Hiển thị tuỳ chọn menu ngữ cảnh" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "Tính năng không có sẵn" }, - "updateKey": { - "message": "Bạn không thể sử dụng tính năng này cho đến khi bạn cập nhật khoá mã hóa." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Thành viên Cao Cấp" @@ -786,8 +819,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." @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "Khôi phục mục" }, - "restoreItemConfirmation": { - "message": "Bạn có chắc chắn muốn khôi phục mục này không?" - }, "restoredItem": { "message": "Mục đã được khôi phục" }, @@ -1462,16 +1492,16 @@ "message": "Đã tự động điền mục " }, "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": "Cảnh báo: Đây là một trang HTTP không an toàn, và mọi thông tin bạn nhập ở đây có khả năng được xem & thay đổi bởi người khác. Thông tin Đăng nhập này ban đầu được lưu ở một trang an toàn (HTTPS)." }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "Bạn vẫn muốn điền thông tin đăng nhập?" }, "autofillIframeWarning": { - "message": "The form is hosted by a different domain than the URI of your saved login. Choose OK to auto-fill anyway, or Cancel to stop." + "message": "Mẫu điền thông tin này được lưu tại một tên miền khác với URI lưu tại thông tin đăng nhập của bạn. Hãy chọn OK để tiếp tục tự động điền, hoặc Hủy bỏ để dừng lại." }, "autofillIframeWarningTip": { - "message": "To prevent this warning in the future, save this URI, $HOSTNAME$, to your Bitwarden login item for this site.", + "message": "Để chặn cảnh báo này trong tương lai, hãy lưu URI này, $HOSTNAME$, vào thông tin đăng nhập của bạn cho trang này ở Kho Bitwarden.", "placeholders": { "hostname": { "content": "$1", @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "Nhận dạng sinh trắc học trên trình duyệt không được hỗ trợ trên thiết bị này" }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "Quyền chưa được cấp" }, @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "Exporting individual vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,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" @@ -2140,8 +2176,8 @@ "notificationSentDevice": { "message": "Một thông báo đã được gửi đến thiết bị của bạn." }, - "logInInitiated": { - "message": "Log in initiated" + "loginInitiated": { + "message": "Login initiated" }, "exposedMasterPassword": { "message": "Mật khẩu chính bị lộ" @@ -2174,10 +2210,10 @@ } }, "autofillPageLoadPolicyActivated": { - "message": "Your organization policies have turned on auto-fill on page load." + "message": "Chính sách quản lí của bạn đã bật chức năng tự động điền khi tải trang." }, "howToAutofill": { - "message": "How to auto-fill" + "message": "Cách tự đồng điền" }, "autofillSelectInfoWithCommand": { "message": "Select an item from this page or use the shortcut: $COMMAND$", @@ -2195,16 +2231,16 @@ "message": "Got it" }, "autofillSettings": { - "message": "Auto-fill settings" + "message": "Cài đặt tự động điền" }, "autofillShortcut": { - "message": "Auto-fill keyboard shortcut" + "message": "Phím tắt tự động điền" }, "autofillShortcutNotSet": { - "message": "The auto-fill shortcut is not set. Change this in the browser's settings." + "message": "Chưa cài đặt phím tắt cho chức năng tự động điền. Vui lòng thay đổi trong cài đặt của trình duyệt." }, "autofillShortcutText": { - "message": "The auto-fill shortcut is: $COMMAND$. Change this in the browser's settings.", + "message": "Phím tắt cho chức năng tự động điền là $COMMAND$. Vui lòng thay đổi trong cài đặt của trình duyệt.", "placeholders": { "command": { "content": "$1", @@ -2213,7 +2249,7 @@ } }, "autofillShortcutTextSafari": { - "message": "Default auto-fill shortcut: $COMMAND$.", + "message": "Phím tắt mặc định cho chức năng tự động điền: $COMMAND$.", "placeholders": { "command": { "content": "$1", @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "Logging in on" }, "opensInANewWindow": { "message": "Opens in a new window" }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." + }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "Access denied. You do not have permission to view this page." + }, + "general": { + "message": "General" + }, + "display": { + "message": "Display" + }, + "accountSuccessfullyCreated": { + "message": "Account successfully created!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "Search" + }, + "inputMinLength": { + "message": "Input must be at least $COUNT$ characters long.", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "Input must not exceed $COUNT$ characters in length.", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 45c3c62db0e..5331208af82 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -41,7 +41,7 @@ "message": "主密码是您访问密码库的唯一密码。它非常重要,请您不要忘记。一旦忘记,无任何办法恢复此密码。" }, "masterPassHintDesc": { - "message": "主密码提示可以在你忘记密码时帮你回忆起来。" + "message": "主密码提示可以在您忘记密码时帮您回忆起来。" }, "reTypeMasterPass": { "message": "再次输入主密码" @@ -91,6 +91,15 @@ "autoFill": { "message": "自动填充" }, + "autoFillLogin": { + "message": "自动填充登录" + }, + "autoFillCard": { + "message": "自动填充支付卡" + }, + "autoFillIdentity": { + "message": "自动填充身份" + }, "generatePasswordCopied": { "message": "生成密码(并复制)" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "无匹配的登录项目" }, + "noCards": { + "message": "无支付卡" + }, + "noIdentities": { + "message": "无身份" + }, + "addLoginMenu": { + "message": "添加登录项目" + }, + "addCardMenu": { + "message": "添加支付卡" + }, + "addIdentityMenu": { + "message": "添加身份" + }, "unlockVaultMenu": { "message": "解锁您的密码库" }, @@ -263,7 +287,7 @@ "message": "单词分隔符" }, "capitalize": { - "message": "大写", + "message": "首字母大写", "description": "Make the first letter of a work uppercase." }, "includeNumber": { @@ -338,6 +362,9 @@ "other": { "message": "其他" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "设置一个解锁方式以更改您的密码库超时动作。" + }, "rateExtension": { "message": "为本扩展打分" }, @@ -357,7 +384,7 @@ "message": "解锁" }, "loggedInAsOn": { - "message": "以 $EMAIL$ 在 $HOSTNAME$ 上登录。", + "message": "已在 $HOSTNAME$ 上以 $EMAIL$ 身份登录。", "placeholders": { "email": { "content": "$1", @@ -477,13 +504,13 @@ "message": "无法在此页面上自动填充所选项目。请改为手工复制并粘贴。" }, "loggedOut": { - "message": "已退出账户" + "message": "已注销" }, "loginExpired": { "message": "您的登录会话已过期。" }, "logOutConfirmation": { - "message": "您确定要退出账户吗?" + "message": "您确定要注销吗?" }, "yes": { "message": "是" @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "更新" }, + "notificationUnlockDesc": { + "message": "解锁 Bitwarden 密码库以完成自动填充请求。" + }, + "notificationUnlock": { + "message": "解锁​​​​" + }, "enableContextMenuItem": { "message": "显示上下文菜单选项" }, @@ -742,7 +775,7 @@ "message": "附件已删除" }, "newAttachment": { - "message": "添加新的附件" + "message": "添加新附件" }, "noAttachments": { "message": "没有附件。" @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "功能不可用" }, - "updateKey": { - "message": "在您更新加密密钥前,您不能使用此功能。" + "encryptionKeyMigrationRequired": { + "message": "需要迁移加密密钥。请登录网页版密码库来更新您的加密密钥。" }, "premiumMembership": { "message": "高级会员" @@ -786,8 +819,8 @@ "ppremiumSignUpStorage": { "message": "1 GB 文件附件加密存储。" }, - "ppremiumSignUpTwoStep": { - "message": "额外的两步登录选项,如 YubiKey、FIDO U2F 和 Duo。" + "premiumSignUpTwoStepOptions": { + "message": "专有的两步登录选项,如 YubiKey 和 Duo。" }, "ppremiumSignUpReports": { "message": "密码健康、账户体检以及数据泄露报告,保障您的密码库安全。" @@ -841,10 +874,10 @@ "message": "使用此功能需要高级会员资格。" }, "enterVerificationCodeApp": { - "message": "请输入您的验证器应用中的 6 位验证码。" + "message": "请输入您的验证器应用中的 6 位数验证码。" }, "enterVerificationCodeEmail": { - "message": "请输入通过电子邮件发送给 $EMAIL$ 的 6 位验证码。", + "message": "请输入发送给电子邮件 $EMAIL$ 的 6 位数验证码。", "placeholders": { "email": { "content": "$1", @@ -946,7 +979,7 @@ "message": "自定义环境" }, "customEnvironmentFooter": { - "message": "适用于高级用户。你可以分别指定各个服务的基础 URL。" + "message": "适用于高级用户。您可以分别指定各个服务的基础 URL。" }, "baseUrl": { "message": "服务器 URL" @@ -1027,7 +1060,7 @@ "message": "值" }, "newCustomField": { - "message": "新建自定义字段" + "message": "新增自定义字段" }, "dragToSort": { "message": "拖动排序" @@ -1050,7 +1083,7 @@ "description": "This describes a value that is 'linked' (tied) to another value." }, "popup2faCloseMessage": { - "message": "如果您点击弹窗外的任何区域,将导致弹窗关闭。您想在新窗口中打开此弹窗,以便它不会关闭吗?" + "message": "如果您点击弹窗外的区域以检查您的验证码电子邮件,将导致弹窗关闭。您想在新窗口中打开此弹窗,以便它不会关闭吗?" }, "popupU2fCloseMessage": { "message": "此浏览器无法处理此弹出窗口中的 U2F 请求。您想要在新窗口中打开此弹出窗口吗?" @@ -1173,7 +1206,7 @@ "message": "许可证号码" }, "email": { - "message": "Email" + "message": "电子邮件" }, "phone": { "message": "电话" @@ -1382,7 +1415,7 @@ "message": "使用 PIN 码解锁" }, "setYourPinCode": { - "message": "设置您用来解锁 Bitwarden 的 PIN 码。您的 PIN 设置将在您退出账户时被重置。" + "message": "设定您用来解锁 Bitwarden 的 PIN 码。您的 PIN 设置将在您完全注销此应用程序时被重置。" }, "pinRequired": { "message": "需要 PIN 码。" @@ -1440,14 +1473,11 @@ "restoreItem": { "message": "恢复项目" }, - "restoreItemConfirmation": { - "message": "您确定要恢复此项目吗?" - }, "restoredItem": { "message": "项目已恢复" }, "vaultTimeoutLogOutConfirmation": { - "message": "超时后退出账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定使用此设置吗?" + "message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定使用此设置吗?" }, "vaultTimeoutLogOutConfirmationTitle": { "message": "超时动作确认" @@ -1462,7 +1492,7 @@ "message": "项目已自动填充 " }, "insecurePageWarning": { - "message": "警告:这是一个不安全的 HTTP 页面,您提交的任何信息都可能被其他人看到和更改。此登录最初保存在安全 (HTTPS) 页面上。" + "message": "警告:这是一个不安全的 HTTP 页面,您提交的任何信息都可能被其他人看到和更改。此登录信息最初保存在安全 (HTTPS) 页面上。" }, "insecurePageWarningFillPrompt": { "message": "您仍然想要填充此登录信息吗?" @@ -1471,7 +1501,7 @@ "message": "该表单由不同于您保存的登录的 URI 域名托管。选择「确定」以自动填充,或选择「取消」停止填充。" }, "autofillIframeWarningTip": { - "message": "要防止以后再次出现此警告,请将此站点的 URI $HOSTNAME$ 保存到您的 Bitwarden 登录项目中。", + "message": "要防止以后出现此警告,请将此站点的 URI $HOSTNAME$ 保存到您的 Bitwarden 登录项目中。", "placeholders": { "hostname": { "content": "$1", @@ -1599,11 +1629,17 @@ "biometricsNotSupportedDesc": { "message": "此设备不支持浏览器生物识别。" }, + "biometricsFailedTitle": { + "message": "生物识别失败" + }, + "biometricsFailedDesc": { + "message": "生物识别无法完成,请尝试使用主密码或注销。如果仍无法解决,请联系 Bitwarden 支持。" + }, "nativeMessaginPermissionErrorTitle": { "message": "未提供权限" }, "nativeMessaginPermissionErrorDesc": { - "message": "没有与 Bitwarden 桌面应用程序通信的权限,我们无法在浏览器扩展中提供生物识别。请再试一次。" + "message": "没有与 Bitwarden 桌面应用程序通信的权限,我们无法在浏览器扩展中提供生物识别。请重试。" }, "nativeMessaginPermissionSidebarTitle": { "message": "权限请求错误" @@ -1612,7 +1648,7 @@ "message": "此操作不能在侧边栏中完成,请在弹出窗口或弹出对话框中重试。" }, "personalOwnershipSubmitError": { - "message": "由于某个企业策略,您被限制为保存项目到您的个人密码库。将所有权选项更改为组织,然后从可用的集合中选择。" + "message": "由于某个企业策略,您不能将项目保存到您的个人密码库。将所有权选项更改为组织,并从可用的集合中选择。" }, "personalOwnershipPolicyInEffect": { "message": "一个组织策略正影响您的所有权选项。" @@ -1784,14 +1820,14 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "newPassword": { - "message": "新建密码" + "message": "新密码" }, "sendDisabled": { "message": "Send 已禁用", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendDisabledWarning": { - "message": "由于企业策略,您只能删除现有的 Send。", + "message": "由于某个企业策略,您只能删除现有的 Send。", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "createdSend": { @@ -1848,7 +1884,7 @@ "message": "一个或多个组织策略正在影响您的 Send 选项。" }, "passwordPrompt": { - "message": "重新询问主密码" + "message": "主密码重新提示" }, "passwordConfirmation": { "message": "确认主密码" @@ -1884,7 +1920,7 @@ "message": "选择文件夹..." }, "ssoCompleteRegistration": { - "message": "要完成 SSO 登陆配置,请设置一个主密码以访问和保护您的密码库。" + "message": "要完成 SSO 登录配置,请设置一个主密码以访问和保护您的密码库。" }, "hours": { "message": "小时" @@ -1974,13 +2010,13 @@ "message": "字符计数开关" }, "sessionTimeout": { - "message": "您的会话已超时。请返回并尝试重新登录。" + "message": "您的会话已超时。请返回然后尝试重新登录。" }, "exportingPersonalVaultTitle": { "message": "导出个人密码库" }, - "exportingPersonalVaultDescription": { - "message": "仅会导出与 $EMAIL$ 关联的个人密码库项目。组织密码库的项目不会导出。", + "exportingIndividualVaultDescription": { + "message": "仅会导出与 $EMAIL$ 关联的个人密码库项目,不包括组织密码库项目。仅会导出密码库项目信息,不包括关联的附件。", "placeholders": { "email": { "content": "$1", @@ -2054,10 +2090,10 @@ "message": "需要高级版订阅" }, "organizationIsDisabled": { - "message": "组织已暂停。" + "message": "组织已停用。" }, "disabledOrganizationFilterError": { - "message": "无法访问已暂停组织中的项目。请联系您的组织所有者获取帮助。" + "message": "无法访问已停用组织中的项目。请联系您的组织所有者获取协助。" }, "loggingInTo": { "message": "正在登录到 $DOMAIN$", @@ -2080,7 +2116,7 @@ "serverVersion": { "message": "服务器版本" }, - "selfHosted": { + "selfHostedServer": { "message": "自托管" }, "thirdParty": { @@ -2120,10 +2156,10 @@ "message": "记住电子邮件地址" }, "loginWithDevice": { - "message": "设备登录" + "message": "使用设备登录" }, "loginWithDeviceEnabledInfo": { - "message": "必须在 Bitwarden 应用程序的设置中启用设备登录。需要其他选项吗?" + "message": "设备登录必须在 Bitwarden 应用程序的设置中启用。需要其他登录选项吗?" }, "fingerprintPhraseHeader": { "message": "指纹短语" @@ -2140,7 +2176,7 @@ "notificationSentDevice": { "message": "通知已发送到您的设备。" }, - "logInInitiated": { + "loginInitiated": { "message": "登录已发起" }, "exposedMasterPassword": { @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "区域" + "loggingInOn": { + "message": "登录到" }, "opensInANewWindow": { "message": "在新窗口中打开" }, + "deviceApprovalRequired": { + "message": "需要设备批准。请在下面选择一个批准选项:" + }, + "rememberThisDevice": { + "message": "记住此设备" + }, + "uncheckIfPublicDevice": { + "message": "若使用公共设备,请取消勾选" + }, + "approveFromYourOtherDevice": { + "message": "从您的其他设备批准" + }, + "requestAdminApproval": { + "message": "请求管理员批准" + }, + "approveWithMasterPassword": { + "message": "使用主密码批准" + }, + "ssoIdentifierRequired": { + "message": "必须填写组织 SSO 标识符。" + }, "eu": { "message": "欧盟", "description": "European Union" }, - "us": { - "message": "美国", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "访问被拒绝。您没有权限查看此页面。" + }, + "general": { + "message": "常规" + }, + "display": { + "message": "显示" + }, + "accountSuccessfullyCreated": { + "message": "账户已成功创建!" + }, + "adminApprovalRequested": { + "message": "已请求管理员批准" + }, + "adminApprovalRequestSentToAdmins": { + "message": "您的请求已发送给您的管理员。" + }, + "youWillBeNotifiedOnceApproved": { + "message": "批准后,您将收到通知。" + }, + "troubleLoggingIn": { + "message": "登录遇到问题?" + }, + "loginApproved": { + "message": "登录已批准" + }, + "userEmailMissing": { + "message": "缺少用户电子邮件" + }, + "deviceTrusted": { + "message": "设备已信任" + }, + "inputRequired": { + "message": "必须输入内容。" + }, + "required": { + "message": "必填" + }, + "search": { + "message": "搜索" + }, + "inputMinLength": { + "message": "至少输入 $COUNT$ 个字符。", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "输入长度不能超过 $COUNT$ 个字符。", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "以下字符不被允许:$CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "输入的值不能低于 $MIN$。", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "输入的值不能超过 $MAX$。", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "一个或多个电子邮件地址无效" + }, + "inputTrimValidator": { + "message": "输入不能只包含空格。", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "输入的不是电子邮件地址。" + }, + "fieldsNeedAttention": { + "message": "上面的 $COUNT$ 个字段需要您注意。", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- 选择 --" + }, + "multiSelectPlaceholder": { + "message": "-- 输入以筛选 --" + }, + "multiSelectLoading": { + "message": "正在获取选项..." + }, + "multiSelectNotFound": { + "message": "未找到任何条目" + }, + "multiSelectClearAll": { + "message": "清除全部" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "子菜单" + }, + "toggleCollapse": { + "message": "切换折叠", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "别名域" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "具有主密码重新提示的项目无法在页面加载时自动填充。页面加载时的自动填充功能已关闭。", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "页面加载时自动填充设置为默认设置。", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "关闭主密码重新提示以编辑此字段", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 42e082f136b..b3368beb188 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -91,6 +91,15 @@ "autoFill": { "message": "自動填入" }, + "autoFillLogin": { + "message": "Auto-fill login" + }, + "autoFillCard": { + "message": "Auto-fill card" + }, + "autoFillIdentity": { + "message": "Auto-fill identity" + }, "generatePasswordCopied": { "message": "產生及複製密碼" }, @@ -100,6 +109,21 @@ "noMatchingLogins": { "message": "無符合的登入資料" }, + "noCards": { + "message": "No cards" + }, + "noIdentities": { + "message": "No identities" + }, + "addLoginMenu": { + "message": "Add login" + }, + "addCardMenu": { + "message": "Add card" + }, + "addIdentityMenu": { + "message": "Add identity" + }, "unlockVaultMenu": { "message": "解鎖您的密碼庫" }, @@ -338,6 +362,9 @@ "other": { "message": "其他" }, + "unlockMethodNeededToChangeTimeoutActionDesc": { + "message": "Set up an unlock method to change your vault timeout action." + }, "rateExtension": { "message": "為本套件評分" }, @@ -630,6 +657,12 @@ "notificationChangeSave": { "message": "更新" }, + "notificationUnlockDesc": { + "message": "Unlock your Bitwarden vault to complete the auto-fill request." + }, + "notificationUnlock": { + "message": "解鎖" + }, "enableContextMenuItem": { "message": "顯示內容選單選項" }, @@ -762,8 +795,8 @@ "featureUnavailable": { "message": "功能不可用" }, - "updateKey": { - "message": "更新加密金鑰前不能使用此功能。" + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "進階會員" @@ -786,8 +819,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": "密碼健康度檢查、提供帳戶體檢以及資料外洩報告,以保障您的密碼庫安全。" @@ -952,7 +985,7 @@ "message": "伺服器 URL" }, "apiUrl": { - "message": "API 伺服器 URL" + "message": "API 伺服器網址" }, "webVaultUrl": { "message": "網頁版密碼庫伺服器 URL" @@ -976,7 +1009,7 @@ "message": "網頁載入時如果偵測到登入表單,則執行自動填入。" }, "experimentalFeature": { - "message": "被竊取或不可信任的網站可以利用自動填入功能在網頁載入時竊取資訊。" + "message": "被入侵或不可信任的網站可以利用網頁載入時的自動填入功能。" }, "learnMoreAboutAutofill": { "message": "進一步瞭解「自動填入」功能" @@ -1255,10 +1288,10 @@ "description": "To clear something out. example: To clear browser history." }, "checkPassword": { - "message": "檢查密碼是否已外洩。" + "message": "檢查密碼是否已暴露。" }, "passwordExposed": { - "message": "此密碼已外洩了 $VALUE$ 次,應立即變更密碼。", + "message": "此密碼在資料外洩事件中被暴露了 $VALUE$ 次,應立即變更它。", "placeholders": { "value": { "content": "$1", @@ -1440,9 +1473,6 @@ "restoreItem": { "message": "還原項目" }, - "restoreItemConfirmation": { - "message": "您確定要還原此項目嗎?" - }, "restoredItem": { "message": "項目已還原" }, @@ -1462,16 +1492,16 @@ "message": "項目已自動填入" }, "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": "警告:這是不安全的 HTTP 頁面,任何您送出的資訊均可能被其他人看見和更改。此登入資訊原先是在安全的 (HTTPS) 頁面儲存的。" }, "insecurePageWarningFillPrompt": { - "message": "Do you still wish to fill this login?" + "message": "您依然想要填充此登入資訊嗎?" }, "autofillIframeWarning": { - "message": "The form is hosted by a different domain than the URI of your saved login. Choose OK to auto-fill anyway, or Cancel to stop." + "message": "這個表單寄放在不同的網域,而非您儲存登入資訊的 URI。選擇「確認」則依然自動填入,「取消」則停止本動作。" }, "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", @@ -1483,13 +1513,13 @@ "message": "設定主密碼" }, "currentMasterPass": { - "message": "Current master password" + "message": "目前的主密碼" }, "newMasterPass": { - "message": "New master password" + "message": "新的主密碼" }, "confirmNewMasterPass": { - "message": "Confirm new master password" + "message": "確認新的主密碼" }, "masterPasswordPolicyInEffect": { "message": "一個或多個組織原則要求您的主密碼須符合下列條件:" @@ -1599,6 +1629,12 @@ "biometricsNotSupportedDesc": { "message": "此裝置不支援瀏覽器生物特徵辨識。" }, + "biometricsFailedTitle": { + "message": "Biometrics failed" + }, + "biometricsFailedDesc": { + "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + }, "nativeMessaginPermissionErrorTitle": { "message": "未提供權限" }, @@ -1872,7 +1908,7 @@ "message": "您的主密碼最近被您的組織管理者變更過。若要存取密碼庫,您必須立即更新主密碼。繼續操作會登出目前的登入階段,並要求您重新登入。其他裝置上的活動登入階段最多會保持一個小時。" }, "updateWeakMasterPasswordWarning": { - "message": "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour." + "message": "您的主密碼不符合您的組織政策之一或多個要求。您必須立即更新您的主密碼以存取密碼庫。進行此操作將登出您目前的工作階段,需要您重新登入。其他裝置上的工作階段可能繼續長達一小時。" }, "resetPasswordPolicyAutoEnroll": { "message": "自動註冊" @@ -1906,7 +1942,7 @@ } }, "vaultTimeoutPolicyWithActionInEffect": { - "message": "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is $HOURS$ hour(s) and $MINUTES$ minute(s). Your vault timeout action is set to $ACTION$.", + "message": "您的組織原則正在影響您的密碼庫逾時時間。密碼庫逾時時間最多可以設定到 $HOURS$ 小時 $MINUTES$ 分鐘。您密碼庫的逾時動作是設為 $ACTION$。", "placeholders": { "hours": { "content": "$1", @@ -1923,7 +1959,7 @@ } }, "vaultTimeoutActionPolicyInEffect": { - "message": "Your organization policies have set your vault timeout action to $ACTION$.", + "message": "您的組織原則已將密碼庫逾時動作設為 $ACTION$。", "placeholders": { "action": { "content": "$1", @@ -1979,8 +2015,8 @@ "exportingPersonalVaultTitle": { "message": "正匯出個人密碼庫" }, - "exportingPersonalVaultDescription": { - "message": "只會匯出與 $EMAIL$ 關聯的個人密碼庫項目。組織密碼庫的項目不包含在內。", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", @@ -2080,8 +2116,8 @@ "serverVersion": { "message": "伺服器版本" }, - "selfHosted": { - "message": "自我裝載" + "selfHostedServer": { + "message": "self-hosted" }, "thirdParty": { "message": "第三方" @@ -2140,14 +2176,14 @@ "notificationSentDevice": { "message": "已傳送通知至您的裝置。" }, - "logInInitiated": { - "message": "登入已起始" + "loginInitiated": { + "message": "登入已發起" }, "exposedMasterPassword": { "message": "已暴露的主密碼" }, "exposedMasterPasswordDesc": { - "message": "在其他資料庫中找到您的密碼。我們建議您使用一個獨特的密碼來保護您的帳號,您確定要用這個密碼嗎?" + "message": "在資料外洩事件中找到了密碼。我們建議您使用一個獨特的密碼來保護您的帳戶,您確定要使用已暴露的密碼嗎?" }, "weakAndExposedMasterPassword": { "message": "強度不足且已暴露的主密碼" @@ -2159,7 +2195,7 @@ "message": "檢查外洩密碼資料庫中是否有此密碼" }, "important": { - "message": "重要事項:" + "message": "重要:" }, "masterPasswordHint": { "message": "如果您忘記主密碼,沒有復原的方法!" @@ -2221,18 +2257,193 @@ } } }, - "region": { - "message": "Region" + "loggingInOn": { + "message": "正登入到" }, "opensInANewWindow": { - "message": "Opens in a new window" + "message": "在新視窗開啟" + }, + "deviceApprovalRequired": { + "message": "Device approval required. Select an approval option below:" + }, + "rememberThisDevice": { + "message": "Remember this device" + }, + "uncheckIfPublicDevice": { + "message": "Uncheck if using a public device" + }, + "approveFromYourOtherDevice": { + "message": "Approve from your other device" + }, + "requestAdminApproval": { + "message": "Request admin approval" + }, + "approveWithMasterPassword": { + "message": "Approve with master password" + }, + "ssoIdentifierRequired": { + "message": "Organization SSO identifier is required." }, "eu": { "message": "EU", "description": "European Union" }, - "us": { - "message": "US", - "description": "United States" + "usDomain": { + "message": "bitwarden.com" + }, + "euDomain": { + "message": "bitwarden.eu" + }, + "accessDenied": { + "message": "拒絕存取。您沒有檢視此頁面的權限。" + }, + "general": { + "message": "一般" + }, + "display": { + "message": "顯示" + }, + "accountSuccessfullyCreated": { + "message": "已成功建立帳戶!" + }, + "adminApprovalRequested": { + "message": "Admin approval requested" + }, + "adminApprovalRequestSentToAdmins": { + "message": "Your request has been sent to your admin." + }, + "youWillBeNotifiedOnceApproved": { + "message": "You will be notified once approved." + }, + "troubleLoggingIn": { + "message": "Trouble logging in?" + }, + "loginApproved": { + "message": "Login approved" + }, + "userEmailMissing": { + "message": "User email missing" + }, + "deviceTrusted": { + "message": "Device trusted" + }, + "inputRequired": { + "message": "Input is required." + }, + "required": { + "message": "required" + }, + "search": { + "message": "搜尋" + }, + "inputMinLength": { + "message": "必須輸入至少 $COUNT$ 個字元。", + "placeholders": { + "count": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxLength": { + "message": "輸入的內容長度不得超過 $COUNT$ 字元。", + "placeholders": { + "count": { + "content": "$1", + "example": "20" + } + } + }, + "inputForbiddenCharacters": { + "message": "The following characters are not allowed: $CHARACTERS$", + "placeholders": { + "characters": { + "content": "$1", + "example": "@, #, $, %" + } + } + }, + "inputMinValue": { + "message": "Input value must be at least $MIN$.", + "placeholders": { + "min": { + "content": "$1", + "example": "8" + } + } + }, + "inputMaxValue": { + "message": "Input value must not exceed $MAX$.", + "placeholders": { + "max": { + "content": "$1", + "example": "100" + } + } + }, + "multipleInputEmails": { + "message": "1 or more emails are invalid" + }, + "inputTrimValidator": { + "message": "Input must not contain only whitespace.", + "description": "Notification to inform the user that a form's input can't contain only whitespace." + }, + "inputEmail": { + "message": "Input is not an email address." + }, + "fieldsNeedAttention": { + "message": "$COUNT$ field(s) above need your attention.", + "placeholders": { + "count": { + "content": "$1", + "example": "4" + } + } + }, + "selectPlaceholder": { + "message": "-- Select --" + }, + "multiSelectPlaceholder": { + "message": "-- Type to filter --" + }, + "multiSelectLoading": { + "message": "Retrieving options..." + }, + "multiSelectNotFound": { + "message": "No items found" + }, + "multiSelectClearAll": { + "message": "Clear all" + }, + "plusNMore": { + "message": "+ $QUANTITY$ more", + "placeholders": { + "quantity": { + "content": "$1", + "example": "5" + } + } + }, + "submenu": { + "message": "Submenu" + }, + "toggleCollapse": { + "message": "Toggle collapse", + "description": "Toggling an expand/collapse state." + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "使用主密碼重新提示的項目無法在頁面加載時自動填寫。已關閉頁面加載時的自動填入。", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "將頁面加載時的自動填入設置為使用默認設定。", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "關閉主密碼重新提示以編輯此欄位", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/admin-console/background/service-factories/organization-service.factory.ts b/apps/browser/src/admin-console/background/service-factories/organization-service.factory.ts index 454b1ce9cd2..a050dc22ecc 100644 --- a/apps/browser/src/admin-console/background/service-factories/organization-service.factory.ts +++ b/apps/browser/src/admin-console/background/service-factories/organization-service.factory.ts @@ -4,11 +4,11 @@ import { FactoryOptions, CachedServices, factory, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; import { stateServiceFactory, StateServiceInitOptions, -} from "../../../background/service_factories/state-service.factory"; +} from "../../../platform/background/service-factories/state-service.factory"; import { BrowserOrganizationService } from "../../services/browser-organization.service"; type OrganizationServiceFactoryOptions = FactoryOptions; diff --git a/apps/browser/src/admin-console/background/service-factories/policy-service.factory.ts b/apps/browser/src/admin-console/background/service-factories/policy-service.factory.ts index 4bb19639c88..89f4a667f8d 100644 --- a/apps/browser/src/admin-console/background/service-factories/policy-service.factory.ts +++ b/apps/browser/src/admin-console/background/service-factories/policy-service.factory.ts @@ -4,11 +4,11 @@ import { CachedServices, factory, FactoryOptions, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; import { stateServiceFactory as stateServiceFactory, StateServiceInitOptions, -} from "../../../background/service_factories/state-service.factory"; +} from "../../../platform/background/service-factories/state-service.factory"; import { BrowserPolicyService } from "../../services/browser-policy.service"; import { diff --git a/apps/browser/src/admin-console/services/browser-organization.service.ts b/apps/browser/src/admin-console/services/browser-organization.service.ts index 3b0ae245a64..6294756cdf7 100644 --- a/apps/browser/src/admin-console/services/browser-organization.service.ts +++ b/apps/browser/src/admin-console/services/browser-organization.service.ts @@ -3,7 +3,7 @@ import { BehaviorSubject } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service"; -import { browserSession, sessionSync } from "../../decorators/session-sync-observable"; +import { browserSession, sessionSync } from "../../platform/decorators/session-sync-observable"; @browserSession export class BrowserOrganizationService extends OrganizationService { diff --git a/apps/browser/src/admin-console/services/browser-policy.service.ts b/apps/browser/src/admin-console/services/browser-policy.service.ts index e51c8dc5978..74aa0f546af 100644 --- a/apps/browser/src/admin-console/services/browser-policy.service.ts +++ b/apps/browser/src/admin-console/services/browser-policy.service.ts @@ -1,13 +1,13 @@ import { BehaviorSubject, filter, map, Observable, switchMap, tap } from "rxjs"; import { Jsonify } from "type-fest"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { browserSession, sessionSync } from "../../decorators/session-sync-observable"; +import { browserSession, sessionSync } from "../../platform/decorators/session-sync-observable"; @browserSession export class BrowserPolicyService extends PolicyService { diff --git a/apps/browser/src/auth/background/service-factories/auth-request-crypto-service.factory.ts b/apps/browser/src/auth/background/service-factories/auth-request-crypto-service.factory.ts new file mode 100644 index 00000000000..e1757f98129 --- /dev/null +++ b/apps/browser/src/auth/background/service-factories/auth-request-crypto-service.factory.ts @@ -0,0 +1,29 @@ +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; +import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; + +import { + CryptoServiceInitOptions, + cryptoServiceFactory, +} from "../../../platform/background/service-factories/crypto-service.factory"; +import { + CachedServices, + FactoryOptions, + factory, +} from "../../../platform/background/service-factories/factory-options"; + +type AuthRequestCryptoServiceFactoryOptions = FactoryOptions; + +export type AuthRequestCryptoServiceInitOptions = AuthRequestCryptoServiceFactoryOptions & + CryptoServiceInitOptions; + +export function authRequestCryptoServiceFactory( + cache: { authRequestCryptoService?: AuthRequestCryptoServiceAbstraction } & CachedServices, + opts: AuthRequestCryptoServiceInitOptions +): Promise { + return factory( + cache, + "authRequestCryptoService", + opts, + async () => new AuthRequestCryptoServiceImplementation(await cryptoServiceFactory(cache, opts)) + ); +} diff --git a/apps/browser/src/auth/background/service-factories/auth-service.factory.ts b/apps/browser/src/auth/background/service-factories/auth-service.factory.ts index 251ceee0aa0..6aaeb476369 100644 --- a/apps/browser/src/auth/background/service-factories/auth-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/auth-service.factory.ts @@ -8,50 +8,58 @@ import { import { apiServiceFactory, ApiServiceInitOptions, -} from "../../../background/service_factories/api-service.factory"; -import { appIdServiceFactory } from "../../../background/service_factories/app-id-service.factory"; +} from "../../../platform/background/service-factories/api-service.factory"; +import { appIdServiceFactory } from "../../../platform/background/service-factories/app-id-service.factory"; import { - cryptoServiceFactory, CryptoServiceInitOptions, -} from "../../../background/service_factories/crypto-service.factory"; + cryptoServiceFactory, +} from "../../../platform/background/service-factories/crypto-service.factory"; import { - encryptServiceFactory, EncryptServiceInitOptions, -} from "../../../background/service_factories/encrypt-service.factory"; + encryptServiceFactory, +} from "../../../platform/background/service-factories/encrypt-service.factory"; import { environmentServiceFactory, EnvironmentServiceInitOptions, -} from "../../../background/service_factories/environment-service.factory"; +} from "../../../platform/background/service-factories/environment-service.factory"; import { CachedServices, factory, FactoryOptions, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; import { i18nServiceFactory, I18nServiceInitOptions, -} from "../../../background/service_factories/i18n-service.factory"; +} from "../../../platform/background/service-factories/i18n-service.factory"; import { logServiceFactory, LogServiceInitOptions, -} from "../../../background/service_factories/log-service.factory"; +} from "../../../platform/background/service-factories/log-service.factory"; import { messagingServiceFactory, MessagingServiceInitOptions, -} from "../../../background/service_factories/messaging-service.factory"; -import { - passwordGenerationServiceFactory, - PasswordGenerationServiceInitOptions, -} from "../../../background/service_factories/password-generation-service.factory"; +} from "../../../platform/background/service-factories/messaging-service.factory"; import { platformUtilsServiceFactory, PlatformUtilsServiceInitOptions, -} from "../../../background/service_factories/platform-utils-service.factory"; +} from "../../../platform/background/service-factories/platform-utils-service.factory"; import { stateServiceFactory, StateServiceInitOptions, -} from "../../../background/service_factories/state-service.factory"; +} from "../../../platform/background/service-factories/state-service.factory"; +import { + passwordStrengthServiceFactory, + PasswordStrengthServiceInitOptions, +} from "../../../tools/background/service_factories/password-strength-service.factory"; +import { + authRequestCryptoServiceFactory, + AuthRequestCryptoServiceInitOptions, +} from "./auth-request-crypto-service.factory"; +import { + deviceTrustCryptoServiceFactory, + DeviceTrustCryptoServiceInitOptions, +} from "./device-trust-crypto-service.factory"; import { keyConnectorServiceFactory, KeyConnectorServiceInitOptions, @@ -75,7 +83,9 @@ export type AuthServiceInitOptions = AuthServiceFactoyOptions & I18nServiceInitOptions & EncryptServiceInitOptions & PolicyServiceInitOptions & - PasswordGenerationServiceInitOptions; + PasswordStrengthServiceInitOptions & + DeviceTrustCryptoServiceInitOptions & + AuthRequestCryptoServiceInitOptions; export function authServiceFactory( cache: { authService?: AbstractAuthService } & CachedServices, @@ -100,8 +110,10 @@ export function authServiceFactory( await twoFactorServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await encryptServiceFactory(cache, opts), - await passwordGenerationServiceFactory(cache, opts), - await policyServiceFactory(cache, opts) + await passwordStrengthServiceFactory(cache, opts), + await policyServiceFactory(cache, opts), + await deviceTrustCryptoServiceFactory(cache, opts), + await authRequestCryptoServiceFactory(cache, opts) ) ); } diff --git a/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts b/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts new file mode 100644 index 00000000000..430d50fea75 --- /dev/null +++ b/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts @@ -0,0 +1,74 @@ +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; + +import { + DevicesApiServiceInitOptions, + devicesApiServiceFactory, +} from "../../../background/service-factories/devices-api-service.factory"; +import { + AppIdServiceInitOptions, + appIdServiceFactory, +} from "../../../platform/background/service-factories/app-id-service.factory"; +import { + CryptoFunctionServiceInitOptions, + cryptoFunctionServiceFactory, +} from "../../../platform/background/service-factories/crypto-function-service.factory"; +import { + CryptoServiceInitOptions, + cryptoServiceFactory, +} from "../../../platform/background/service-factories/crypto-service.factory"; +import { + EncryptServiceInitOptions, + encryptServiceFactory, +} from "../../../platform/background/service-factories/encrypt-service.factory"; +import { + CachedServices, + FactoryOptions, + factory, +} from "../../../platform/background/service-factories/factory-options"; +import { + I18nServiceInitOptions, + i18nServiceFactory, +} from "../../../platform/background/service-factories/i18n-service.factory"; +import { + PlatformUtilsServiceInitOptions, + platformUtilsServiceFactory, +} from "../../../platform/background/service-factories/platform-utils-service.factory"; +import { + StateServiceInitOptions, + stateServiceFactory, +} from "../../../platform/background/service-factories/state-service.factory"; + +type DeviceTrustCryptoServiceFactoryOptions = FactoryOptions; + +export type DeviceTrustCryptoServiceInitOptions = DeviceTrustCryptoServiceFactoryOptions & + CryptoFunctionServiceInitOptions & + CryptoServiceInitOptions & + EncryptServiceInitOptions & + StateServiceInitOptions & + AppIdServiceInitOptions & + DevicesApiServiceInitOptions & + I18nServiceInitOptions & + PlatformUtilsServiceInitOptions; + +export function deviceTrustCryptoServiceFactory( + cache: { deviceTrustCryptoService?: DeviceTrustCryptoServiceAbstraction } & CachedServices, + opts: DeviceTrustCryptoServiceInitOptions +): Promise { + return factory( + cache, + "deviceTrustCryptoService", + opts, + async () => + new DeviceTrustCryptoService( + await cryptoFunctionServiceFactory(cache, opts), + await cryptoServiceFactory(cache, opts), + await encryptServiceFactory(cache, opts), + await stateServiceFactory(cache, opts), + await appIdServiceFactory(cache, opts), + await devicesApiServiceFactory(cache, opts), + await i18nServiceFactory(cache, opts), + await platformUtilsServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts index 0689398f9c4..25eb85e5568 100644 --- a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts @@ -8,28 +8,28 @@ import { import { apiServiceFactory, ApiServiceInitOptions, -} from "../../../background/service_factories/api-service.factory"; +} from "../../../platform/background/service-factories/api-service.factory"; import { - cryptoFunctionServiceFactory, CryptoFunctionServiceInitOptions, -} from "../../../background/service_factories/crypto-function-service.factory"; + cryptoFunctionServiceFactory, +} from "../../../platform/background/service-factories/crypto-function-service.factory"; import { CryptoServiceInitOptions, cryptoServiceFactory, -} from "../../../background/service_factories/crypto-service.factory"; +} from "../../../platform/background/service-factories/crypto-service.factory"; import { FactoryOptions, CachedServices, factory, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; import { logServiceFactory, LogServiceInitOptions, -} from "../../../background/service_factories/log-service.factory"; +} from "../../../platform/background/service-factories/log-service.factory"; import { stateServiceFactory, StateServiceInitOptions, -} from "../../../background/service_factories/state-service.factory"; +} from "../../../platform/background/service-factories/state-service.factory"; import { TokenServiceInitOptions, tokenServiceFactory } from "./token-service.factory"; diff --git a/apps/browser/src/auth/background/service-factories/token-service.factory.ts b/apps/browser/src/auth/background/service-factories/token-service.factory.ts index 00f09cbde11..389f8d1541a 100644 --- a/apps/browser/src/auth/background/service-factories/token-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/token-service.factory.ts @@ -5,11 +5,11 @@ import { FactoryOptions, CachedServices, factory, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; import { stateServiceFactory, StateServiceInitOptions, -} from "../../../background/service_factories/state-service.factory"; +} from "../../../platform/background/service-factories/state-service.factory"; type TokenServiceFactoryOptions = FactoryOptions; diff --git a/apps/browser/src/auth/background/service-factories/totp-service.factory.ts b/apps/browser/src/auth/background/service-factories/totp-service.factory.ts index c6533bd0b4b..48331576cd3 100644 --- a/apps/browser/src/auth/background/service-factories/totp-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/totp-service.factory.ts @@ -4,16 +4,16 @@ import { TotpService } from "@bitwarden/common/services/totp.service"; import { CryptoFunctionServiceInitOptions, cryptoFunctionServiceFactory, -} from "../../../background/service_factories/crypto-function-service.factory"; +} from "../../../platform/background/service-factories/crypto-function-service.factory"; import { FactoryOptions, CachedServices, factory, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; import { LogServiceInitOptions, logServiceFactory, -} from "../../../background/service_factories/log-service.factory"; +} from "../../../platform/background/service-factories/log-service.factory"; type TotpServiceOptions = FactoryOptions; diff --git a/apps/browser/src/auth/background/service-factories/two-factor-service.factory.ts b/apps/browser/src/auth/background/service-factories/two-factor-service.factory.ts index 8763a96d04e..040a5edfb4a 100644 --- a/apps/browser/src/auth/background/service-factories/two-factor-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/two-factor-service.factory.ts @@ -5,15 +5,15 @@ import { FactoryOptions, CachedServices, factory, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; import { I18nServiceInitOptions, i18nServiceFactory, -} from "../../../background/service_factories/i18n-service.factory"; +} from "../../../platform/background/service-factories/i18n-service.factory"; import { PlatformUtilsServiceInitOptions, platformUtilsServiceFactory, -} from "../../../background/service_factories/platform-utils-service.factory"; +} from "../../../platform/background/service-factories/platform-utils-service.factory"; type TwoFactorServiceFactoryOptions = FactoryOptions; diff --git a/apps/browser/src/auth/background/service-factories/user-verification-api-service.factory.ts b/apps/browser/src/auth/background/service-factories/user-verification-api-service.factory.ts new file mode 100644 index 00000000000..01bfb0f13cb --- /dev/null +++ b/apps/browser/src/auth/background/service-factories/user-verification-api-service.factory.ts @@ -0,0 +1,29 @@ +import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction"; +import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service"; + +import { + ApiServiceInitOptions, + apiServiceFactory, +} from "../../../platform/background/service-factories/api-service.factory"; +import { + FactoryOptions, + CachedServices, + factory, +} from "../../../platform/background/service-factories/factory-options"; + +type UserVerificationApiServiceFactoryOptions = FactoryOptions; + +export type UserVerificationApiServiceInitOptions = UserVerificationApiServiceFactoryOptions & + ApiServiceInitOptions; + +export function userVerificationApiServiceFactory( + cache: { userVerificationApiService?: UserVerificationApiServiceAbstraction } & CachedServices, + opts: UserVerificationApiServiceInitOptions +): Promise { + return factory( + cache, + "userVerificationApiService", + opts, + async () => new UserVerificationApiService(await apiServiceFactory(cache, opts)) + ); +} diff --git a/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts b/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts new file mode 100644 index 00000000000..79d327c9485 --- /dev/null +++ b/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts @@ -0,0 +1,51 @@ +import { UserVerificationService as AbstractUserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; + +import { + CryptoServiceInitOptions, + cryptoServiceFactory, +} from "../../../platform/background/service-factories/crypto-service.factory"; +import { + FactoryOptions, + CachedServices, + factory, +} from "../../../platform/background/service-factories/factory-options"; +import { + I18nServiceInitOptions, + i18nServiceFactory, +} from "../../../platform/background/service-factories/i18n-service.factory"; +import { + StateServiceInitOptions, + stateServiceFactory, +} from "../../../platform/background/service-factories/state-service.factory"; + +import { + UserVerificationApiServiceInitOptions, + userVerificationApiServiceFactory, +} from "./user-verification-api-service.factory"; + +type UserVerificationServiceFactoryOptions = FactoryOptions; + +export type UserVerificationServiceInitOptions = UserVerificationServiceFactoryOptions & + StateServiceInitOptions & + CryptoServiceInitOptions & + I18nServiceInitOptions & + UserVerificationApiServiceInitOptions; + +export function userVerificationServiceFactory( + cache: { userVerificationService?: AbstractUserVerificationService } & CachedServices, + opts: UserVerificationServiceInitOptions +): Promise { + return factory( + cache, + "userVerificationService", + opts, + async () => + new UserVerificationService( + await stateServiceFactory(cache, opts), + await cryptoServiceFactory(cache, opts), + await i18nServiceFactory(cache, opts), + await userVerificationApiServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/auth/popup/environment.component.ts b/apps/browser/src/auth/popup/environment.component.ts index 5ad42a600c7..c70b5f597c1 100644 --- a/apps/browser/src/auth/popup/environment.component.ts +++ b/apps/browser/src/auth/popup/environment.component.ts @@ -3,10 +3,10 @@ import { Router } from "@angular/router"; import { EnvironmentComponent as BaseEnvironmentComponent } from "@bitwarden/angular/components/environment.component"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { BrowserEnvironmentService } from "../../services/browser-environment.service"; +import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service"; @Component({ selector: "app-environment", diff --git a/apps/browser/src/auth/popup/hint.component.ts b/apps/browser/src/auth/popup/hint.component.ts index a0477bb27a0..a743dc7da24 100644 --- a/apps/browser/src/auth/popup/hint.component.ts +++ b/apps/browser/src/auth/popup/hint.component.ts @@ -3,10 +3,10 @@ import { ActivatedRoute, Router } from "@angular/router"; import { HintComponent as BaseHintComponent } from "@bitwarden/angular/auth/components/hint.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @Component({ selector: "app-hint", diff --git a/apps/browser/src/auth/popup/home.component.html b/apps/browser/src/auth/popup/home.component.html index da208a8d50c..6b42033c4bc 100644 --- a/apps/browser/src/auth/popup/home.component.html +++ b/apps/browser/src/auth/popup/home.component.html @@ -9,7 +9,7 @@ - +
{{ "verifyIdentity" | i18n }}
- +
-
-
+
+
appInputVerbatim />
-
+
{ - document.getElementById(this.pinLock ? "pin" : "masterPassword").focus(); + document.getElementById(this.pinEnabled ? "pin" : "masterPassword")?.focus(); if ( this.biometricLock && !disableAutoBiometricsPrompt && @@ -93,7 +96,7 @@ export class LockComponent extends BaseLockComponent { }, 100); } - async unlockBiometric(): Promise { + override async unlockBiometric(): Promise { if (!this.biometricLock) { return; } diff --git a/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.html b/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.html new file mode 100644 index 00000000000..32e3ea0c598 --- /dev/null +++ b/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.html @@ -0,0 +1,108 @@ +
+
+

+ {{ "loginInitiated" | i18n }} +

+
+ + +
diff --git a/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.ts b/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.ts new file mode 100644 index 00000000000..7fac9a42b06 --- /dev/null +++ b/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.ts @@ -0,0 +1,18 @@ +import { Component } from "@angular/core"; + +import { BaseLoginDecryptionOptionsComponent } from "@bitwarden/angular/auth/components/base-login-decryption-options.component"; + +@Component({ + selector: "browser-login-decryption-options", + templateUrl: "login-decryption-options.component.html", +}) +export class LoginDecryptionOptionsComponent extends BaseLoginDecryptionOptionsComponent { + override async createUser(): Promise { + try { + await super.createUser(); + await this.router.navigate(["/tabs/vault"]); + } catch (error) { + this.validationService.showError(error); + } + } +} diff --git a/apps/browser/src/auth/popup/login-with-device.component.html b/apps/browser/src/auth/popup/login-with-device.component.html index d794b7d212b..127f7ec96fe 100644 --- a/apps/browser/src/auth/popup/login-with-device.component.html +++ b/apps/browser/src/auth/popup/login-with-device.component.html @@ -5,32 +5,57 @@

diff --git a/apps/browser/src/auth/popup/login-with-device.component.ts b/apps/browser/src/auth/popup/login-with-device.component.ts index dae6fd2d4da..f3a1dfffaa0 100644 --- a/apps/browser/src/auth/popup/login-with-device.component.ts +++ b/apps/browser/src/auth/popup/login-with-device.component.ts @@ -1,20 +1,23 @@ +import { Location } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; import { LoginWithDeviceComponent as BaseLoginWithDeviceComponent } from "@bitwarden/angular/auth/components/login-with-device.component"; import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; @@ -42,7 +45,10 @@ export class LoginWithDeviceComponent validationService: ValidationService, stateService: StateService, loginService: LoginService, - syncService: SyncService + syncService: SyncService, + deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + authReqCryptoService: AuthRequestCryptoServiceAbstraction, + private location: Location ) { super( router, @@ -59,10 +65,16 @@ export class LoginWithDeviceComponent anonymousHubService, validationService, stateService, - loginService + loginService, + deviceTrustCryptoService, + authReqCryptoService ); super.onSuccessfulLogin = async () => { await syncService.fullSync(true); }; } + + protected back() { + this.location.back(); + } } 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 @@

- +
-
+
+ +
{ - await syncService.fullSync(true); + syncService.fullSync(true); // If the vault is unlocked then this will clear keys from memory, which we don't want to do if ((await this.authService.getAuthStatus()) !== AuthenticationStatus.Unlocked) { BrowserApi.reloadOpenWindows(); } - const thisWindow = window.open("", "_self"); - thisWindow.close(); + this.win.close(); + }; + + super.onSuccessfulLoginTde = async () => { + syncService.fullSync(true); + }; + + super.onSuccessfulLoginTdeNavigate = async () => { + this.win.close(); }; } } diff --git a/apps/browser/src/auth/popup/two-factor-options.component.html b/apps/browser/src/auth/popup/two-factor-options.component.html index 3fc510bb27c..f25944aba65 100644 --- a/apps/browser/src/auth/popup/two-factor-options.component.html +++ b/apps/browser/src/auth/popup/two-factor-options.component.html @@ -1,6 +1,6 @@
- +

{{ "twoStepOptions" | i18n }} diff --git a/apps/browser/src/auth/popup/two-factor-options.component.ts b/apps/browser/src/auth/popup/two-factor-options.component.ts index 6aa8109f05b..a7e95a2a4ec 100644 --- a/apps/browser/src/auth/popup/two-factor-options.component.ts +++ b/apps/browser/src/auth/popup/two-factor-options.component.ts @@ -1,10 +1,10 @@ import { Component } from "@angular/core"; -import { Router } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { TwoFactorOptionsComponent as BaseTwoFactorOptionsComponent } from "@bitwarden/angular/auth/components/two-factor-options.component"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @Component({ selector: "app-two-factor-options", @@ -15,14 +15,33 @@ export class TwoFactorOptionsComponent extends BaseTwoFactorOptionsComponent { twoFactorService: TwoFactorService, router: Router, i18nService: I18nService, - platformUtilsService: PlatformUtilsService + platformUtilsService: PlatformUtilsService, + private activatedRoute: ActivatedRoute ) { super(twoFactorService, router, i18nService, platformUtilsService, window); } + close() { + this.navigateTo2FA(); + } + choose(p: any) { super.choose(p); this.twoFactorService.setSelectedProvider(p.type); - this.router.navigate(["2fa"]); + + this.navigateTo2FA(); + } + + navigateTo2FA() { + const sso = this.activatedRoute.snapshot.queryParamMap.get("sso") === "true"; + + if (sso) { + // Persist SSO flag back to the 2FA comp if it exists + // in order for successful login logic to work properly for + // SSO + 2FA in browser extension + this.router.navigate(["2fa"], { queryParams: { sso: true } }); + } else { + this.router.navigate(["2fa"]); + } } } diff --git a/apps/browser/src/auth/popup/two-factor.component.html b/apps/browser/src/auth/popup/two-factor.component.html index 8abe2d4aeb6..7fec67378cc 100644 --- a/apps/browser/src/auth/popup/two-factor.component.html +++ b/apps/browser/src/auth/popup/two-factor.component.html @@ -87,7 +87,7 @@

- +
@@ -112,7 +112,9 @@

selectedProviderType === providerType.OrganizationDuo " > -
+
+ +
@@ -123,7 +125,7 @@

- +

{{ "noTwoStepProviders" | i18n }}

diff --git a/apps/browser/src/auth/popup/two-factor.component.ts b/apps/browser/src/auth/popup/two-factor.component.ts index 678675ba389..c0af31d25e5 100644 --- a/apps/browser/src/auth/popup/two-factor.component.ts +++ b/apps/browser/src/auth/popup/two-factor.component.ts @@ -1,25 +1,27 @@ -import { Component } from "@angular/core"; +import { Component, Inject } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { first } from "rxjs/operators"; import { TwoFactorComponent as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor.component"; -import { DialogServiceAbstraction, SimpleDialogType } from "@bitwarden/angular/services/dialog"; +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; -import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { DialogService } from "@bitwarden/components"; -import { BrowserApi } from "../../browser/browserApi"; +import { BrowserApi } from "../../platform/browser/browser-api"; import { PopupUtilsService } from "../../popup/services/popup-utils.service"; const BroadcasterSubscriptionId = "TwoFactorComponent"; @@ -48,7 +50,9 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { twoFactorService: TwoFactorService, appIdService: AppIdService, loginService: LoginService, - private dialogService: DialogServiceAbstraction + configService: ConfigServiceAbstraction, + private dialogService: DialogService, + @Inject(WINDOW) protected win: Window ) { super( authService, @@ -56,19 +60,28 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { i18nService, apiService, platformUtilsService, - window, + win, environmentService, stateService, route, logService, twoFactorService, appIdService, - loginService + loginService, + configService ); - super.onSuccessfulLogin = () => { - this.loginService.clearValues(); - return syncService.fullSync(true); + super.onSuccessfulLogin = async () => { + syncService.fullSync(true); }; + + super.onSuccessfulLoginTde = async () => { + syncService.fullSync(true); + }; + + super.onSuccessfulLoginTdeNavigate = async () => { + this.win.close(); + }; + super.successRoute = "/tabs/vault"; // FIXME: Chromium 110 has broken WebAuthn support in extensions via an iframe this.webAuthnNewTab = true; @@ -107,7 +120,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "warning" }, content: { key: "popup2faCloseMessage" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (confirmed) { this.popupUtilsService.popOut(window); @@ -117,11 +130,19 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.queryParams.pipe(first()).subscribe(async (qParams) => { if (qParams.sso === "true") { - super.onSuccessfulLogin = () => { - BrowserApi.reloadOpenWindows(); - const thisWindow = window.open("", "_self"); - thisWindow.close(); - return this.syncService.fullSync(true); + super.onSuccessfulLogin = async () => { + // This is not awaited so we don't pause the application while the sync is happening. + // This call is executed by the service that lives in the background script so it will continue + // the sync even if this tab closes. + this.syncService.fullSync(true); + + // Force sidebars (FF && Opera) to reload while exempting current window + // because we are just going to close the current window. + BrowserApi.reloadOpenWindows(true); + + // We don't need this window anymore because the intent is for the user to be left + // on the web vault screen which tells them to continue in the browser extension (sidebar or popup) + BrowserApi.closeBitwardenExtensionTab(); }; } }); @@ -137,7 +158,15 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { } anotherMethod() { - this.router.navigate(["2fa-options"]); + const sso = this.route.snapshot.queryParamMap.get("sso") === "true"; + + if (sso) { + // We must persist this so when the user returns to the 2FA comp, the + // proper onSuccessfulLogin logic is executed. + this.router.navigate(["2fa-options"], { queryParams: { sso: true } }); + } else { + this.router.navigate(["2fa-options"]); + } } async isLinux() { diff --git a/apps/browser/src/autofill/background/context-menus.background.ts b/apps/browser/src/autofill/background/context-menus.background.ts index 9d04571a7c4..bc26353cbd9 100644 --- a/apps/browser/src/autofill/background/context-menus.background.ts +++ b/apps/browser/src/autofill/background/context-menus.background.ts @@ -1,5 +1,5 @@ import LockedVaultPendingNotificationsItem from "../../background/models/lockedVaultPendingNotificationsItem"; -import { BrowserApi } from "../../browser/browserApi"; +import { BrowserApi } from "../../platform/browser/browser-api"; import { ContextMenuClickedHandler } from "../browser/context-menu-clicked-handler"; export default class ContextMenusBackground { @@ -30,6 +30,7 @@ export default class ContextMenusBackground { msg.data.commandToRetry.msg.data, msg.data.commandToRetry.sender.tab ); + await BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar"); } } ); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index b7cdfd97929..73bdc2cd16f 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -5,24 +5,30 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ThemeType } from "@bitwarden/common/enums"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import AddUnlockVaultQueueMessage from "../../background/models/add-unlock-vault-queue-message"; import AddChangePasswordQueueMessage from "../../background/models/addChangePasswordQueueMessage"; import AddLoginQueueMessage from "../../background/models/addLoginQueueMessage"; import AddLoginRuntimeMessage from "../../background/models/addLoginRuntimeMessage"; import ChangePasswordRuntimeMessage from "../../background/models/changePasswordRuntimeMessage"; import LockedVaultPendingNotificationsItem from "../../background/models/lockedVaultPendingNotificationsItem"; import { NotificationQueueMessageType } from "../../background/models/notificationQueueMessageType"; -import { BrowserApi } from "../../browser/browserApi"; -import { BrowserStateService } from "../../services/abstractions/browser-state.service"; +import { BrowserApi } from "../../platform/browser/browser-api"; +import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; import { AutofillService } from "../services/abstractions/autofill.service"; export default class NotificationBackground { - private notificationQueue: (AddLoginQueueMessage | AddChangePasswordQueueMessage)[] = []; + private notificationQueue: ( + | AddLoginQueueMessage + | AddChangePasswordQueueMessage + | AddUnlockVaultQueueMessage + )[] = []; constructor( private autofillService: AutofillService, @@ -30,7 +36,8 @@ export default class NotificationBackground { private authService: AuthService, private policyService: PolicyService, private folderService: FolderService, - private stateService: BrowserStateService + private stateService: BrowserStateService, + private environmentService: EnvironmentService ) {} async init() { @@ -51,10 +58,7 @@ export default class NotificationBackground { async processMessage(msg: any, sender: chrome.runtime.MessageSender) { switch (msg.command) { case "unlockCompleted": - if (msg.data.target !== "notification.background") { - return; - } - await this.processMessage(msg.data.commandToRetry.msg, msg.data.commandToRetry.sender); + await this.handleUnlockCompleted(msg.data, sender); break; case "bgGetDataForTab": await this.getDataForTab(sender.tab, msg.responseCommand); @@ -80,7 +84,9 @@ export default class NotificationBackground { if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) { const retryMessage: LockedVaultPendingNotificationsItem = { commandToRetry: { - msg: msg, + msg: { + command: msg, + }, sender: sender, }, target: "notification.background", @@ -112,6 +118,9 @@ export default class NotificationBackground { break; } break; + case "promptForLogin": + await this.unlockVault(sender.tab); + break; default: break; } @@ -167,11 +176,21 @@ export default class NotificationBackground { isVaultLocked: this.notificationQueue[i].wasVaultLocked, theme: await this.getCurrentTheme(), removeIndividualVault: await this.removeIndividualVault(), + webVaultURL: await this.environmentService.getWebVaultUrl(), }, }); } else if (this.notificationQueue[i].type === NotificationQueueMessageType.ChangePassword) { BrowserApi.tabSendMessageData(tab, "openNotificationBar", { type: "change", + typeData: { + isVaultLocked: this.notificationQueue[i].wasVaultLocked, + theme: await this.getCurrentTheme(), + webVaultURL: await this.environmentService.getWebVaultUrl(), + }, + }); + } else if (this.notificationQueue[i].type === NotificationQueueMessageType.UnlockVault) { + BrowserApi.tabSendMessageData(tab, "openNotificationBar", { + type: "unlock", typeData: { isVaultLocked: this.notificationQueue[i].wasVaultLocked, theme: await this.getCurrentTheme(), @@ -301,6 +320,20 @@ export default class NotificationBackground { } } + private async unlockVault(tab: chrome.tabs.Tab) { + const currentAuthStatus = await this.authService.getAuthStatus(); + if (currentAuthStatus !== AuthenticationStatus.Locked || this.notificationQueue.length) { + return; + } + + const loginDomain = Utils.getDomain(tab.url); + if (!loginDomain) { + return; + } + + this.pushUnlockVaultToQueue(loginDomain, tab); + } + private async pushChangePasswordToQueue( cipherId: string, loginDomain: string, @@ -323,6 +356,20 @@ export default class NotificationBackground { await this.checkNotificationQueue(tab); } + private async pushUnlockVaultToQueue(loginDomain: string, tab: chrome.tabs.Tab) { + this.removeTabFromNotificationQueue(tab); + const message: AddUnlockVaultQueueMessage = { + type: NotificationQueueMessageType.UnlockVault, + domain: loginDomain, + tabId: tab.id, + expires: new Date(new Date().getTime() + 0.5 * 60000), // 30 seconds + wasVaultLocked: true, + }; + this.notificationQueue.push(message); + await this.checkNotificationQueue(tab); + this.removeTabFromNotificationQueue(tab); + } + private async saveOrUpdateCredentials(tab: chrome.tabs.Tab, edit: boolean, folderId?: string) { for (let i = this.notificationQueue.length - 1; i >= 0; i--) { const queueMessage = this.notificationQueue[i]; @@ -417,7 +464,7 @@ export default class NotificationBackground { private async getDecryptedCipherById(cipherId: string) { const cipher = await this.cipherService.get(cipherId); if (cipher != null && cipher.type === CipherType.Login) { - return await cipher.decrypt(); + return await cipher.decrypt(await this.cipherService.getKeyForCipherKeyDecryption(cipher)); } return null; } @@ -459,4 +506,22 @@ export default class NotificationBackground { this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership) ); } + + private async handleUnlockCompleted( + messageData: LockedVaultPendingNotificationsItem, + sender: chrome.runtime.MessageSender + ): Promise { + if (messageData.commandToRetry.msg.command === "autofill_login") { + await BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar"); + } + + if (messageData.target !== "notification.background") { + return; + } + + await this.processMessage( + messageData.commandToRetry.msg.command, + messageData.commandToRetry.sender + ); + } } diff --git a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts index a23b5e8dbaf..efa5bdcffbb 100644 --- a/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts +++ b/apps/browser/src/autofill/background/service_factories/autofill-service.factory.ts @@ -2,27 +2,31 @@ import { TotpServiceInitOptions, totpServiceFactory, } from "../../../auth/background/service-factories/totp-service.factory"; +import { + UserVerificationServiceInitOptions, + userVerificationServiceFactory, +} from "../../../auth/background/service-factories/user-verification-service.factory"; import { EventCollectionServiceInitOptions, eventCollectionServiceFactory, -} from "../../../background/service_factories/event-collection-service.factory"; +} from "../../../background/service-factories/event-collection-service.factory"; +import { + settingsServiceFactory, + SettingsServiceInitOptions, +} from "../../../background/service-factories/settings-service.factory"; import { CachedServices, factory, FactoryOptions, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; import { logServiceFactory, LogServiceInitOptions, -} from "../../../background/service_factories/log-service.factory"; -import { - settingsServiceFactory, - SettingsServiceInitOptions, -} from "../../../background/service_factories/settings-service.factory"; +} from "../../../platform/background/service-factories/log-service.factory"; import { stateServiceFactory, StateServiceInitOptions, -} from "../../../background/service_factories/state-service.factory"; +} from "../../../platform/background/service-factories/state-service.factory"; import { cipherServiceFactory, CipherServiceInitOptions, @@ -38,7 +42,8 @@ export type AutoFillServiceInitOptions = AutoFillServiceOptions & TotpServiceInitOptions & EventCollectionServiceInitOptions & LogServiceInitOptions & - SettingsServiceInitOptions; + SettingsServiceInitOptions & + UserVerificationServiceInitOptions; export function autofillServiceFactory( cache: { autofillService?: AbstractAutoFillService } & CachedServices, @@ -55,7 +60,8 @@ export function autofillServiceFactory( await totpServiceFactory(cache, opts), await eventCollectionServiceFactory(cache, opts), await logServiceFactory(cache, opts), - await settingsServiceFactory(cache, opts) + await settingsServiceFactory(cache, opts), + await userVerificationServiceFactory(cache, opts) ) ); } diff --git a/apps/browser/src/autofill/background/tabs.background.ts b/apps/browser/src/autofill/background/tabs.background.ts index 0f724c84dd5..0655fd23b62 100644 --- a/apps/browser/src/autofill/background/tabs.background.ts +++ b/apps/browser/src/autofill/background/tabs.background.ts @@ -21,6 +21,8 @@ export default class TabsBackground { } this.focusedWindowId = windowId; + await this.main.refreshBadge(); + await this.main.refreshMenu(); this.main.messagingService.send("windowChanged"); }); 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 53d3d100769..4532718809e 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 @@ -69,12 +69,12 @@ describe("CipherContextMenuHandler", () => { expect(mainContextMenuHandler.noLogins).toHaveBeenCalledTimes(1); }); - it("only adds valid ciphers", async () => { + it("only adds autofill ciphers including ciphers that require reprompt", async () => { authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked); mainContextMenuHandler.init.mockResolvedValue(true); - const realCipher = { + const loginCipher = { id: "5", type: CipherType.Login, reprompt: CipherRepromptType.None, @@ -82,27 +82,57 @@ describe("CipherContextMenuHandler", () => { login: { username: "Test Username" }, }; + const repromptLoginCipher = { + id: "6", + type: CipherType.Login, + reprompt: CipherRepromptType.Password, + name: "Test Reprompt Cipher", + login: { username: "Test Username" }, + }; + + const cardCipher = { + id: "7", + type: CipherType.Card, + name: "Test Card Cipher", + card: { username: "Test Username" }, + }; + cipherService.getAllDecryptedForUrl.mockResolvedValue([ - null, - undefined, - { type: CipherType.Card }, - { type: CipherType.Login, reprompt: CipherRepromptType.Password }, - realCipher, + null, // invalid cipher + undefined, // invalid cipher + { type: CipherType.SecureNote }, // invalid cipher + loginCipher, // valid cipher + repromptLoginCipher, + cardCipher, // valid cipher ] as any[]); await sut.update("https://test.com"); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1); - expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com"); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com", [ + CipherType.Card, + CipherType.Identity, + ]); - expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledTimes(1); + expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledTimes(3); expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith( "Test Cipher (Test Username)", "5", - "https://test.com", - realCipher + loginCipher + ); + + expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith( + "Test Reprompt Cipher (Test Username)", + "6", + repromptLoginCipher + ); + + expect(mainContextMenuHandler.loadOptions).toHaveBeenCalledWith( + "Test Card Cipher", + "7", + cardCipher ); }); }); 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 8803267685c..f46442c4192 100644 --- a/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/cipher-context-menu-handler.ts @@ -1,10 +1,9 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { StateFactory } from "@bitwarden/common/factories/stateFactory"; -import { Utils } from "@bitwarden/common/misc/utils"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; +import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; 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"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -12,13 +11,14 @@ import { authServiceFactory, AuthServiceInitOptions, } from "../../auth/background/service-factories/auth-service.factory"; -import { CachedServices } from "../../background/service_factories/factory-options"; -import { BrowserApi } from "../../browser/browserApi"; import { Account } from "../../models/account"; +import { CachedServices } from "../../platform/background/service-factories/factory-options"; +import { BrowserApi } from "../../platform/browser/browser-api"; import { cipherServiceFactory, CipherServiceInitOptions, } from "../../vault/background/service_factories/cipher-service.factory"; +import { AutofillCipherTypeId } from "../types"; import { MainContextMenuHandler } from "./main-context-menu-handler"; @@ -67,9 +67,6 @@ export class CipherContextMenuHandler { clipboardWriteCallback: NOT_IMPLEMENTED, win: self, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, @@ -81,6 +78,12 @@ export class CipherContextMenuHandler { ); } + static async windowsOnFocusChangedListener(windowId: number, serviceCache: CachedServices) { + const cipherContextMenuHandler = await CipherContextMenuHandler.create(serviceCache); + const tab = await BrowserApi.getTabFromCurrentWindow(); + await cipherContextMenuHandler.update(tab?.url); + } + static async tabsOnActivatedListener( activeInfo: chrome.tabs.TabActiveInfo, serviceCache: CachedServices @@ -157,33 +160,67 @@ export class CipherContextMenuHandler { return; } - const ciphers = await this.cipherService.getAllDecryptedForUrl(url); + const ciphers = await this.cipherService.getAllDecryptedForUrl(url, [ + CipherType.Card, + CipherType.Identity, + ]); ciphers.sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b)); - if (ciphers.length === 0) { - await this.mainContextMenuHandler.noLogins(url); - return; + const groupedCiphers: Record = ciphers.reduce( + (ciphersByType, cipher) => { + if (!cipher?.type) { + return ciphersByType; + } + + const existingCiphersOfType = ciphersByType[cipher.type as AutofillCipherTypeId] || []; + + return { + ...ciphersByType, + [cipher.type]: [...existingCiphersOfType, cipher], + }; + }, + { + [CipherType.Login]: [], + [CipherType.Card]: [], + [CipherType.Identity]: [], + } + ); + + if (groupedCiphers[CipherType.Login].length === 0) { + await this.mainContextMenuHandler.noLogins(); + } + + if (groupedCiphers[CipherType.Identity].length === 0) { + await this.mainContextMenuHandler.noIdentities(); + } + + if (groupedCiphers[CipherType.Card].length === 0) { + await this.mainContextMenuHandler.noCards(); } for (const cipher of ciphers) { - await this.updateForCipher(url, cipher); + await this.updateForCipher(cipher); } } - private async updateForCipher(url: string, cipher: CipherView) { + private async updateForCipher(cipher: CipherView) { if ( cipher == null || - cipher.type !== CipherType.Login || - cipher.reprompt !== CipherRepromptType.None + !new Set([CipherType.Login, CipherType.Card, CipherType.Identity]).has(cipher.type) ) { return; } let title = cipher.name; - if (!Utils.isNullOrEmpty(title)) { + + if (cipher.type === CipherType.Login && !Utils.isNullOrEmpty(title) && cipher.login?.username) { title += ` (${cipher.login.username})`; } - await this.mainContextMenuHandler.loadOptions(title, cipher.id, url, cipher); + if (cipher.type === CipherType.Card && cipher.card?.subTitle) { + title += ` ${cipher.card.subTitle}`; + } + + await this.mainContextMenuHandler.loadOptions(title, cipher.id, cipher); } } 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..5c6b5e07da3 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,12 +3,23 @@ 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 { StateService } from "@bitwarden/common/platform/abstractions/state.service"; 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"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + AUTOFILL_ID, + COPY_PASSWORD_ID, + COPY_USERNAME_ID, + COPY_VERIFICATIONCODE_ID, + GENERATE_PASSWORD_ID, + NOOP_COMMAND_SUFFIX, +} from "../constants"; + import { CopyToClipboardAction, ContextMenuClickedHandler, @@ -16,13 +27,6 @@ import { GeneratePasswordToClipboardAction, AutofillAction, } from "./context-menu-clicked-handler"; -import { - AUTOFILL_ID, - COPY_PASSWORD_ID, - COPY_USERNAME_ID, - COPY_VERIFICATIONCODE_ID, - GENERATE_PASSWORD_ID, -} from "./main-context-menu-handler"; describe("ContextMenuClickedHandler", () => { const createData = ( @@ -50,6 +54,7 @@ describe("ContextMenuClickedHandler", () => { type: CipherType.Login, } as any) ); + cipherView.login.username = username ?? "USERNAME"; cipherView.login.password = password ?? "PASSWORD"; cipherView.login.totp = totp ?? "TOTP"; @@ -61,8 +66,10 @@ describe("ContextMenuClickedHandler", () => { let autofill: AutofillAction; let authService: MockProxy; let cipherService: MockProxy; + let stateService: MockProxy; let totpService: MockProxy; let eventCollectionService: MockProxy; + let userVerificationService: MockProxy; let sut: ContextMenuClickedHandler; @@ -72,6 +79,7 @@ describe("ContextMenuClickedHandler", () => { autofill = jest.fn, [tab: chrome.tabs.Tab, cipher: CipherView]>(); authService = mock(); cipherService = mock(); + stateService = mock(); totpService = mock(); eventCollectionService = mock(); @@ -81,8 +89,10 @@ describe("ContextMenuClickedHandler", () => { autofill, authService, cipherService, + stateService, totpService, - eventCollectionService + eventCollectionService, + userVerificationService ); }); @@ -103,7 +113,7 @@ describe("ContextMenuClickedHandler", () => { const cipher = createCipher(); cipherService.getAllDecrypted.mockResolvedValue([cipher]); - await sut.run(createData("T_1", AUTOFILL_ID), { id: 5 } as any); + await sut.run(createData(`${AUTOFILL_ID}_1`, AUTOFILL_ID), { id: 5 } as any); expect(autofill).toBeCalledTimes(1); @@ -115,11 +125,16 @@ describe("ContextMenuClickedHandler", () => { createCipher({ username: "TEST_USERNAME" }), ]); - await sut.run(createData("T_1", COPY_USERNAME_ID)); + await sut.run(createData(`${COPY_USERNAME_ID}_1`, COPY_USERNAME_ID), { + url: "https://test.com", + } as any); expect(copyToClipboard).toBeCalledTimes(1); - expect(copyToClipboard).toHaveBeenCalledWith({ text: "TEST_USERNAME", options: undefined }); + expect(copyToClipboard).toHaveBeenCalledWith({ + text: "TEST_USERNAME", + tab: { url: "https://test.com" }, + }); }); it("copies password to clipboard", async () => { @@ -127,11 +142,16 @@ describe("ContextMenuClickedHandler", () => { createCipher({ password: "TEST_PASSWORD" }), ]); - await sut.run(createData("T_1", COPY_PASSWORD_ID)); + await sut.run(createData(`${COPY_PASSWORD_ID}_1`, COPY_PASSWORD_ID), { + url: "https://test.com", + } as any); expect(copyToClipboard).toBeCalledTimes(1); - expect(copyToClipboard).toHaveBeenCalledWith({ text: "TEST_PASSWORD", options: undefined }); + expect(copyToClipboard).toHaveBeenCalledWith({ + text: "TEST_PASSWORD", + tab: { url: "https://test.com" }, + }); }); it("copies totp code to clipboard", async () => { @@ -145,11 +165,16 @@ describe("ContextMenuClickedHandler", () => { return Promise.resolve("654321"); }); - await sut.run(createData("T_1", COPY_VERIFICATIONCODE_ID)); + await sut.run(createData(`${COPY_VERIFICATIONCODE_ID}_1`, COPY_VERIFICATIONCODE_ID), { + url: "https://test.com", + } as any); expect(totpService.getCode).toHaveBeenCalledTimes(1); - expect(copyToClipboard).toHaveBeenCalledWith({ text: "123456" }); + expect(copyToClipboard).toHaveBeenCalledWith({ + text: "123456", + tab: { url: "https://test.com" }, + }); }); it("attempts to find a cipher when noop but unlocked", async () => { @@ -160,11 +185,13 @@ describe("ContextMenuClickedHandler", () => { } as any, ]); - await sut.run(createData("T_noop", COPY_USERNAME_ID), { url: "https://test.com" } as any); + await sut.run(createData(`${COPY_USERNAME_ID}_${NOOP_COMMAND_SUFFIX}`, COPY_USERNAME_ID), { + url: "https://test.com", + } as any); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1); - expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com"); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com", []); expect(copyToClipboard).toHaveBeenCalledTimes(1); @@ -182,11 +209,13 @@ describe("ContextMenuClickedHandler", () => { } as any, ]); - await sut.run(createData("T_noop", COPY_USERNAME_ID), { url: "https://test.com" } as any); + await sut.run(createData(`${COPY_USERNAME_ID}_${NOOP_COMMAND_SUFFIX}`, COPY_USERNAME_ID), { + url: "https://test.com", + } as any); expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledTimes(1); - expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com"); + expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith("https://test.com", []); }); }); }); 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 4b75942e031..54c3e244991 100644 --- a/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts +++ b/apps/browser/src/autofill/browser/context-menu-clicked-handler.ts @@ -1,12 +1,15 @@ 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/factories/stateFactory"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; 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"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { @@ -14,13 +17,14 @@ 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 { CachedServices } from "../../background/service_factories/factory-options"; -import { passwordGenerationServiceFactory } from "../../background/service_factories/password-generation-service.factory"; -import { stateServiceFactory } from "../../background/service_factories/state-service.factory"; -import { BrowserApi } from "../../browser/browserApi"; +import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory"; import { Account } from "../../models/account"; +import { CachedServices } from "../../platform/background/service-factories/factory-options"; +import { stateServiceFactory } from "../../platform/background/service-factories/state-service.factory"; +import { BrowserApi } from "../../platform/browser/browser-api"; +import { passwordGenerationServiceFactory } from "../../tools/background/service_factories/password-generation-service.factory"; import { cipherServiceFactory, CipherServiceInitOptions, @@ -28,16 +32,21 @@ import { import { autofillServiceFactory } from "../background/service_factories/autofill-service.factory"; import { copyToClipboard, GeneratePasswordToClipboardCommand } from "../clipboard"; import { AutofillTabCommand } from "../commands/autofill-tab-command"; - import { + AUTOFILL_CARD_ID, AUTOFILL_ID, + AUTOFILL_IDENTITY_ID, COPY_IDENTIFIER_ID, COPY_PASSWORD_ID, COPY_USERNAME_ID, COPY_VERIFICATIONCODE_ID, + CREATE_CARD_ID, + CREATE_IDENTITY_ID, + CREATE_LOGIN_ID, GENERATE_PASSWORD_ID, NOOP_COMMAND_SUFFIX, -} from "./main-context-menu-handler"; +} from "../constants"; +import { AutofillCipherTypeId } from "../types"; export type CopyToClipboardOptions = { text: string; tab: chrome.tabs.Tab }; export type CopyToClipboardAction = (options: CopyToClipboardOptions) => void; @@ -55,8 +64,10 @@ export class ContextMenuClickedHandler { private autofillAction: AutofillAction, private authService: AuthService, private cipherService: CipherService, + private stateService: StateService, private totpService: TotpService, - private eventCollectionService: EventCollectionService + private eventCollectionService: EventCollectionService, + private userVerificationService: UserVerificationService ) {} static async mv3Create(cachedServices: CachedServices) { @@ -85,9 +96,6 @@ export class ContextMenuClickedHandler { clipboardWriteCallback: NOT_IMPLEMENTED, win: self, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, @@ -108,8 +116,10 @@ export class ContextMenuClickedHandler { (tab, cipher) => autofillCommand.doAutofillTabWithCipherCommand(tab, cipher), await authServiceFactory(cachedServices, serviceOptions), await cipherServiceFactory(cachedServices, serviceOptions), + await stateServiceFactory(cachedServices, serviceOptions), await totpServiceFactory(cachedServices, serviceOptions), - await eventCollectionServiceFactory(cachedServices, serviceOptions) + await eventCollectionServiceFactory(cachedServices, serviceOptions), + await userVerificationServiceFactory(cachedServices, serviceOptions) ); } @@ -141,18 +151,16 @@ export class ContextMenuClickedHandler { ); } - async run(info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) { + async run(info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) { + if (!tab) { + return; + } + switch (info.menuItemId) { case GENERATE_PASSWORD_ID: - if (!tab) { - return; - } await this.generatePasswordToClipboard(tab); break; case COPY_IDENTIFIER_ID: - if (!tab) { - return; - } this.copyToClipboard({ text: await this.getIdentifier(tab, info), tab: tab }); break; default: @@ -160,7 +168,11 @@ export class ContextMenuClickedHandler { } } - async cipherAction(info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) { + async cipherAction(info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) { + if (!tab) { + return; + } + if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) { const retryMessage: LockedVaultPendingNotificationsItem = { commandToRetry: { @@ -181,43 +193,136 @@ export class ContextMenuClickedHandler { // NOTE: We don't actually use the first part of this ID, we further switch based on the parentMenuItemId // I would really love to not add it but that is a departure from how it currently works. - const id = (info.menuItemId as string).split("_")[1]; // We create all the ids, we can guarantee they are strings + const menuItemId = (info.menuItemId as string).split("_")[1]; // We create all the ids, we can guarantee they are strings let cipher: CipherView | undefined; - if (id === NOOP_COMMAND_SUFFIX) { + const isCreateCipherAction = [CREATE_LOGIN_ID, CREATE_IDENTITY_ID, CREATE_CARD_ID].includes( + menuItemId as string + ); + + if (isCreateCipherAction) { + // pass; defer to logic below + } else if (menuItemId === NOOP_COMMAND_SUFFIX) { + const additionalCiphersToGet = + info.parentMenuItemId === AUTOFILL_IDENTITY_ID + ? [CipherType.Identity] + : info.parentMenuItemId === AUTOFILL_CARD_ID + ? [CipherType.Card] + : []; + // This NOOP item has come through which is generally only for no access state but since we got here // we are actually unlocked we will do our best to find a good match of an item to autofill this is useful // in scenarios like unlock on autofill - const ciphers = await this.cipherService.getAllDecryptedForUrl(tab.url); - cipher = ciphers.find((c) => c.reprompt === CipherRepromptType.None); + const ciphers = await this.cipherService.getAllDecryptedForUrl( + tab.url, + additionalCiphersToGet + ); + + cipher = ciphers[0]; } else { const ciphers = await this.cipherService.getAllDecrypted(); - cipher = ciphers.find((c) => c.id === id); + cipher = ciphers.find(({ id }) => id === menuItemId); } - if (cipher == null) { + if (!cipher && !isCreateCipherAction) { return; } + this.stateService.setLastActive(new Date().getTime()); switch (info.parentMenuItemId) { case AUTOFILL_ID: - if (tab == null) { - return; + case AUTOFILL_IDENTITY_ID: + case AUTOFILL_CARD_ID: { + const cipherType = this.getCipherCreationType(menuItemId); + + if (cipherType) { + await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { + cipherType, + }); + break; } - await this.autofillAction(tab, cipher); + + if (await this.isPasswordRepromptRequired(cipher)) { + await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { + cipherId: cipher.id, + // The action here is passed on to the single-use reprompt window and doesn't change based on cipher type + action: AUTOFILL_ID, + }); + } else { + await this.autofillAction(tab, cipher); + } + break; + } case COPY_USERNAME_ID: + if (menuItemId === CREATE_LOGIN_ID) { + await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { + cipherType: CipherType.Login, + }); + break; + } + this.copyToClipboard({ text: cipher.login.username, tab: tab }); break; case COPY_PASSWORD_ID: - this.copyToClipboard({ text: cipher.login.password, tab: tab }); - this.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id); + if (menuItemId === CREATE_LOGIN_ID) { + await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { + cipherType: CipherType.Login, + }); + break; + } + + if (await this.isPasswordRepromptRequired(cipher)) { + await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { + cipherId: cipher.id, + action: info.parentMenuItemId, + }); + } else { + this.copyToClipboard({ text: cipher.login.password, tab: tab }); + this.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id); + } + break; case COPY_VERIFICATIONCODE_ID: - this.copyToClipboard({ text: await this.totpService.getCode(cipher.login.totp), tab: tab }); + if (menuItemId === CREATE_LOGIN_ID) { + await BrowserApi.tabSendMessageData(tab, "openAddEditCipher", { + cipherType: CipherType.Login, + }); + break; + } + + if (await this.isPasswordRepromptRequired(cipher)) { + await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { + cipherId: cipher.id, + action: info.parentMenuItemId, + }); + } else { + this.copyToClipboard({ + text: await this.totpService.getCode(cipher.login.totp), + tab: tab, + }); + } + break; } } + private async isPasswordRepromptRequired(cipher: CipherView): Promise { + return ( + cipher.reprompt === CipherRepromptType.Password && + (await this.userVerificationService.hasMasterPasswordAndMasterKeyHash()) + ); + } + + private getCipherCreationType(menuItemId?: string): AutofillCipherTypeId | null { + return menuItemId === CREATE_IDENTITY_ID + ? CipherType.Identity + : menuItemId === CREATE_CARD_ID + ? CipherType.Card + : menuItemId === CREATE_LOGIN_ID + ? CipherType.Login + : null; + } + 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.spec.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts index 9d6a1db84a7..95916d2e6f1 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts @@ -1,12 +1,12 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { BrowserStateService } from "../../services/abstractions/browser-state.service"; +import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; import { MainContextMenuHandler } from "./main-context-menu-handler"; @@ -60,7 +60,7 @@ describe("context-menu", () => { const createdMenu = await sut.init(); expect(createdMenu).toBeTruthy(); - expect(createSpy).toHaveBeenCalledTimes(7); + expect(createSpy).toHaveBeenCalledTimes(10); }); it("has menu enabled and has premium", async () => { @@ -70,7 +70,7 @@ describe("context-menu", () => { const createdMenu = await sut.init(); expect(createdMenu).toBeTruthy(); - expect(createSpy).toHaveBeenCalledTimes(8); + expect(createSpy).toHaveBeenCalledTimes(11); }); }); @@ -97,7 +97,7 @@ describe("context-menu", () => { }; it("is not a login cipher", async () => { - await sut.loadOptions("TEST_TITLE", "1", "", { + await sut.loadOptions("TEST_TITLE", "1", { ...createCipher(), type: CipherType.SecureNote, } as any); @@ -109,7 +109,6 @@ describe("context-menu", () => { await sut.loadOptions( "TEST_TITLE", "1", - "", createCipher({ username: "", totp: "", @@ -123,18 +122,18 @@ describe("context-menu", () => { it("create entry for each cipher piece", async () => { stateService.getCanAccessPremium.mockResolvedValue(true); - await sut.loadOptions("TEST_TITLE", "1", "", createCipher()); + await sut.loadOptions("TEST_TITLE", "1", createCipher()); // One for autofill, copy username, copy password, and copy totp code expect(createSpy).toHaveBeenCalledTimes(4); }); - it("creates noop item for no cipher", async () => { + it("creates a login/unlock item for each context menu action option when user is not authenticated", async () => { stateService.getCanAccessPremium.mockResolvedValue(true); - await sut.loadOptions("TEST_TITLE", "NOOP", ""); + await sut.loadOptions("TEST_TITLE", "NOOP"); - expect(createSpy).toHaveBeenCalledTimes(4); + expect(createSpy).toHaveBeenCalledTimes(6); }); }); }); 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 14775c846f3..cc5cc9a5166 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.ts @@ -1,42 +1,44 @@ -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { StateFactory } from "@bitwarden/common/factories/stateFactory"; -import { Utils } from "@bitwarden/common/misc/utils"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { CachedServices } from "../../background/service_factories/factory-options"; +import { Account } from "../../models/account"; +import { CachedServices } from "../../platform/background/service-factories/factory-options"; import { i18nServiceFactory, I18nServiceInitOptions, -} from "../../background/service_factories/i18n-service.factory"; +} from "../../platform/background/service-factories/i18n-service.factory"; import { logServiceFactory, LogServiceInitOptions, -} from "../../background/service_factories/log-service.factory"; +} from "../../platform/background/service-factories/log-service.factory"; import { stateServiceFactory, StateServiceInitOptions, -} from "../../background/service_factories/state-service.factory"; -import { Account } from "../../models/account"; -import { BrowserStateService } from "../../services/abstractions/browser-state.service"; - -export const ROOT_ID = "root"; - -export const AUTOFILL_ID = "autofill"; -export const COPY_USERNAME_ID = "copy-username"; -export const COPY_PASSWORD_ID = "copy-password"; -export const COPY_VERIFICATIONCODE_ID = "copy-totp"; -export const COPY_IDENTIFIER_ID = "copy-identifier"; - -const SEPARATOR_ID = "separator"; -export const GENERATE_PASSWORD_ID = "generate-password"; - -export const NOOP_COMMAND_SUFFIX = "noop"; +} from "../../platform/background/service-factories/state-service.factory"; +import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; +import { + AUTOFILL_CARD_ID, + AUTOFILL_ID, + AUTOFILL_IDENTITY_ID, + COPY_IDENTIFIER_ID, + COPY_PASSWORD_ID, + COPY_USERNAME_ID, + COPY_VERIFICATIONCODE_ID, + CREATE_CARD_ID, + CREATE_IDENTITY_ID, + CREATE_LOGIN_ID, + GENERATE_PASSWORD_ID, + NOOP_COMMAND_SUFFIX, + ROOT_ID, + SEPARATOR_ID, +} from "../constants"; export class MainContextMenuHandler { - // private initRunning = false; create: (options: chrome.contextMenus.CreateProperties) => Promise; @@ -79,9 +81,6 @@ export class MainContextMenuHandler { logServiceOptions: { isDev: false, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, @@ -123,7 +122,7 @@ export class MainContextMenuHandler { await create({ id: AUTOFILL_ID, parentId: ROOT_ID, - title: this.i18nService.t("autoFill"), + title: this.i18nService.t("autoFillLogin"), }); await create({ @@ -147,7 +146,25 @@ export class MainContextMenuHandler { } await create({ - id: SEPARATOR_ID, + id: SEPARATOR_ID + 1, + type: "separator", + parentId: ROOT_ID, + }); + + await create({ + id: AUTOFILL_IDENTITY_ID, + parentId: ROOT_ID, + title: this.i18nService.t("autoFillIdentity"), + }); + + await create({ + id: AUTOFILL_CARD_ID, + parentId: ROOT_ID, + title: this.i18nService.t("autoFillCard"), + }); + + await create({ + id: SEPARATOR_ID + 2, type: "separator", parentId: ROOT_ID, }); @@ -197,40 +214,52 @@ export class MainContextMenuHandler { }); } - async loadOptions(title: string, id: string, url: string, cipher?: CipherView | undefined) { - if (cipher != null && cipher.type !== CipherType.Login) { - return; - } - + async loadOptions(title: string, optionId: string, cipher?: CipherView) { try { const sanitizedTitle = MainContextMenuHandler.sanitizeContextMenuTitle(title); - const createChildItem = async (parent: string) => { - const menuItemId = `${parent}_${id}`; + const createChildItem = async (parentId: string) => { + const menuItemId = `${parentId}_${optionId}`; + return await this.create({ type: "normal", id: menuItemId, - parentId: parent, + parentId, title: sanitizedTitle, contexts: ["all"], }); }; - if (cipher == null || !Utils.isNullOrEmpty(cipher.login.password)) { + if ( + !cipher || + (cipher.type === CipherType.Login && !Utils.isNullOrEmpty(cipher.login?.password)) + ) { await createChildItem(AUTOFILL_ID); + if (cipher?.viewPassword ?? true) { await createChildItem(COPY_PASSWORD_ID); } } - if (cipher == null || !Utils.isNullOrEmpty(cipher.login.username)) { + if ( + !cipher || + (cipher.type === CipherType.Login && !Utils.isNullOrEmpty(cipher.login?.username)) + ) { await createChildItem(COPY_USERNAME_ID); } const canAccessPremium = await this.stateService.getCanAccessPremium(); - if (canAccessPremium && (cipher == null || !Utils.isNullOrEmpty(cipher.login.totp))) { + if (canAccessPremium && (!cipher || !Utils.isNullOrEmpty(cipher.login?.totp))) { await createChildItem(COPY_VERIFICATIONCODE_ID); } + + if ((!cipher || cipher.type === CipherType.Card) && optionId !== CREATE_LOGIN_ID) { + await createChildItem(AUTOFILL_CARD_ID); + } + + if ((!cipher || cipher.type === CipherType.Identity) && optionId !== CREATE_LOGIN_ID) { + await createChildItem(AUTOFILL_IDENTITY_ID); + } } catch (error) { this.logService.warning(error.message); } @@ -245,13 +274,72 @@ export class MainContextMenuHandler { const authed = await this.stateService.getIsAuthenticated(); await this.loadOptions( this.i18nService.t(authed ? "unlockVaultMenu" : "loginToVaultMenu"), - NOOP_COMMAND_SUFFIX, - "" + NOOP_COMMAND_SUFFIX ); } } - async noLogins(url: string) { - await this.loadOptions(this.i18nService.t("noMatchingLogins"), NOOP_COMMAND_SUFFIX, url); + async noCards() { + await this.create({ + id: `${AUTOFILL_CARD_ID}_NOTICE`, + enabled: false, + parentId: AUTOFILL_CARD_ID, + title: this.i18nService.t("noCards"), + type: "normal", + }); + + await this.create({ + id: `${AUTOFILL_CARD_ID}_${SEPARATOR_ID}`, + parentId: AUTOFILL_CARD_ID, + type: "separator", + }); + + await this.create({ + id: `${AUTOFILL_CARD_ID}_${CREATE_CARD_ID}`, + parentId: AUTOFILL_CARD_ID, + title: this.i18nService.t("addCardMenu"), + type: "normal", + }); + } + + async noIdentities() { + await this.create({ + id: `${AUTOFILL_IDENTITY_ID}_NOTICE`, + enabled: false, + parentId: AUTOFILL_IDENTITY_ID, + title: this.i18nService.t("noIdentities"), + type: "normal", + }); + + await this.create({ + id: `${AUTOFILL_IDENTITY_ID}_${SEPARATOR_ID}`, + parentId: AUTOFILL_IDENTITY_ID, + type: "separator", + }); + + await this.create({ + id: `${AUTOFILL_IDENTITY_ID}_${CREATE_IDENTITY_ID}`, + parentId: AUTOFILL_IDENTITY_ID, + title: this.i18nService.t("addIdentityMenu"), + type: "normal", + }); + } + + async noLogins() { + await this.create({ + id: `${AUTOFILL_ID}_NOTICE`, + enabled: false, + parentId: AUTOFILL_ID, + title: this.i18nService.t("noMatchingLogins"), + type: "normal", + }); + + await this.create({ + id: `${AUTOFILL_ID}_${SEPARATOR_ID}` + 1, + parentId: AUTOFILL_ID, + type: "separator", + }); + + await this.loadOptions(this.i18nService.t("addLoginMenu"), CREATE_LOGIN_ID); } } diff --git a/apps/browser/src/autofill/clipboard/clear-clipboard.spec.ts b/apps/browser/src/autofill/clipboard/clear-clipboard.spec.ts index baced83894d..7bfe7934046 100644 --- a/apps/browser/src/autofill/clipboard/clear-clipboard.spec.ts +++ b/apps/browser/src/autofill/clipboard/clear-clipboard.spec.ts @@ -1,4 +1,4 @@ -import { BrowserApi } from "../../browser/browserApi"; +import { BrowserApi } from "../../platform/browser/browser-api"; import { ClearClipboard } from "./clear-clipboard"; diff --git a/apps/browser/src/autofill/clipboard/clear-clipboard.ts b/apps/browser/src/autofill/clipboard/clear-clipboard.ts index a96a72fc658..f8018bb036a 100644 --- a/apps/browser/src/autofill/clipboard/clear-clipboard.ts +++ b/apps/browser/src/autofill/clipboard/clear-clipboard.ts @@ -1,4 +1,4 @@ -import { BrowserApi } from "../../browser/browserApi"; +import { BrowserApi } from "../../platform/browser/browser-api"; export const clearClipboardAlarmName = "clearClipboard"; diff --git a/apps/browser/src/autofill/clipboard/copy-to-clipboard-command.ts b/apps/browser/src/autofill/clipboard/copy-to-clipboard-command.ts index 926b78b9762..92d35e70e57 100644 --- a/apps/browser/src/autofill/clipboard/copy-to-clipboard-command.ts +++ b/apps/browser/src/autofill/clipboard/copy-to-clipboard-command.ts @@ -1,4 +1,4 @@ -import { BrowserApi } from "../../browser/browserApi"; +import { BrowserApi } from "../../platform/browser/browser-api"; /** * Copies text to the clipboard in a MV3 safe way. diff --git a/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.spec.ts b/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.spec.ts index 38c62231ac8..3001087f74f 100644 --- a/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.spec.ts +++ b/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.spec.ts @@ -2,14 +2,14 @@ import { mock, MockProxy } from "jest-mock-extended"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; -import { setAlarmTime } from "../../alarms/alarm-state"; -import { BrowserApi } from "../../browser/browserApi"; -import { BrowserStateService } from "../../services/abstractions/browser-state.service"; +import { setAlarmTime } from "../../platform/alarms/alarm-state"; +import { BrowserApi } from "../../platform/browser/browser-api"; +import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; import { clearClipboardAlarmName } from "./clear-clipboard"; import { GeneratePasswordToClipboardCommand } from "./generate-password-to-clipboard-command"; -jest.mock("../../alarms/alarm-state", () => { +jest.mock("../../platform/alarms/alarm-state", () => { return { setAlarmTime: jest.fn(), }; diff --git a/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.ts b/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.ts index 7dd37a64ad4..62110166658 100644 --- a/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.ts +++ b/apps/browser/src/autofill/clipboard/generate-password-to-clipboard-command.ts @@ -1,7 +1,7 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; -import { setAlarmTime } from "../../alarms/alarm-state"; -import { BrowserStateService } from "../../services/abstractions/browser-state.service"; +import { setAlarmTime } from "../../platform/alarms/alarm-state"; +import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; import { clearClipboardAlarmName } from "./clear-clipboard"; import { copyToClipboard } from "./copy-to-clipboard-command"; diff --git a/apps/browser/src/autofill/commands/autofill-tab-command.ts b/apps/browser/src/autofill/commands/autofill-tab-command.ts index 4910a6cf6fa..b51edd929ee 100644 --- a/apps/browser/src/autofill/commands/autofill-tab-command.ts +++ b/apps/browser/src/autofill/commands/autofill-tab-command.ts @@ -46,6 +46,7 @@ export class AutofillTabCommand { onlyEmptyFields: false, onlyVisibleFields: false, fillNewPassword: true, + allowTotpAutofill: true, }); } diff --git a/apps/browser/src/autofill/constants.ts b/apps/browser/src/autofill/constants.ts new file mode 100644 index 00000000000..ef82035aef7 --- /dev/null +++ b/apps/browser/src/autofill/constants.ts @@ -0,0 +1,29 @@ +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; + +/* Context Menu item Ids */ +export const AUTOFILL_CARD_ID = "autofill-card"; +export const AUTOFILL_ID = "autofill"; +export const AUTOFILL_IDENTITY_ID = "autofill-identity"; +export const COPY_IDENTIFIER_ID = "copy-identifier"; +export const COPY_PASSWORD_ID = "copy-password"; +export const COPY_USERNAME_ID = "copy-username"; +export const COPY_VERIFICATIONCODE_ID = "copy-totp"; +export const CREATE_CARD_ID = "create-card"; +export const CREATE_IDENTITY_ID = "create-identity"; +export const CREATE_LOGIN_ID = "create-login"; +export const GENERATE_PASSWORD_ID = "generate-password"; +export const NOOP_COMMAND_SUFFIX = "noop"; +export const ROOT_ID = "root"; +export const SEPARATOR_ID = "separator"; 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/autofill.js b/apps/browser/src/autofill/content/autofill.js index 052fd1120fe..2f3857d3fa8 100644 --- a/apps/browser/src/autofill/content/autofill.js +++ b/apps/browser/src/autofill/content/autofill.js @@ -751,8 +751,8 @@ ].join('\n\n'); if ( - // At least one of the `savedURLs` uses SSL - savedURLs.some(url => url.startsWith('https://')) && + // At least one of the `savedURLs` uses SSL for the current page + savedURLs.some(url => url.startsWith(`https://${window.location.hostname}`)) && // The current page is not using SSL document.location.protocol === 'http:' && // There are password inputs on the page @@ -768,8 +768,16 @@ // Detect if within an iframe, and the iframe is sandboxed function isSandboxed() { - // self.origin is 'null' if inside a frame with sandboxed csp or iframe tag - return self.origin == null || self.origin === 'null'; + // self.origin is 'null' if inside a frame with sandboxed csp or iframe tag + if (String(self.origin).toLowerCase() === "null") { + return true; + } + + if (window.frameElement?.hasAttribute("sandbox")) { + return true; + } + + return location.hostname === ""; } function doFill(fillScript) { @@ -978,13 +986,18 @@ styleTimeout = 200; /** - * Fll an element `el` using the value `op` from the fill script + * Fill an element `el` using the value `op` from the fill script * @param {HTMLElement} el * @param {string} op */ function fillTheElement(el, op) { var shouldCheck; if (el && null !== op && void 0 !== op && !(el.disabled || el.a || el.readOnly)) { + const tabURLChanged = !fillScript.savedUrls?.some(url => url.startsWith(window.location.origin)) + // Check to make sure the page location didn't change + if (tabURLChanged) { + return; + } switch (markTheFilling && el.form && !el.form.opfilled && (el.form.opfilled = true), el.type ? el.type.toLowerCase() : null) { case 'checkbox': 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 8bf16ff879c..00000000000 --- a/apps/browser/src/autofill/content/autofillv2.ts +++ /dev/null @@ -1,1391 +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; + selectInfo?: any; /** * The `maxLength` attribute for the field */ - maxLength: number; - /** - * The `tagName` for the field - */ - tagName: string; - [key: string]: any; + maxLength?: number | null; + + rel?: string | null; + + checked?: boolean; } diff --git a/apps/browser/src/autofill/models/autofill-form.ts b/apps/browser/src/autofill/models/autofill-form.ts index e23539bd303..3f06e28a912 100644 --- a/apps/browser/src/autofill/models/autofill-form.ts +++ b/apps/browser/src/autofill/models/autofill-form.ts @@ -2,6 +2,7 @@ * Represents an HTML form whose elements can be autofilled */ export default class AutofillForm { + [key: string]: any; /** * The unique identifier assigned to this field during collection of the page details */ diff --git a/apps/browser/src/autofill/models/autofill-page-details.ts b/apps/browser/src/autofill/models/autofill-page-details.ts index c45359a05ae..89710e1597b 100644 --- a/apps/browser/src/autofill/models/autofill-page-details.ts +++ b/apps/browser/src/autofill/models/autofill-page-details.ts @@ -5,10 +5,6 @@ import AutofillForm from "./autofill-form"; * The details of a page that have been collected and can be used for autofill */ export default class AutofillPageDetails { - /** - * A unique identifier for the page - */ - documentUUID: string; title: string; url: string; documentUrl: string; diff --git a/apps/browser/src/autofill/models/autofill-script.ts b/apps/browser/src/autofill/models/autofill-script.ts index 13535c69052..7ab96eb2856 100644 --- a/apps/browser/src/autofill/models/autofill-script.ts +++ b/apps/browser/src/autofill/models/autofill-script.ts @@ -1,29 +1,24 @@ // String values affect code flow in autofill.ts and must not be changed -export type FillScriptOp = "click_on_opid" | "focus_by_opid" | "fill_by_opid" | "delay"; +export type FillScriptActions = "click_on_opid" | "focus_by_opid" | "fill_by_opid"; -export type FillScript = [op: FillScriptOp, opid: string, value?: string]; - -export type AutofillScriptOptions = { - animate?: boolean; - markFilling?: boolean; -}; +export type FillScript = [action: FillScriptActions, opid: string, value?: string]; export type AutofillScriptProperties = { delay_between_operations?: number; }; +export type AutofillInsertActions = { + fill_by_opid: ({ opid, value }: { opid: string; value: string }) => void; + click_on_opid: ({ opid }: { opid: string }) => void; + focus_by_opid: ({ opid }: { opid: string }) => void; +}; + export default class AutofillScript { script: FillScript[] = []; - documentUUID = ""; properties: AutofillScriptProperties = {}; - options: AutofillScriptOptions = {}; metadata: any = {}; // Unused, not written or read autosubmit: any = null; // Appears to be unused, read but not written savedUrls: string[]; untrustedIframe: boolean; itemType: string; // Appears to be unused, read but not written - - constructor(documentUUID: string) { - this.documentUUID = documentUUID; - } } diff --git a/apps/browser/src/autofill/notification/bar.html b/apps/browser/src/autofill/notification/bar.html index deec7fd512c..a6be58de70a 100644 --- a/apps/browser/src/autofill/notification/bar.html +++ b/apps/browser/src/autofill/notification/bar.html @@ -51,4 +51,13 @@
+ + diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index 59367dabee4..dcc4ce010f6 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -28,9 +28,20 @@ function load() { notificationEdit: chrome.i18n.getMessage("edit"), notificationChangeSave: chrome.i18n.getMessage("notificationChangeSave"), notificationChangeDesc: chrome.i18n.getMessage("notificationChangeDesc"), + notificationUnlock: chrome.i18n.getMessage("notificationUnlock"), + notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"), }; - document.getElementById("logo-link").title = i18n.appName; + const logoLink = document.getElementById("logo-link") as HTMLAnchorElement; + logoLink.title = i18n.appName; + + // Update logo link to user's regional domain + const webVaultURL = getQueryVariable("webVaultURL"); + const newVaultURL = webVaultURL && decodeURIComponent(webVaultURL); + + if (newVaultURL && newVaultURL !== logoLink.href) { + logoLink.href = newVaultURL; + } // i18n for "Add" template const addTemplate = document.getElementById("template-add") as HTMLTemplateElement; @@ -63,6 +74,13 @@ function load() { changeTemplate.content.getElementById("change-text").textContent = i18n.notificationChangeDesc; + const unlockTemplate = document.getElementById("template-unlock") as HTMLTemplateElement; + + const unlockButton = unlockTemplate.content.getElementById("unlock-vault"); + unlockButton.textContent = i18n.notificationUnlock; + + unlockTemplate.content.getElementById("unlock-text").textContent = i18n.notificationUnlockDesc; + // i18n for body content const closeButton = document.getElementById("close-button"); closeButton.title = i18n.close; @@ -71,6 +89,8 @@ function load() { handleTypeAdd(); } else if (getQueryVariable("type") === "change") { handleTypeChange(); + } else if (getQueryVariable("type") === "unlock") { + handleTypeUnlock(); } closeButton.addEventListener("click", (e) => { @@ -163,6 +183,17 @@ function handleTypeChange() { }); } +function handleTypeUnlock() { + setContent(document.getElementById("template-unlock") as HTMLTemplateElement); + + const unlockButton = document.getElementById("unlock-vault"); + unlockButton.addEventListener("click", (e) => { + sendPlatformMessage({ + command: "bgReopenPromptForLogin", + }); + }); +} + function setContent(template: HTMLTemplateElement) { const content = document.getElementById("content"); while (content.firstChild) { diff --git a/apps/browser/src/autofill/services/abstractions/autofill.service.ts b/apps/browser/src/autofill/services/abstractions/autofill.service.ts index b3ad26f0a0b..bbd0b21d226 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill.service.ts @@ -1,3 +1,5 @@ +import { UriMatchType } from "@bitwarden/common/enums"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import AutofillField from "../../models/autofill-field"; @@ -21,6 +23,7 @@ export interface AutoFillOptions { fillNewPassword?: boolean; skipLastUsed?: boolean; allowUntrustedIframe?: boolean; + allowTotpAutofill?: boolean; } export interface FormData { @@ -30,13 +33,32 @@ export interface FormData { passwords: AutofillField[]; } +export interface GenerateFillScriptOptions { + skipUsernameOnlyFill: boolean; + onlyEmptyFields: boolean; + onlyVisibleFields: boolean; + fillNewPassword: boolean; + allowTotpAutofill: boolean; + cipher: CipherView; + tabUrl: string; + defaultUriMatch: UriMatchType; +} + export abstract class AutofillService { + injectAutofillScripts: ( + sender: chrome.runtime.MessageSender, + autofillV2?: boolean + ) => Promise; getFormsWithPasswordFields: (pageDetails: AutofillPageDetails) => FormData[]; - doAutoFill: (options: AutoFillOptions) => Promise; + doAutoFill: (options: AutoFillOptions) => Promise; doAutoFillOnTab: ( pageDetails: PageDetail[], tab: chrome.tabs.Tab, fromCommand: boolean - ) => Promise; - doAutoFillActiveTab: (pageDetails: PageDetail[], fromCommand: boolean) => Promise; + ) => Promise; + doAutoFillActiveTab: ( + pageDetails: PageDetail[], + fromCommand: boolean, + cipherType?: CipherType + ) => Promise; } diff --git a/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts new file mode 100644 index 00000000000..e4a409eb599 --- /dev/null +++ b/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts @@ -0,0 +1,32 @@ +import AutofillField from "../../models/autofill-field"; +import AutofillForm from "../../models/autofill-form"; +import AutofillPageDetails from "../../models/autofill-page-details"; +import { ElementWithOpId, FormFieldElement } from "../../types"; + +type AutofillFormElements = Map, AutofillForm>; + +type AutofillFieldElements = Map, AutofillField>; + +type UpdateAutofillDataAttributeParams = { + element: ElementWithOpId; + attributeName: string; + dataTarget?: AutofillForm | AutofillField; + dataTargetKey?: string; +}; + +interface CollectAutofillContentService { + getPageDetails(): Promise; + getAutofillFieldElementByOpid(opid: string): HTMLElement | null; + queryAllTreeWalkerNodes( + rootNode: Node, + filterCallback: CallableFunction, + isObservingShadowRoot?: boolean + ): Node[]; +} + +export { + AutofillFormElements, + AutofillFieldElements, + UpdateAutofillDataAttributeParams, + CollectAutofillContentService, +}; diff --git a/apps/browser/src/autofill/services/abstractions/dom-element-visibility.service.ts b/apps/browser/src/autofill/services/abstractions/dom-element-visibility.service.ts new file mode 100644 index 00000000000..b7fd958bfd1 --- /dev/null +++ b/apps/browser/src/autofill/services/abstractions/dom-element-visibility.service.ts @@ -0,0 +1,6 @@ +interface DomElementVisibilityService { + isFormFieldViewable: (element: HTMLElement) => Promise; + isElementHiddenByCss: (element: HTMLElement) => boolean; +} + +export { DomElementVisibilityService }; diff --git a/apps/browser/src/autofill/services/abstractions/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/abstractions/insert-autofill-content.service.ts new file mode 100644 index 00000000000..9df6c479d0c --- /dev/null +++ b/apps/browser/src/autofill/services/abstractions/insert-autofill-content.service.ts @@ -0,0 +1,7 @@ +import AutofillScript from "../../models/autofill-script"; + +interface InsertAutofillContentService { + fillForm(fillScript: AutofillScript): void; +} + +export { InsertAutofillContentService }; diff --git a/apps/browser/src/autofill/services/autofill-constants.ts b/apps/browser/src/autofill/services/autofill-constants.ts index fcab50712c9..58c295c0c21 100644 --- a/apps/browser/src/autofill/services/autofill-constants.ts +++ b/apps/browser/src/autofill/services/autofill-constants.ts @@ -20,6 +20,17 @@ export class AutoFillConstants { "benutzer id", ]; + static readonly TotpFieldNames: string[] = [ + "totp", + "2fa", + "mfa", + "totpcode", + "2facode", + "mfacode", + "twofactor", + "twofactorcode", + ]; + static readonly PasswordFieldIgnoreList: string[] = [ "onetimepassword", "captcha", diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts new file mode 100644 index 00000000000..f8f12fa7ddb --- /dev/null +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -0,0 +1,4240 @@ +import { mock, mockReset } from "jest-mock-extended"; + +import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; +import { + EventType, + FieldType, + LinkedIdType, + LoginLinkedId, + UriMatchType, +} from "@bitwarden/common/enums"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; +import { SettingsService } from "@bitwarden/common/services/settings.service"; +import { TotpService } from "@bitwarden/common/services/totp.service"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { CardView } from "@bitwarden/common/vault/models/view/card.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; +import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; +import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; + +import { BrowserApi } from "../../platform/browser/browser-api"; +import { BrowserStateService } from "../../platform/services/browser-state.service"; +import { + createAutofillFieldMock, + createAutofillPageDetailsMock, + createAutofillScriptMock, + createChromeTabMock, + createGenerateFillScriptOptionsMock, +} from "../jest/autofill-mocks"; +import { triggerTestFailure } from "../jest/testing-utils"; +import AutofillField from "../models/autofill-field"; +import AutofillPageDetails from "../models/autofill-page-details"; +import AutofillScript from "../models/autofill-script"; + +import { + AutoFillOptions, + GenerateFillScriptOptions, + PageDetail, +} from "./abstractions/autofill.service"; +import { AutoFillConstants, IdentityAutoFillConstants } from "./autofill-constants"; +import AutofillService from "./autofill.service"; + +describe("AutofillService", () => { + let autofillService: AutofillService; + const cipherService = mock(); + const stateService = mock(); + const totpService = mock(); + const eventCollectionService = mock(); + const logService = mock(); + const settingsService = mock(); + const userVerificationService = mock(); + + beforeEach(() => { + autofillService = new AutofillService( + cipherService, + stateService, + totpService, + eventCollectionService, + logService, + settingsService, + userVerificationService + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockReset(cipherService); + }); + + describe("injectAutofillScripts", () => { + const autofillV1Script = "autofill.js"; + const autofillV2Script = "autofill-init.js"; + const defaultAutofillScripts = ["autofiller.js", "notificationBar.js", "contextMenuHandler.js"]; + const defaultExecuteScriptOptions = { runAt: "document_start" }; + let tabMock: chrome.tabs.Tab; + let sender: chrome.runtime.MessageSender; + + beforeEach(() => { + tabMock = createChromeTabMock(); + sender = { tab: tabMock }; + jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation(); + }); + + it("accepts an extension message sender and injects the autofill scripts into the tab of the sender", async () => { + await autofillService.injectAutofillScripts(sender); + + [autofillV1Script, ...defaultAutofillScripts].forEach((scriptName) => { + expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { + file: `content/${scriptName}`, + frameId: sender.frameId, + ...defaultExecuteScriptOptions, + }); + }); + expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, { + file: `content/${autofillV2Script}`, + frameId: sender.frameId, + ...defaultExecuteScriptOptions, + }); + }); + + it("will inject the autofill-init class if the enableAutofillV2 flag is set", () => { + autofillService.injectAutofillScripts(sender, true); + + expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, { + file: `content/${autofillV2Script}`, + ...defaultExecuteScriptOptions, + }); + expect(BrowserApi.executeScriptInTab).not.toHaveBeenCalledWith(tabMock.id, { + file: `content/${autofillV1Script}`, + ...defaultExecuteScriptOptions, + }); + }); + }); + + describe("getFormsWithPasswordFields", () => { + let pageDetailsMock: AutofillPageDetails; + + beforeEach(() => { + pageDetailsMock = createAutofillPageDetailsMock(); + }); + + it("returns an empty FormData array if no password fields are found", () => { + jest.spyOn(AutofillService, "loadPasswordFields"); + + const formData = autofillService.getFormsWithPasswordFields(pageDetailsMock); + + expect(AutofillService.loadPasswordFields).toHaveBeenCalledWith( + pageDetailsMock, + true, + true, + false, + true + ); + expect(formData).toStrictEqual([]); + }); + + it("returns an FormData array containing a form with it's autofill data", () => { + const usernameInputField = createAutofillFieldMock({ + opid: "username-field", + form: "validFormId", + elementNumber: 1, + }); + const passwordInputField = createAutofillFieldMock({ + opid: "password-field", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + pageDetailsMock.fields = [usernameInputField, passwordInputField]; + + const formData = autofillService.getFormsWithPasswordFields(pageDetailsMock); + + expect(formData).toStrictEqual([ + { + form: pageDetailsMock.forms.validFormId, + password: pageDetailsMock.fields[1], + passwords: [pageDetailsMock.fields[1]], + username: pageDetailsMock.fields[0], + }, + ]); + }); + + it("narrows down three passwords that are present on a page to a single password field to autofill when only one form element is present on the page", () => { + const usernameInputField = createAutofillFieldMock({ + opid: "username-field", + form: "validFormId", + elementNumber: 1, + }); + const passwordInputField = createAutofillFieldMock({ + opid: "password-field", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + const secondPasswordInputField = createAutofillFieldMock({ + opid: "another-password-field", + type: "password", + form: undefined, + elementNumber: 3, + }); + const thirdPasswordInputField = createAutofillFieldMock({ + opid: "a-third-password-field", + type: "password", + form: undefined, + elementNumber: 4, + }); + pageDetailsMock.fields = [ + usernameInputField, + passwordInputField, + secondPasswordInputField, + thirdPasswordInputField, + ]; + + const formData = autofillService.getFormsWithPasswordFields(pageDetailsMock); + + expect(formData).toStrictEqual([ + { + form: pageDetailsMock.forms.validFormId, + password: pageDetailsMock.fields[1], + passwords: [ + pageDetailsMock.fields[1], + { ...pageDetailsMock.fields[2], form: pageDetailsMock.fields[1].form }, + { ...pageDetailsMock.fields[3], form: pageDetailsMock.fields[1].form }, + ], + username: pageDetailsMock.fields[0], + }, + ]); + }); + + it("will check for a hidden username field", () => { + const usernameInputField = createAutofillFieldMock({ + opid: "username-field", + form: "validFormId", + elementNumber: 1, + isViewable: false, + readonly: true, + }); + const passwordInputField = createAutofillFieldMock({ + opid: "password-field", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + pageDetailsMock.fields = [usernameInputField, passwordInputField]; + jest.spyOn(autofillService as any, "findUsernameField"); + + const formData = autofillService.getFormsWithPasswordFields(pageDetailsMock); + + expect(autofillService["findUsernameField"]).toHaveBeenCalledWith( + pageDetailsMock, + passwordInputField, + true, + true, + false + ); + expect(formData).toStrictEqual([ + { + form: pageDetailsMock.forms.validFormId, + password: pageDetailsMock.fields[1], + passwords: [pageDetailsMock.fields[1]], + username: pageDetailsMock.fields[0], + }, + ]); + }); + }); + + describe("doAutoFill", () => { + let autofillOptions: AutoFillOptions; + const nothingToAutofillError = "Nothing to auto-fill."; + const didNotAutofillError = "Did not auto-fill."; + + beforeEach(() => { + autofillOptions = { + cipher: mock({ + id: "cipherId", + type: CipherType.Login, + }), + pageDetails: [ + { + frameId: 1, + tab: createChromeTabMock(), + details: createAutofillPageDetailsMock({ + fields: [ + createAutofillFieldMock({ + opid: "username-field", + form: "validFormId", + elementNumber: 1, + }), + createAutofillFieldMock({ + opid: "password-field", + type: "password", + form: "validFormId", + elementNumber: 2, + }), + ], + }), + }, + ], + tab: createChromeTabMock(), + }; + autofillOptions.cipher.fields = [mock({ name: "username" })]; + autofillOptions.cipher.login.matchesUri = jest.fn().mockReturnValue(true); + autofillOptions.cipher.login.username = "username"; + autofillOptions.cipher.login.password = "password"; + }); + + describe("given a set of autofill options that are incomplete", () => { + it("throws an error if the tab is not provided", async () => { + autofillOptions.tab = undefined; + + try { + await autofillService.doAutoFill(autofillOptions); + triggerTestFailure(); + } catch (error) { + expect(error.message).toBe(nothingToAutofillError); + } + }); + + it("throws an error if the cipher is not provided", async () => { + autofillOptions.cipher = undefined; + + try { + await autofillService.doAutoFill(autofillOptions); + triggerTestFailure(); + } catch (error) { + expect(error.message).toBe(nothingToAutofillError); + } + }); + + it("throws an error if the page details are not provided", async () => { + autofillOptions.pageDetails = undefined; + + try { + await autofillService.doAutoFill(autofillOptions); + triggerTestFailure(); + } catch (error) { + expect(error.message).toBe(nothingToAutofillError); + } + }); + + it("throws an error if the page details are empty", async () => { + autofillOptions.pageDetails = []; + + try { + await autofillService.doAutoFill(autofillOptions); + triggerTestFailure(); + } catch (error) { + expect(error.message).toBe(nothingToAutofillError); + } + }); + + it("throws an error if an autofill did not occur for any of the passed pages", async () => { + autofillOptions.tab.url = "https://a-different-url.com"; + + try { + await autofillService.doAutoFill(autofillOptions); + triggerTestFailure(); + } catch (error) { + expect(error.message).toBe(didNotAutofillError); + } + }); + }); + + it("will autofill login data for a page", async () => { + jest.spyOn(stateService, "getCanAccessPremium"); + jest.spyOn(stateService, "getDefaultUriMatch"); + jest.spyOn(autofillService as any, "generateFillScript"); + jest.spyOn(autofillService as any, "generateLoginFillScript"); + jest.spyOn(logService, "info"); + jest.spyOn(cipherService, "updateLastUsedDate"); + jest.spyOn(eventCollectionService, "collect"); + + const autofillResult = await autofillService.doAutoFill(autofillOptions); + + const currentAutofillPageDetails = autofillOptions.pageDetails[0]; + expect(stateService.getCanAccessPremium).toHaveBeenCalled(); + expect(stateService.getDefaultUriMatch).toHaveBeenCalled(); + expect(autofillService["generateFillScript"]).toHaveBeenCalledWith( + currentAutofillPageDetails.details, + { + skipUsernameOnlyFill: autofillOptions.skipUsernameOnlyFill || false, + onlyEmptyFields: autofillOptions.onlyEmptyFields || false, + onlyVisibleFields: autofillOptions.onlyVisibleFields || false, + fillNewPassword: autofillOptions.fillNewPassword || false, + allowTotpAutofill: autofillOptions.allowTotpAutofill || false, + cipher: autofillOptions.cipher, + tabUrl: autofillOptions.tab.url, + defaultUriMatch: 0, + } + ); + expect(autofillService["generateLoginFillScript"]).toHaveBeenCalled(); + expect(logService.info).not.toHaveBeenCalled(); + expect(cipherService.updateLastUsedDate).toHaveBeenCalledWith(autofillOptions.cipher.id); + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( + autofillOptions.pageDetails[0].tab.id, + { + command: "fillForm", + fillScript: { + autosubmit: null, + metadata: {}, + properties: { + delay_between_operations: 20, + }, + savedUrls: [], + script: [ + ["click_on_opid", "username-field"], + ["focus_by_opid", "username-field"], + ["fill_by_opid", "username-field", "username"], + ["click_on_opid", "password-field"], + ["focus_by_opid", "password-field"], + ["fill_by_opid", "password-field", "password"], + ["focus_by_opid", "password-field"], + ], + untrustedIframe: false, + }, + url: currentAutofillPageDetails.tab.url, + }, + { + frameId: currentAutofillPageDetails.frameId, + }, + expect.any(Function) + ); + expect(eventCollectionService.collect).toHaveBeenCalledWith( + EventType.Cipher_ClientAutofilled, + autofillOptions.cipher.id + ); + expect(autofillResult).toBeNull(); + }); + + it("will autofill card data for a page", async () => { + autofillOptions.cipher.type = CipherType.Card; + autofillOptions.cipher.card = mock({ + cardholderName: "cardholderName", + }); + autofillOptions.pageDetails[0].details.fields = [ + createAutofillFieldMock({ + opid: "cardholderName", + form: "validFormId", + elementNumber: 2, + autoCompleteType: "cc-name", + }), + ]; + jest.spyOn(autofillService as any, "generateCardFillScript"); + jest.spyOn(eventCollectionService, "collect"); + + await autofillService.doAutoFill(autofillOptions); + + expect(autofillService["generateCardFillScript"]).toHaveBeenCalled(); + expect(chrome.tabs.sendMessage).toHaveBeenCalled(); + expect(eventCollectionService.collect).toHaveBeenCalledWith( + EventType.Cipher_ClientAutofilled, + autofillOptions.cipher.id + ); + }); + + it("will autofill identity data for a page", async () => { + autofillOptions.cipher.type = CipherType.Identity; + autofillOptions.cipher.identity = mock({ + firstName: "firstName", + middleName: "middleName", + lastName: "lastName", + }); + autofillOptions.pageDetails[0].details.fields = [ + createAutofillFieldMock({ + opid: "full-name", + form: "validFormId", + elementNumber: 2, + autoCompleteType: "full-name", + }), + ]; + jest.spyOn(autofillService as any, "generateIdentityFillScript"); + jest.spyOn(eventCollectionService, "collect"); + + await autofillService.doAutoFill(autofillOptions); + + expect(autofillService["generateIdentityFillScript"]).toHaveBeenCalled(); + expect(chrome.tabs.sendMessage).toHaveBeenCalled(); + expect(eventCollectionService.collect).toHaveBeenCalledWith( + EventType.Cipher_ClientAutofilled, + autofillOptions.cipher.id + ); + }); + + it("blocks autofill on an untrusted iframe", async () => { + autofillOptions.allowUntrustedIframe = false; + autofillOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(false); + jest.spyOn(logService, "info"); + + try { + await autofillService.doAutoFill(autofillOptions); + triggerTestFailure(); + } catch (error) { + expect(logService.info).toHaveBeenCalledWith( + "Auto-fill on page load was blocked due to an untrusted iframe." + ); + expect(error.message).toBe(didNotAutofillError); + } + }); + + it("allows autofill on an untrusted iframe if the passed option allowing untrusted iframes is set to true", async () => { + autofillOptions.allowUntrustedIframe = true; + autofillOptions.cipher.login.matchesUri = jest.fn().mockReturnValue(false); + jest.spyOn(logService, "info"); + + await autofillService.doAutoFill(autofillOptions); + + expect(logService.info).not.toHaveBeenCalledWith( + "Auto-fill on page load was blocked due to an untrusted iframe." + ); + }); + + it("skips updating the cipher's last used date if the passed options indicate that we should skip the last used cipher", async () => { + autofillOptions.skipLastUsed = true; + jest.spyOn(cipherService, "updateLastUsedDate"); + + await autofillService.doAutoFill(autofillOptions); + + expect(cipherService.updateLastUsedDate).not.toHaveBeenCalled(); + }); + + it("returns early if the fillScript cannot be generated", async () => { + jest.spyOn(autofillService as any, "generateFillScript").mockReturnValueOnce(undefined); + jest.spyOn(BrowserApi, "tabSendMessage"); + + try { + await autofillService.doAutoFill(autofillOptions); + triggerTestFailure(); + } catch (error) { + expect(autofillService["generateFillScript"]).toHaveBeenCalled(); + expect(BrowserApi.tabSendMessage).not.toHaveBeenCalled(); + expect(error.message).toBe(didNotAutofillError); + } + }); + + it("returns a TOTP value", async () => { + const totpCode = "123456"; + autofillOptions.cipher.login.totp = "totp"; + jest.spyOn(stateService, "getDisableAutoTotpCopy").mockResolvedValueOnce(false); + jest.spyOn(totpService, "getCode").mockReturnValueOnce(Promise.resolve(totpCode)); + + const autofillResult = await autofillService.doAutoFill(autofillOptions); + + expect(stateService.getDisableAutoTotpCopy).toHaveBeenCalled(); + expect(totpService.getCode).toHaveBeenCalledWith(autofillOptions.cipher.login.totp); + expect(autofillResult).toBe(totpCode); + }); + + it("returns a null value if the cipher type is not for a Login", async () => { + autofillOptions.cipher.type = CipherType.Identity; + autofillOptions.cipher.identity = mock(); + + const autofillResult = await autofillService.doAutoFill(autofillOptions); + + expect(autofillResult).toBeNull(); + }); + + it("returns a null value if the login does not contain a TOTP value", async () => { + autofillOptions.cipher.login.totp = undefined; + jest.spyOn(stateService, "getDisableAutoTotpCopy"); + jest.spyOn(totpService, "getCode"); + + const autofillResult = await autofillService.doAutoFill(autofillOptions); + + expect(stateService.getDisableAutoTotpCopy).not.toHaveBeenCalled(); + expect(totpService.getCode).not.toHaveBeenCalled(); + expect(autofillResult).toBeNull(); + }); + + it("returns a null value if the user cannot access premium and the organization does not use TOTP", async () => { + autofillOptions.cipher.login.totp = "totp"; + autofillOptions.cipher.organizationUseTotp = false; + jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValueOnce(false); + + const autofillResult = await autofillService.doAutoFill(autofillOptions); + + expect(autofillResult).toBeNull(); + }); + + it("returns a null value if the user has disabled `auto TOTP copy`", async () => { + autofillOptions.cipher.login.totp = "totp"; + autofillOptions.cipher.organizationUseTotp = true; + jest.spyOn(stateService, "getCanAccessPremium").mockResolvedValueOnce(true); + jest.spyOn(stateService, "getDisableAutoTotpCopy").mockResolvedValueOnce(true); + + const autofillResult = await autofillService.doAutoFill(autofillOptions); + + expect(autofillResult).toBeNull(); + }); + }); + + describe("doAutoFillOnTab", () => { + let pageDetails: PageDetail[]; + let tab: chrome.tabs.Tab; + + beforeEach(() => { + tab = createChromeTabMock(); + pageDetails = [ + { + frameId: 1, + tab: createChromeTabMock(), + details: createAutofillPageDetailsMock({ + fields: [ + createAutofillFieldMock({ + opid: "username-field", + form: "validFormId", + elementNumber: 1, + }), + createAutofillFieldMock({ + opid: "password-field", + type: "password", + form: "validFormId", + elementNumber: 2, + }), + ], + }), + }, + ]; + }); + + describe("given a tab url which does not match a cipher", () => { + it("will skip autofill and return a null value when triggering on page load", async () => { + jest.spyOn(autofillService, "doAutoFill"); + jest.spyOn(cipherService, "getNextCipherForUrl"); + jest.spyOn(cipherService, "getLastLaunchedForUrl").mockResolvedValueOnce(null); + jest.spyOn(cipherService, "getLastUsedForUrl").mockResolvedValueOnce(null); + + const result = await autofillService.doAutoFillOnTab(pageDetails, tab, false); + + expect(cipherService.getNextCipherForUrl).not.toHaveBeenCalled(); + expect(cipherService.getLastLaunchedForUrl).toHaveBeenCalledWith(tab.url, true); + expect(cipherService.getLastUsedForUrl).toHaveBeenCalledWith(tab.url, true); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it("will skip autofill and return a null value when triggering from a keyboard shortcut", async () => { + jest.spyOn(autofillService, "doAutoFill"); + jest.spyOn(cipherService, "getNextCipherForUrl").mockResolvedValueOnce(null); + jest.spyOn(cipherService, "getLastLaunchedForUrl").mockResolvedValueOnce(null); + jest.spyOn(cipherService, "getLastUsedForUrl").mockResolvedValueOnce(null); + + const result = await autofillService.doAutoFillOnTab(pageDetails, tab, true); + + expect(cipherService.getNextCipherForUrl).toHaveBeenCalledWith(tab.url); + expect(cipherService.getLastLaunchedForUrl).not.toHaveBeenCalled(); + expect(cipherService.getLastUsedForUrl).not.toHaveBeenCalled(); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); + + describe("given a tab url which matches a cipher", () => { + let cipher: CipherView; + + beforeEach(() => { + cipher = mock({ + reprompt: CipherRepromptType.None, + localData: { + lastLaunched: Date.now().valueOf(), + }, + }); + }); + + it("will autofill the last launched cipher and return a TOTP value when triggering on page load", async () => { + const totpCode = "123456"; + const fromCommand = false; + jest.spyOn(autofillService, "doAutoFill").mockResolvedValueOnce(totpCode); + jest.spyOn(cipherService, "getLastLaunchedForUrl").mockResolvedValueOnce(cipher); + jest.spyOn(cipherService, "getLastUsedForUrl"); + jest.spyOn(cipherService, "updateLastUsedIndexForUrl"); + + const result = await autofillService.doAutoFillOnTab(pageDetails, tab, fromCommand); + + expect(cipherService.getLastLaunchedForUrl).toHaveBeenCalledWith(tab.url, true); + expect(cipherService.getLastUsedForUrl).not.toHaveBeenCalled(); + expect(cipherService.updateLastUsedIndexForUrl).not.toHaveBeenCalled(); + expect(autofillService.doAutoFill).toHaveBeenCalledWith({ + tab: tab, + cipher: cipher, + pageDetails: pageDetails, + skipLastUsed: !fromCommand, + skipUsernameOnlyFill: !fromCommand, + onlyEmptyFields: !fromCommand, + onlyVisibleFields: !fromCommand, + fillNewPassword: fromCommand, + allowUntrustedIframe: fromCommand, + allowTotpAutofill: fromCommand, + }); + expect(result).toBe(totpCode); + }); + + it("will autofill the last used cipher and return a TOTP value when triggering on page load ", async () => { + cipher.localData.lastLaunched = Date.now().valueOf() - 30001; + const totpCode = "123456"; + const fromCommand = false; + jest.spyOn(autofillService, "doAutoFill").mockResolvedValueOnce(totpCode); + jest.spyOn(cipherService, "getLastLaunchedForUrl").mockResolvedValueOnce(cipher); + jest.spyOn(cipherService, "getLastUsedForUrl").mockResolvedValueOnce(cipher); + jest.spyOn(cipherService, "updateLastUsedIndexForUrl"); + + const result = await autofillService.doAutoFillOnTab(pageDetails, tab, fromCommand); + + expect(cipherService.getLastLaunchedForUrl).toHaveBeenCalledWith(tab.url, true); + expect(cipherService.getLastUsedForUrl).toHaveBeenCalledWith(tab.url, true); + expect(cipherService.updateLastUsedIndexForUrl).not.toHaveBeenCalled(); + expect(autofillService.doAutoFill).toHaveBeenCalledWith({ + tab: tab, + cipher: cipher, + pageDetails: pageDetails, + skipLastUsed: !fromCommand, + skipUsernameOnlyFill: !fromCommand, + onlyEmptyFields: !fromCommand, + onlyVisibleFields: !fromCommand, + fillNewPassword: fromCommand, + allowUntrustedIframe: fromCommand, + allowTotpAutofill: fromCommand, + }); + expect(result).toBe(totpCode); + }); + + it("will autofill the next cipher, update the last used cipher index, and return a TOTP value when triggering from a keyboard shortcut", async () => { + const totpCode = "123456"; + const fromCommand = true; + jest.spyOn(autofillService, "doAutoFill").mockResolvedValueOnce(totpCode); + jest.spyOn(cipherService, "getNextCipherForUrl").mockResolvedValueOnce(cipher); + jest.spyOn(cipherService, "updateLastUsedIndexForUrl"); + + const result = await autofillService.doAutoFillOnTab(pageDetails, tab, fromCommand); + + expect(cipherService.getNextCipherForUrl).toHaveBeenCalledWith(tab.url); + expect(cipherService.updateLastUsedIndexForUrl).toHaveBeenCalledWith(tab.url); + expect(autofillService.doAutoFill).toHaveBeenCalledWith({ + tab: tab, + cipher: cipher, + pageDetails: pageDetails, + skipLastUsed: !fromCommand, + skipUsernameOnlyFill: !fromCommand, + onlyEmptyFields: !fromCommand, + onlyVisibleFields: !fromCommand, + fillNewPassword: fromCommand, + allowUntrustedIframe: fromCommand, + allowTotpAutofill: fromCommand, + }); + expect(result).toBe(totpCode); + }); + + it("will skip autofill, launch the password reprompt window, and return a null value if the cipher re-prompt type is not `None`", async () => { + cipher.reprompt = CipherRepromptType.Password; + jest.spyOn(autofillService, "doAutoFill"); + jest.spyOn(cipherService, "getNextCipherForUrl").mockResolvedValueOnce(cipher); + jest + .spyOn(userVerificationService, "hasMasterPasswordAndMasterKeyHash") + .mockResolvedValueOnce(true); + jest.spyOn(BrowserApi, "tabSendMessageData").mockImplementation(); + + const result = await autofillService.doAutoFillOnTab(pageDetails, tab, true); + + expect(cipherService.getNextCipherForUrl).toHaveBeenCalledWith(tab.url); + expect(userVerificationService.hasMasterPasswordAndMasterKeyHash).toHaveBeenCalled(); + expect(BrowserApi.tabSendMessageData).toHaveBeenCalledWith(tab, "passwordReprompt", { + cipherId: cipher.id, + action: "autofill", + }); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + }); + }); + + describe("doAutoFillActiveTab", () => { + let pageDetails: PageDetail[]; + let tab: chrome.tabs.Tab; + + beforeEach(() => { + tab = createChromeTabMock(); + pageDetails = [ + { + frameId: 1, + tab: createChromeTabMock(), + details: createAutofillPageDetailsMock({ + fields: [ + createAutofillFieldMock({ + opid: "username-field", + form: "validFormId", + elementNumber: 1, + }), + createAutofillFieldMock({ + opid: "password-field", + type: "password", + form: "validFormId", + elementNumber: 2, + }), + ], + }), + }, + ]; + }); + + it("returns a null vault without doing autofill if the page details does not contain fields ", async () => { + pageDetails[0].details.fields = []; + jest.spyOn(autofillService as any, "getActiveTab"); + jest.spyOn(autofillService, "doAutoFill"); + + const result = await autofillService.doAutoFillActiveTab(pageDetails, false); + + expect(autofillService["getActiveTab"]).not.toHaveBeenCalled(); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it("returns a null value without doing autofill if the active tab cannot be found", async () => { + jest.spyOn(autofillService as any, "getActiveTab").mockResolvedValueOnce(undefined); + jest.spyOn(autofillService, "doAutoFill"); + + const result = await autofillService.doAutoFillActiveTab(pageDetails, false); + + expect(autofillService["getActiveTab"]).toHaveBeenCalled(); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it("returns a null value without doing autofill if the active tab url cannot be found", async () => { + jest.spyOn(autofillService as any, "getActiveTab").mockResolvedValueOnce({ + id: 1, + url: undefined, + }); + jest.spyOn(autofillService, "doAutoFill"); + + const result = await autofillService.doAutoFillActiveTab(pageDetails, false); + + expect(autofillService["getActiveTab"]).toHaveBeenCalled(); + expect(autofillService.doAutoFill).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it("queries the active tab and enacts an autofill on that tab", async () => { + const totp = "123456"; + const fromCommand = false; + jest.spyOn(autofillService as any, "getActiveTab").mockResolvedValueOnce(tab); + jest.spyOn(autofillService, "doAutoFillOnTab").mockResolvedValueOnce(totp); + + const result = await autofillService.doAutoFillActiveTab(pageDetails, fromCommand); + + expect(autofillService["getActiveTab"]).toHaveBeenCalled(); + expect(autofillService.doAutoFillOnTab).toHaveBeenCalledWith(pageDetails, tab, fromCommand); + expect(result).toBe(totp); + }); + }); + + describe("getActiveTab", () => { + it("throws are error if a tab cannot be found", async () => { + jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValueOnce(undefined); + + try { + await autofillService["getActiveTab"](); + triggerTestFailure(); + } catch (error) { + expect(BrowserApi.getTabFromCurrentWindow).toHaveBeenCalled(); + expect(error.message).toBe("No tab found."); + } + }); + + it("returns the active tab from the current window", async () => { + const tab = createChromeTabMock(); + jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValueOnce(tab); + + const result = await autofillService["getActiveTab"](); + expect(BrowserApi.getTabFromCurrentWindow).toHaveBeenCalled(); + expect(result).toBe(tab); + }); + }); + + describe("generateFillScript", () => { + let defaultUsernameField: AutofillField; + let defaultUsernameFieldView: FieldView; + let defaultPasswordField: AutofillField; + let defaultPasswordFieldView: FieldView; + let pageDetail: AutofillPageDetails; + let generateFillScriptOptions: GenerateFillScriptOptions; + + beforeEach(() => { + defaultUsernameField = createAutofillFieldMock({ + opid: "username-field", + form: "validFormId", + htmlID: "username", + elementNumber: 1, + }); + defaultUsernameFieldView = mock({ + name: "username", + value: defaultUsernameField.value, + }); + defaultPasswordField = createAutofillFieldMock({ + opid: "password-field", + type: "password", + form: "validFormId", + htmlID: "password", + elementNumber: 2, + }); + defaultPasswordFieldView = mock({ + name: "password", + value: defaultPasswordField.value, + }); + pageDetail = createAutofillPageDetailsMock({ + fields: [defaultUsernameField, defaultPasswordField], + }); + generateFillScriptOptions = createGenerateFillScriptOptionsMock(); + generateFillScriptOptions.cipher.fields = [ + defaultUsernameFieldView, + defaultPasswordFieldView, + ]; + }); + + it("returns null if the page details are not provided", async () => { + const value = await autofillService["generateFillScript"]( + undefined, + generateFillScriptOptions + ); + + expect(value).toBeNull(); + }); + + it("returns null if the passed options do not contain a valid cipher", async () => { + generateFillScriptOptions.cipher = undefined; + + const value = await autofillService["generateFillScript"]( + pageDetail, + generateFillScriptOptions + ); + + expect(value).toBeNull(); + }); + + describe("given a valid set of cipher fields and page detail fields", () => { + it("will not attempt to fill by opid duplicate fields found within the page details", async () => { + const duplicateUsernameField: AutofillField = createAutofillFieldMock({ + opid: "username-field", + form: "validFormId", + htmlID: "username", + elementNumber: 3, + }); + pageDetail.fields.push(duplicateUsernameField); + jest.spyOn(generateFillScriptOptions.cipher, "linkedFieldValue"); + jest.spyOn(autofillService as any, "findMatchingFieldIndex"); + jest.spyOn(AutofillService, "fillByOpid"); + + await autofillService["generateFillScript"](pageDetail, generateFillScriptOptions); + + expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith( + expect.anything(), + duplicateUsernameField, + duplicateUsernameField.value + ); + }); + + it("will not attempt to fill by opid fields that are not viewable and are not a `span` element", async () => { + defaultUsernameField.viewable = false; + jest.spyOn(AutofillService, "fillByOpid"); + + await autofillService["generateFillScript"](pageDetail, generateFillScriptOptions); + + expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith( + expect.anything(), + defaultUsernameField, + defaultUsernameField.value + ); + }); + + it("will fill by opid fields that are not viewable but are a `span` element", async () => { + defaultUsernameField.viewable = false; + defaultUsernameField.tagName = "span"; + jest.spyOn(AutofillService, "fillByOpid"); + + await autofillService["generateFillScript"](pageDetail, generateFillScriptOptions); + + expect(AutofillService.fillByOpid).toHaveBeenNthCalledWith( + 1, + expect.anything(), + defaultUsernameField, + defaultUsernameField.value + ); + }); + + it("will not attempt to fill by opid fields that do not contain a property that matches the field name", async () => { + defaultUsernameField.htmlID = "does-not-match-username"; + jest.spyOn(AutofillService, "fillByOpid"); + + await autofillService["generateFillScript"](pageDetail, generateFillScriptOptions); + + expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith( + expect.anything(), + defaultUsernameField, + defaultUsernameField.value + ); + }); + + it("will fill by opid fields that contain a property that matches the field name", async () => { + jest.spyOn(generateFillScriptOptions.cipher, "linkedFieldValue"); + jest.spyOn(autofillService as any, "findMatchingFieldIndex"); + jest.spyOn(AutofillService, "fillByOpid"); + + await autofillService["generateFillScript"](pageDetail, generateFillScriptOptions); + + expect(autofillService["findMatchingFieldIndex"]).toHaveBeenCalledTimes(2); + expect(generateFillScriptOptions.cipher.linkedFieldValue).not.toHaveBeenCalled(); + expect(AutofillService.fillByOpid).toHaveBeenNthCalledWith( + 1, + expect.anything(), + defaultUsernameField, + defaultUsernameField.value + ); + expect(AutofillService.fillByOpid).toHaveBeenNthCalledWith( + 2, + expect.anything(), + defaultPasswordField, + defaultPasswordField.value + ); + }); + + it("it will fill by opid fields of type Linked", async () => { + const fieldLinkedId: LinkedIdType = LoginLinkedId.Username; + const linkedFieldValue = "linkedFieldValue"; + defaultUsernameFieldView.type = FieldType.Linked; + defaultUsernameFieldView.linkedId = fieldLinkedId; + jest + .spyOn(generateFillScriptOptions.cipher, "linkedFieldValue") + .mockReturnValueOnce(linkedFieldValue); + jest.spyOn(AutofillService, "fillByOpid"); + + await autofillService["generateFillScript"](pageDetail, generateFillScriptOptions); + + expect(generateFillScriptOptions.cipher.linkedFieldValue).toHaveBeenCalledTimes(1); + expect(generateFillScriptOptions.cipher.linkedFieldValue).toHaveBeenCalledWith( + fieldLinkedId + ); + expect(AutofillService.fillByOpid).toHaveBeenNthCalledWith( + 1, + expect.anything(), + defaultUsernameField, + linkedFieldValue + ); + expect(AutofillService.fillByOpid).toHaveBeenNthCalledWith( + 2, + expect.anything(), + defaultPasswordField, + defaultPasswordField.value + ); + }); + + it("will fill by opid fields of type Boolean", async () => { + defaultUsernameFieldView.type = FieldType.Boolean; + defaultUsernameFieldView.value = "true"; + jest.spyOn(generateFillScriptOptions.cipher, "linkedFieldValue"); + jest.spyOn(AutofillService, "fillByOpid"); + + await autofillService["generateFillScript"](pageDetail, generateFillScriptOptions); + + expect(generateFillScriptOptions.cipher.linkedFieldValue).not.toHaveBeenCalled(); + expect(AutofillService.fillByOpid).toHaveBeenNthCalledWith( + 1, + expect.anything(), + defaultUsernameField, + defaultUsernameFieldView.value + ); + }); + + it("will fill by opid fields of type Boolean with a value of false if no value is provided", async () => { + defaultUsernameFieldView.type = FieldType.Boolean; + defaultUsernameFieldView.value = undefined; + jest.spyOn(AutofillService, "fillByOpid"); + + await autofillService["generateFillScript"](pageDetail, generateFillScriptOptions); + + expect(AutofillService.fillByOpid).toHaveBeenNthCalledWith( + 1, + expect.anything(), + defaultUsernameField, + "false" + ); + }); + }); + + it("returns a fill script generated for a login autofill", async () => { + const fillScriptMock = createAutofillScriptMock( + {}, + { "username-field": "username-value", "password-value": "password-value" } + ); + generateFillScriptOptions.cipher.type = CipherType.Login; + jest + .spyOn(autofillService as any, "generateLoginFillScript") + .mockReturnValueOnce(fillScriptMock); + + const value = await autofillService["generateFillScript"]( + pageDetail, + generateFillScriptOptions + ); + + expect(autofillService["generateLoginFillScript"]).toHaveBeenCalledWith( + { + autosubmit: null, + metadata: {}, + properties: {}, + script: [ + ["click_on_opid", "username-field"], + ["focus_by_opid", "username-field"], + ["fill_by_opid", "username-field", "default-value"], + ["click_on_opid", "password-field"], + ["focus_by_opid", "password-field"], + ["fill_by_opid", "password-field", "default-value"], + ], + }, + pageDetail, + { + "password-field": defaultPasswordField, + "username-field": defaultUsernameField, + }, + generateFillScriptOptions + ); + expect(value).toBe(fillScriptMock); + }); + + it("returns a fill script generated for a card autofill", async () => { + const fillScriptMock = createAutofillScriptMock( + {}, + { "first-name-field": "first-name-value", "last-name-value": "last-name-value" } + ); + generateFillScriptOptions.cipher.type = CipherType.Card; + jest + .spyOn(autofillService as any, "generateCardFillScript") + .mockReturnValueOnce(fillScriptMock); + + const value = await autofillService["generateFillScript"]( + pageDetail, + generateFillScriptOptions + ); + + expect(autofillService["generateCardFillScript"]).toHaveBeenCalledWith( + { + autosubmit: null, + metadata: {}, + properties: {}, + script: [ + ["click_on_opid", "username-field"], + ["focus_by_opid", "username-field"], + ["fill_by_opid", "username-field", "default-value"], + ["click_on_opid", "password-field"], + ["focus_by_opid", "password-field"], + ["fill_by_opid", "password-field", "default-value"], + ], + }, + pageDetail, + { + "password-field": defaultPasswordField, + "username-field": defaultUsernameField, + }, + generateFillScriptOptions + ); + expect(value).toBe(fillScriptMock); + }); + + it("returns a fill script generated for an identity autofill", async () => { + const fillScriptMock = createAutofillScriptMock( + {}, + { "first-name-field": "first-name-value", "last-name-value": "last-name-value" } + ); + generateFillScriptOptions.cipher.type = CipherType.Identity; + jest + .spyOn(autofillService as any, "generateIdentityFillScript") + .mockReturnValueOnce(fillScriptMock); + + const value = await autofillService["generateFillScript"]( + pageDetail, + generateFillScriptOptions + ); + + expect(autofillService["generateIdentityFillScript"]).toHaveBeenCalledWith( + { + autosubmit: null, + metadata: {}, + properties: {}, + script: [ + ["click_on_opid", "username-field"], + ["focus_by_opid", "username-field"], + ["fill_by_opid", "username-field", "default-value"], + ["click_on_opid", "password-field"], + ["focus_by_opid", "password-field"], + ["fill_by_opid", "password-field", "default-value"], + ], + }, + pageDetail, + { + "password-field": defaultPasswordField, + "username-field": defaultUsernameField, + }, + generateFillScriptOptions + ); + expect(value).toBe(fillScriptMock); + }); + + it("returns null if the cipher type is not for a login, card, or identity", async () => { + generateFillScriptOptions.cipher.type = CipherType.SecureNote; + + const value = await autofillService["generateFillScript"]( + pageDetail, + generateFillScriptOptions + ); + + expect(value).toBeNull(); + }); + }); + + describe("generateLoginFillScript", () => { + let fillScript: AutofillScript; + let pageDetails: AutofillPageDetails; + let filledFields: { [id: string]: AutofillField }; + let options: GenerateFillScriptOptions; + let defaultLoginUriView: LoginUriView; + + beforeEach(() => { + fillScript = createAutofillScriptMock(); + pageDetails = createAutofillPageDetailsMock(); + filledFields = { + "username-field": createAutofillFieldMock({ + opid: "username-field", + form: "validFormId", + elementNumber: 1, + }), + "password-field": createAutofillFieldMock({ + opid: "password-field", + form: "validFormId", + elementNumber: 2, + }), + "totp-field": createAutofillFieldMock({ + opid: "totp-field", + form: "validFormId", + elementNumber: 3, + }), + }; + defaultLoginUriView = mock({ + uri: "https://www.example.com", + match: UriMatchType.Domain, + }); + options = createGenerateFillScriptOptionsMock(); + options.cipher.login = mock({ + uris: [defaultLoginUriView], + }); + options.cipher.login.matchesUri = jest.fn().mockReturnValue(true); + }); + + it("returns null if the cipher does not have login data", async () => { + options.cipher.login = undefined; + jest.spyOn(autofillService as any, "inUntrustedIframe"); + jest.spyOn(AutofillService, "loadPasswordFields"); + jest.spyOn(autofillService as any, "findUsernameField"); + jest.spyOn(AutofillService, "fieldIsFuzzyMatch"); + jest.spyOn(AutofillService, "fillByOpid"); + jest.spyOn(AutofillService, "setFillScriptForFocus"); + + const value = await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["inUntrustedIframe"]).not.toHaveBeenCalled(); + expect(AutofillService.loadPasswordFields).not.toHaveBeenCalled(); + expect(autofillService["findUsernameField"]).not.toHaveBeenCalled(); + expect(AutofillService.fieldIsFuzzyMatch).not.toHaveBeenCalled(); + expect(AutofillService.fillByOpid).not.toHaveBeenCalled(); + expect(AutofillService.setFillScriptForFocus).not.toHaveBeenCalled(); + expect(value).toBeNull(); + }); + + describe("given a list of login uri views", () => { + it("returns an empty array of saved login uri views if the login cipher has no login uri views", async () => { + options.cipher.login.uris = []; + + const value = await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.savedUrls).toStrictEqual([]); + }); + + it("returns a list of saved login uri views within the fill script", async () => { + const secondUriView = mock({ + uri: "https://www.second-example.com", + }); + const thirdUriView = mock({ + uri: "https://www.third-example.com", + }); + options.cipher.login.uris = [defaultLoginUriView, secondUriView, thirdUriView]; + + const value = await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.savedUrls).toStrictEqual([ + defaultLoginUriView.uri, + secondUriView.uri, + thirdUriView.uri, + ]); + }); + + it("skips adding any login uri views that have a UriMatchType of Never to the list of saved urls", async () => { + const secondUriView = mock({ + uri: "https://www.second-example.com", + }); + const thirdUriView = mock({ + uri: "https://www.third-example.com", + match: UriMatchType.Never, + }); + options.cipher.login.uris = [defaultLoginUriView, secondUriView, thirdUriView]; + + const value = await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.savedUrls).toStrictEqual([defaultLoginUriView.uri, secondUriView.uri]); + expect(value.savedUrls).not.toContain(thirdUriView.uri); + }); + }); + + describe("given a valid set of page details and autofill options", () => { + let usernameField: AutofillField; + let usernameFieldView: FieldView; + let passwordField: AutofillField; + let passwordFieldView: FieldView; + let totpField: AutofillField; + let totpFieldView: FieldView; + + beforeEach(() => { + usernameField = createAutofillFieldMock({ + opid: "username", + form: "validFormId", + elementNumber: 1, + }); + usernameFieldView = mock({ + name: "username", + }); + passwordField = createAutofillFieldMock({ + opid: "password", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + passwordFieldView = mock({ + name: "password", + }); + totpField = createAutofillFieldMock({ + opid: "totp", + type: "text", + form: "validFormId", + htmlName: "totpcode", + elementNumber: 3, + }); + totpFieldView = mock({ + name: "totp", + }); + pageDetails.fields = [usernameField, passwordField, totpField]; + options.cipher.fields = [usernameFieldView, passwordFieldView, totpFieldView]; + options.cipher.login.matchesUri = jest.fn().mockReturnValue(true); + options.cipher.login.username = "username"; + options.cipher.login.password = "password"; + options.cipher.login.totp = "totp"; + }); + + it("attempts to load the password fields from hidden and read only elements if no visible password fields are found within the page details", async () => { + pageDetails.fields = [ + createAutofillFieldMock({ + opid: "password-field", + type: "password", + viewable: true, + readonly: true, + }), + ]; + jest.spyOn(AutofillService, "loadPasswordFields"); + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.loadPasswordFields).toHaveBeenCalledTimes(2); + expect(AutofillService.loadPasswordFields).toHaveBeenNthCalledWith( + 1, + pageDetails, + false, + false, + options.onlyEmptyFields, + options.fillNewPassword + ); + expect(AutofillService.loadPasswordFields).toHaveBeenNthCalledWith( + 2, + pageDetails, + true, + true, + options.onlyEmptyFields, + options.fillNewPassword + ); + }); + + describe("given a valid list of forms within the passed page details", () => { + beforeEach(() => { + usernameField.viewable = false; + usernameField.readonly = true; + totpField.viewable = false; + totpField.readonly = true; + jest.spyOn(autofillService as any, "findUsernameField"); + jest.spyOn(autofillService as any, "findTotpField"); + }); + + it("will attempt to find a username field from hidden fields if no visible username fields are found", async () => { + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["findUsernameField"]).toHaveBeenCalledTimes(2); + expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith( + 1, + pageDetails, + passwordField, + false, + false, + false + ); + expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith( + 2, + pageDetails, + passwordField, + true, + true, + false + ); + }); + + it("will not attempt to find a username field from hidden fields if the passed options indicate only visible fields should be referenced", async () => { + options.onlyVisibleFields = true; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["findUsernameField"]).toHaveBeenCalledTimes(1); + expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith( + 1, + pageDetails, + passwordField, + false, + false, + false + ); + expect(autofillService["findUsernameField"]).not.toHaveBeenNthCalledWith( + 2, + pageDetails, + passwordField, + true, + true, + false + ); + }); + + it("will attempt to find a totp field from hidden fields if no visible totp fields are found", async () => { + options.allowTotpAutofill = true; + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["findTotpField"]).toHaveBeenCalledTimes(2); + expect(autofillService["findTotpField"]).toHaveBeenNthCalledWith( + 1, + pageDetails, + passwordField, + false, + false, + false + ); + expect(autofillService["findTotpField"]).toHaveBeenNthCalledWith( + 2, + pageDetails, + passwordField, + true, + true, + false + ); + }); + + it("will not attempt to find a totp field from hidden fields if the passed options indicate only visible fields should be referenced", async () => { + options.allowTotpAutofill = true; + options.onlyVisibleFields = true; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["findTotpField"]).toHaveBeenCalledTimes(1); + expect(autofillService["findTotpField"]).toHaveBeenNthCalledWith( + 1, + pageDetails, + passwordField, + false, + false, + false + ); + expect(autofillService["findTotpField"]).not.toHaveBeenNthCalledWith( + 2, + pageDetails, + passwordField, + true, + true, + false + ); + }); + + it("will not attempt to find a totp field from hidden fields if the passed options do not allow for TOTP values to be filled", async () => { + options.allowTotpAutofill = false; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["findTotpField"]).not.toHaveBeenCalled(); + }); + }); + + describe("given a list of fields without forms within the passed page details", () => { + beforeEach(() => { + pageDetails.forms = undefined; + jest.spyOn(autofillService as any, "findUsernameField"); + jest.spyOn(autofillService as any, "findTotpField"); + }); + + it("will attempt to match a password field that does not contain a form to a username field", async () => { + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["findUsernameField"]).toHaveBeenCalledTimes(1); + expect(autofillService["findUsernameField"]).toHaveBeenCalledWith( + pageDetails, + passwordField, + false, + false, + true + ); + }); + + it("will attempt to match a password field that does not contain a form to a username field that is not visible", async () => { + usernameField.viewable = false; + usernameField.readonly = true; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["findUsernameField"]).toHaveBeenCalledTimes(2); + expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith( + 1, + pageDetails, + passwordField, + false, + false, + true + ); + expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith( + 2, + pageDetails, + passwordField, + true, + true, + true + ); + }); + + it("will not attempt to match a password field that does not contain a form to a username field that is not visible if the passed options indicate only visible fields", async () => { + usernameField.viewable = false; + usernameField.readonly = true; + options.onlyVisibleFields = true; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["findUsernameField"]).toHaveBeenCalledTimes(1); + expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith( + 1, + pageDetails, + passwordField, + false, + false, + true + ); + expect(autofillService["findUsernameField"]).not.toHaveBeenNthCalledWith( + 2, + pageDetails, + passwordField, + true, + true, + true + ); + }); + + it("will attempt to match a password field that does not contain a form to a TOTP field", async () => { + options.allowTotpAutofill = true; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["findTotpField"]).toHaveBeenCalledTimes(1); + expect(autofillService["findTotpField"]).toHaveBeenCalledWith( + pageDetails, + passwordField, + false, + false, + true + ); + }); + + it("will attempt to match a password field that does not contain a form to a TOTP field that is not visible", async () => { + options.onlyVisibleFields = false; + options.allowTotpAutofill = true; + totpField.viewable = false; + totpField.readonly = true; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["findTotpField"]).toHaveBeenCalledTimes(2); + expect(autofillService["findTotpField"]).toHaveBeenNthCalledWith( + 1, + pageDetails, + passwordField, + false, + false, + true + ); + expect(autofillService["findTotpField"]).toHaveBeenNthCalledWith( + 2, + pageDetails, + passwordField, + true, + true, + true + ); + }); + }); + + describe("given a set of page details that does not contain a password field", () => { + let emailField: AutofillField; + let emailFieldView: FieldView; + let telephoneField: AutofillField; + let telephoneFieldView: FieldView; + let totpField: AutofillField; + let totpFieldView: FieldView; + let nonViewableField: AutofillField; + let nonViewableFieldView: FieldView; + + beforeEach(() => { + usernameField.htmlName = "username"; + emailField = createAutofillFieldMock({ + opid: "email", + type: "email", + form: "validFormId", + elementNumber: 2, + }); + emailFieldView = mock({ + name: "email", + }); + telephoneField = createAutofillFieldMock({ + opid: "telephone", + type: "tel", + form: "validFormId", + elementNumber: 3, + }); + telephoneFieldView = mock({ + name: "telephone", + }); + totpField = createAutofillFieldMock({ + opid: "totp", + type: "text", + form: "validFormId", + htmlName: "totpcode", + elementNumber: 4, + }); + totpFieldView = mock({ + name: "totp", + }); + nonViewableField = createAutofillFieldMock({ + opid: "non-viewable", + form: "validFormId", + viewable: false, + elementNumber: 4, + }); + nonViewableFieldView = mock({ + name: "non-viewable", + }); + pageDetails.fields = [ + usernameField, + emailField, + telephoneField, + totpField, + nonViewableField, + ]; + options.cipher.fields = [ + usernameFieldView, + emailFieldView, + telephoneFieldView, + totpFieldView, + nonViewableFieldView, + ]; + jest.spyOn(AutofillService, "fieldIsFuzzyMatch"); + jest.spyOn(AutofillService, "fillByOpid"); + }); + + it("will attempt to fuzzy match a username to a viewable text, email or tel field if no password fields are found and the username fill is not being skipped", async () => { + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.fieldIsFuzzyMatch).toHaveBeenCalledTimes(4); + expect(AutofillService.fieldIsFuzzyMatch).toHaveBeenNthCalledWith( + 1, + usernameField, + AutoFillConstants.UsernameFieldNames + ); + expect(AutofillService.fieldIsFuzzyMatch).toHaveBeenNthCalledWith( + 2, + emailField, + AutoFillConstants.UsernameFieldNames + ); + expect(AutofillService.fieldIsFuzzyMatch).toHaveBeenNthCalledWith( + 3, + telephoneField, + AutoFillConstants.UsernameFieldNames + ); + expect(AutofillService.fieldIsFuzzyMatch).toHaveBeenNthCalledWith( + 4, + totpField, + AutoFillConstants.UsernameFieldNames + ); + expect(AutofillService.fieldIsFuzzyMatch).not.toHaveBeenNthCalledWith( + 5, + nonViewableField, + AutoFillConstants.UsernameFieldNames + ); + expect(AutofillService.fillByOpid).toHaveBeenCalledTimes(1); + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + usernameField, + options.cipher.login.username + ); + }); + + it("will not attempt to fuzzy match a username if the username fill is being skipped", async () => { + options.skipUsernameOnlyFill = true; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.fieldIsFuzzyMatch).not.toHaveBeenCalledWith( + expect.anything(), + AutoFillConstants.UsernameFieldNames + ); + }); + + it("will attempt to fuzzy match a totp field if totp autofill is allowed", async () => { + options.allowTotpAutofill = true; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.fieldIsFuzzyMatch).toHaveBeenCalledWith( + expect.anything(), + AutoFillConstants.TotpFieldNames + ); + }); + + it("will not attempt to fuzzy match a totp field if totp autofill is not allowed", async () => { + options.allowTotpAutofill = false; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.fieldIsFuzzyMatch).not.toHaveBeenCalledWith( + expect.anything(), + AutoFillConstants.TotpFieldNames + ); + }); + }); + + it("returns a value indicating if the page url is in an untrusted iframe", async () => { + jest.spyOn(autofillService as any, "inUntrustedIframe").mockReturnValueOnce(true); + + const value = await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.untrustedIframe).toBe(true); + }); + + it("returns a fill script used to autofill a login item", async () => { + jest.spyOn(autofillService as any, "inUntrustedIframe"); + jest.spyOn(AutofillService, "loadPasswordFields"); + jest.spyOn(autofillService as any, "findUsernameField"); + jest.spyOn(AutofillService, "fieldIsFuzzyMatch"); + jest.spyOn(AutofillService, "fillByOpid"); + jest.spyOn(AutofillService, "setFillScriptForFocus"); + + const value = await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["inUntrustedIframe"]).toHaveBeenCalledWith(pageDetails.url, options); + expect(AutofillService.loadPasswordFields).toHaveBeenCalledWith( + pageDetails, + false, + false, + options.onlyEmptyFields, + options.fillNewPassword + ); + expect(autofillService["findUsernameField"]).toHaveBeenCalledWith( + pageDetails, + passwordField, + false, + false, + false + ); + expect(AutofillService.fieldIsFuzzyMatch).not.toHaveBeenCalled(); + expect(AutofillService.fillByOpid).toHaveBeenCalledTimes(2); + expect(AutofillService.fillByOpid).toHaveBeenNthCalledWith( + 1, + fillScript, + usernameField, + options.cipher.login.username + ); + expect(AutofillService.fillByOpid).toHaveBeenNthCalledWith( + 2, + fillScript, + passwordField, + options.cipher.login.password + ); + expect(AutofillService.setFillScriptForFocus).toHaveBeenCalledWith( + filledFields, + fillScript + ); + expect(value).toStrictEqual({ + autosubmit: null, + metadata: {}, + properties: { delay_between_operations: 20 }, + savedUrls: ["https://www.example.com"], + script: [ + ["click_on_opid", "default-field"], + ["focus_by_opid", "default-field"], + ["fill_by_opid", "default-field", "default"], + ["click_on_opid", "username"], + ["focus_by_opid", "username"], + ["fill_by_opid", "username", "username"], + ["click_on_opid", "password"], + ["focus_by_opid", "password"], + ["fill_by_opid", "password", "password"], + ["focus_by_opid", "password"], + ], + itemType: "", + untrustedIframe: false, + }); + }); + }); + }); + + describe("generateCardFillScript", () => { + let fillScript: AutofillScript; + let pageDetails: AutofillPageDetails; + let filledFields: { [id: string]: AutofillField }; + let options: GenerateFillScriptOptions; + + beforeEach(() => { + fillScript = createAutofillScriptMock({ + script: [], + }); + pageDetails = createAutofillPageDetailsMock(); + filledFields = { + "cardholderName-field": createAutofillFieldMock({ + opid: "cardholderName-field", + form: "validFormId", + elementNumber: 1, + htmlName: "cc-name", + }), + "cardNumber-field": createAutofillFieldMock({ + opid: "cardNumber-field", + form: "validFormId", + elementNumber: 2, + htmlName: "cc-number", + }), + "expMonth-field": createAutofillFieldMock({ + opid: "expMonth-field", + form: "validFormId", + elementNumber: 3, + htmlName: "exp-month", + }), + "expYear-field": createAutofillFieldMock({ + opid: "expYear-field", + form: "validFormId", + elementNumber: 4, + htmlName: "exp-year", + }), + "code-field": createAutofillFieldMock({ + opid: "code-field", + form: "validFormId", + elementNumber: 1, + htmlName: "cvc", + }), + }; + options = createGenerateFillScriptOptionsMock(); + options.cipher.card = mock(); + }); + + it("returns null if the passed options contains a cipher with no card view", () => { + options.cipher.card = undefined; + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value).toBeNull(); + }); + + describe("given an invalid autofill field", () => { + const unmodifiedFillScriptValues: AutofillScript = { + autosubmit: null, + metadata: {}, + properties: { delay_between_operations: 20 }, + savedUrls: [], + script: [], + itemType: "", + untrustedIframe: false, + }; + + it("returns an unmodified fill script when the field is a `span` field", () => { + const spanField = createAutofillFieldMock({ + opid: "span-field", + form: "validFormId", + elementNumber: 5, + htmlName: "spanField", + tagName: "span", + }); + pageDetails.fields = [spanField]; + jest.spyOn(AutofillService, "forCustomFieldsOnly"); + jest.spyOn(autofillService as any, "isExcludedType"); + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(spanField); + expect(autofillService["isExcludedType"]).not.toHaveBeenCalled(); + expect(value).toStrictEqual(unmodifiedFillScriptValues); + }); + + AutoFillConstants.ExcludedAutofillTypes.forEach((excludedType) => { + it(`returns an unmodified fill script when the field has a '${excludedType}' type`, () => { + const invalidField = createAutofillFieldMock({ + opid: `${excludedType}-field`, + form: "validFormId", + elementNumber: 5, + htmlName: "invalidField", + type: excludedType, + }); + pageDetails.fields = [invalidField]; + jest.spyOn(AutofillService, "forCustomFieldsOnly"); + jest.spyOn(autofillService as any, "isExcludedType"); + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(invalidField); + expect(autofillService["isExcludedType"]).toHaveBeenCalledWith( + invalidField.type, + AutoFillConstants.ExcludedAutofillTypes + ); + expect(value).toStrictEqual(unmodifiedFillScriptValues); + }); + }); + + it("returns an unmodified fill script when the field is not viewable", () => { + const notViewableField = createAutofillFieldMock({ + opid: "invalid-field", + form: "validFormId", + elementNumber: 5, + htmlName: "invalidField", + type: "text", + viewable: false, + }); + pageDetails.fields = [notViewableField]; + jest.spyOn(AutofillService, "forCustomFieldsOnly"); + jest.spyOn(autofillService as any, "isExcludedType"); + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(notViewableField); + expect(autofillService["isExcludedType"]).toHaveBeenCalled(); + expect(value).toStrictEqual(unmodifiedFillScriptValues); + }); + }); + + describe("given a valid set of autofill fields", () => { + let cardholderNameField: AutofillField; + let cardholderNameFieldView: FieldView; + let cardNumberField: AutofillField; + let cardNumberFieldView: FieldView; + let expMonthField: AutofillField; + let expMonthFieldView: FieldView; + let expYearField: AutofillField; + let expYearFieldView: FieldView; + let codeField: AutofillField; + let codeFieldView: FieldView; + let brandField: AutofillField; + let brandFieldView: FieldView; + + beforeEach(() => { + cardholderNameField = createAutofillFieldMock({ + opid: "cardholderName", + form: "validFormId", + elementNumber: 1, + htmlName: "cc-name", + }); + cardholderNameFieldView = mock({ name: "cardholderName" }); + cardNumberField = createAutofillFieldMock({ + opid: "cardNumber", + form: "validFormId", + elementNumber: 2, + htmlName: "cc-number", + }); + cardNumberFieldView = mock({ name: "cardNumber" }); + expMonthField = createAutofillFieldMock({ + opid: "expMonth", + form: "validFormId", + elementNumber: 3, + htmlName: "exp-month", + }); + expMonthFieldView = mock({ name: "expMonth" }); + expYearField = createAutofillFieldMock({ + opid: "expYear", + form: "validFormId", + elementNumber: 4, + htmlName: "exp-year", + }); + expYearFieldView = mock({ name: "expYear" }); + codeField = createAutofillFieldMock({ + opid: "code", + form: "validFormId", + elementNumber: 1, + htmlName: "cvc", + }); + brandField = createAutofillFieldMock({ + opid: "brand", + form: "validFormId", + elementNumber: 1, + htmlName: "card-brand", + }); + brandFieldView = mock({ name: "brand" }); + codeFieldView = mock({ name: "code" }); + pageDetails.fields = [ + cardholderNameField, + cardNumberField, + expMonthField, + expYearField, + codeField, + brandField, + ]; + options.cipher.fields = [ + cardholderNameFieldView, + cardNumberFieldView, + expMonthFieldView, + expYearFieldView, + codeFieldView, + brandFieldView, + ]; + options.cipher.card.cardholderName = "testCardholderName"; + options.cipher.card.number = "testCardNumber"; + options.cipher.card.expMonth = "testExpMonth"; + options.cipher.card.expYear = "testExpYear"; + options.cipher.card.code = "testCode"; + options.cipher.card.brand = "testBrand"; + jest.spyOn(AutofillService, "forCustomFieldsOnly"); + jest.spyOn(autofillService as any, "isExcludedType"); + jest.spyOn(AutofillService as any, "isFieldMatch"); + jest.spyOn(autofillService as any, "makeScriptAction"); + jest.spyOn(AutofillService, "hasValue"); + jest.spyOn(autofillService as any, "fieldAttrsContain"); + jest.spyOn(AutofillService, "fillByOpid"); + jest.spyOn(autofillService as any, "makeScriptActionWithValue"); + }); + + it("returns a fill script containing all of the passed card fields", () => { + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledTimes(6); + expect(autofillService["isExcludedType"]).toHaveBeenCalledTimes(6); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalled(); + expect(autofillService["makeScriptAction"]).toHaveBeenCalledTimes(4); + expect(AutofillService["hasValue"]).toHaveBeenCalledTimes(6); + expect(autofillService["fieldAttrsContain"]).toHaveBeenCalledTimes(3); + expect(AutofillService["fillByOpid"]).toHaveBeenCalledTimes(6); + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledTimes(4); + expect(value).toStrictEqual({ + autosubmit: null, + itemType: "", + metadata: {}, + properties: { + delay_between_operations: 20, + }, + savedUrls: [], + script: [ + ["click_on_opid", "cardholderName"], + ["focus_by_opid", "cardholderName"], + ["fill_by_opid", "cardholderName", "testCardholderName"], + ["click_on_opid", "cardNumber"], + ["focus_by_opid", "cardNumber"], + ["fill_by_opid", "cardNumber", "testCardNumber"], + ["click_on_opid", "code"], + ["focus_by_opid", "code"], + ["fill_by_opid", "code", "testCode"], + ["click_on_opid", "brand"], + ["focus_by_opid", "brand"], + ["fill_by_opid", "brand", "testBrand"], + ["click_on_opid", "expMonth"], + ["focus_by_opid", "expMonth"], + ["fill_by_opid", "expMonth", "testExpMonth"], + ["click_on_opid", "expYear"], + ["focus_by_opid", "expYear"], + ["fill_by_opid", "expYear", "testExpYear"], + ], + untrustedIframe: false, + }); + }); + }); + + describe("given an expiration month field", () => { + let expMonthField: AutofillField; + let expMonthFieldView: FieldView; + + beforeEach(() => { + expMonthField = createAutofillFieldMock({ + opid: "expMonth", + form: "validFormId", + elementNumber: 3, + htmlName: "exp-month", + selectInfo: { + options: [ + ["January", "01"], + ["February", "02"], + ["March", "03"], + ["April", "04"], + ["May", "05"], + ["June", "06"], + ["July", "07"], + ["August", "08"], + ["September", "09"], + ["October", "10"], + ["November", "11"], + ["December", "12"], + ], + }, + }); + expMonthFieldView = mock({ name: "expMonth" }); + pageDetails.fields = [expMonthField]; + options.cipher.fields = [expMonthFieldView]; + options.cipher.card.expMonth = "05"; + }); + + it("returns an expiration month parsed from found select options within the field", () => { + const testValue = "sometestvalue"; + expMonthField.selectInfo.options[4] = ["May", testValue]; + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]); + }); + + it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the end of the list of options", () => { + const testValue = "sometestvalue"; + expMonthField.selectInfo.options[4] = ["May", testValue]; + expMonthField.selectInfo.options.push(["", ""]); + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]); + }); + + it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the start of the list of options", () => { + const testValue = "sometestvalue"; + expMonthField.selectInfo.options[4] = ["May", testValue]; + expMonthField.selectInfo.options.unshift(["", ""]); + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]); + }); + + it("returns an expiration month with a zero attached if the field requires two characters, and the vault item has only one character", () => { + options.cipher.card.expMonth = "5"; + expMonthField.selectInfo = null; + expMonthField.placeholder = "mm"; + expMonthField.maxLength = 2; + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, "05"]); + }); + }); + + describe("given an expiration year field", () => { + let expYearField: AutofillField; + let expYearFieldView: FieldView; + + beforeEach(() => { + expYearField = createAutofillFieldMock({ + opid: "expYear", + form: "validFormId", + elementNumber: 3, + htmlName: "exp-year", + selectInfo: { + options: [ + ["2023", "2023"], + ["2024", "2024"], + ["2025", "2025"], + ], + }, + }); + expYearFieldView = mock({ name: "expYear" }); + pageDetails.fields = [expYearField]; + options.cipher.fields = [expYearFieldView]; + options.cipher.card.expYear = "2024"; + }); + + it("returns an expiration year parsed from the select options if an exact match is found for either the select option text or value", () => { + const someTestValue = "sometestvalue"; + expYearField.selectInfo.options[1] = ["2024", someTestValue]; + options.cipher.card.expYear = someTestValue; + + let value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, someTestValue]); + + expYearField.selectInfo.options[1] = [someTestValue, "2024"]; + + value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, someTestValue]); + }); + + it("returns an expiration year parsed from the select options if the value of an option contains only two characters and the vault item value contains four characters", () => { + const yearValue = "26"; + expYearField.selectInfo.options.push(["The year 2026", yearValue]); + options.cipher.card.expYear = "2026"; + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, yearValue]); + }); + + it("returns an expiration year parsed from the select options if the vault of an option is separated by a colon", () => { + const yearValue = "26"; + const colonSeparatedYearValue = `2:0${yearValue}`; + expYearField.selectInfo.options.push(["The year 2026", colonSeparatedYearValue]); + options.cipher.card.expYear = yearValue; + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual([ + "fill_by_opid", + expYearField.opid, + colonSeparatedYearValue, + ]); + }); + + it("returns an expiration year with `20` prepended to the vault item value if the field to be filled expects a `yyyy` format but the vault item only has two characters", () => { + const yearValue = "26"; + expYearField.selectInfo = null; + expYearField.placeholder = "yyyy"; + expYearField.maxLength = 4; + options.cipher.card.expYear = yearValue; + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual([ + "fill_by_opid", + expYearField.opid, + `20${yearValue}`, + ]); + }); + + it("returns an expiration year with only the last two values if the field to be filled expects a `yy` format but the vault item contains four characters", () => { + const yearValue = "26"; + expYearField.selectInfo = null; + expYearField.placeholder = "yy"; + expYearField.maxLength = 2; + options.cipher.card.expYear = `20${yearValue}`; + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, yearValue]); + }); + }); + + describe("given a generic expiration date field", () => { + let expirationDateField: AutofillField; + let expirationDateFieldView: FieldView; + + beforeEach(() => { + expirationDateField = createAutofillFieldMock({ + opid: "expirationDate", + form: "validFormId", + elementNumber: 3, + htmlName: "expiration-date", + }); + filledFields["exp-field"] = expirationDateField; + expirationDateFieldView = mock({ name: "exp" }); + pageDetails.fields = [expirationDateField]; + options.cipher.fields = [expirationDateFieldView]; + options.cipher.card.expMonth = "05"; + options.cipher.card.expYear = "2024"; + }); + + const expectedDateFormats = [ + ["mm/yyyy", "05/2024"], + ["mm/yy", "05/24"], + ["yyyy/mm", "2024/05"], + ["yy/mm", "24/05"], + ["mm-yyyy", "05-2024"], + ["mm-yy", "05-24"], + ["yyyy-mm", "2024-05"], + ["yy-mm", "24-05"], + ["yyyymm", "202405"], + ["yymm", "2405"], + ["mmyyyy", "052024"], + ["mmyy", "0524"], + ]; + expectedDateFormats.forEach((dateFormat, index) => { + it(`returns an expiration date format matching '${dateFormat[0]}'`, () => { + expirationDateField.placeholder = dateFormat[0]; + if (index === 0) { + options.cipher.card.expYear = "24"; + } + if (index === 1) { + options.cipher.card.expMonth = "5"; + } + + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", dateFormat[1]]); + }); + }); + + it("returns an expiration date format matching `yyyy-mm` if no valid format can be identified", () => { + const value = autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", "2024-05"]); + }); + }); + }); + + describe("inUntrustedIframe", () => { + it("returns a false value if the passed pageUrl is equal to the options tabUrl", () => { + const pageUrl = "https://www.example.com"; + const tabUrl = "https://www.example.com"; + const generateFillScriptOptions = createGenerateFillScriptOptionsMock({ tabUrl }); + generateFillScriptOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(true); + jest.spyOn(settingsService, "getEquivalentDomains"); + + const result = autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions); + + expect(settingsService.getEquivalentDomains).not.toHaveBeenCalled(); + expect(generateFillScriptOptions.cipher.login.matchesUri).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("returns a false value if the passed pageUrl matches the domain of the tabUrl", () => { + const pageUrl = "https://subdomain.example.com"; + const tabUrl = "https://www.example.com"; + const equivalentDomains = new Set(["example.com"]); + const generateFillScriptOptions = createGenerateFillScriptOptionsMock({ tabUrl }); + generateFillScriptOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(true); + jest.spyOn(settingsService as any, "getEquivalentDomains").mockReturnValue(equivalentDomains); + + const result = autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions); + + expect(settingsService.getEquivalentDomains).toHaveBeenCalledWith(pageUrl); + expect(generateFillScriptOptions.cipher.login.matchesUri).toHaveBeenCalledWith( + pageUrl, + equivalentDomains, + generateFillScriptOptions.defaultUriMatch + ); + expect(result).toBe(false); + }); + + it("returns a true value if the passed pageUrl does not match the domain of the tabUrl", () => { + const pageUrl = "https://subdomain.example.com"; + const tabUrl = "https://www.not-example.com"; + const equivalentDomains = new Set(["not-example.com"]); + const generateFillScriptOptions = createGenerateFillScriptOptionsMock({ tabUrl }); + generateFillScriptOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(false); + jest.spyOn(settingsService as any, "getEquivalentDomains").mockReturnValue(equivalentDomains); + + const result = autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions); + + expect(settingsService.getEquivalentDomains).toHaveBeenCalledWith(pageUrl); + expect(generateFillScriptOptions.cipher.login.matchesUri).toHaveBeenCalledWith( + pageUrl, + equivalentDomains, + generateFillScriptOptions.defaultUriMatch + ); + expect(result).toBe(true); + }); + }); + + describe("fieldAttrsContain", () => { + let cardNumberField: AutofillField; + + beforeEach(() => { + cardNumberField = createAutofillFieldMock({ + opid: "cardNumber", + form: "validFormId", + elementNumber: 1, + htmlName: "card-number", + }); + }); + + it("returns false if a field is not passed", () => { + const value = autofillService["fieldAttrsContain"](null, "data-foo"); + + expect(value).toBe(false); + }); + + it("returns false if the field does not contain the passed attribute", () => { + const value = autofillService["fieldAttrsContain"](cardNumberField, "data-foo"); + + expect(value).toBe(false); + }); + + it("returns true if the field contains the passed attribute", () => { + const value = autofillService["fieldAttrsContain"](cardNumberField, "card-number"); + + expect(value).toBe(true); + }); + }); + + describe("generateIdentityFillScript", () => { + let fillScript: AutofillScript; + let pageDetails: AutofillPageDetails; + let filledFields: { [id: string]: AutofillField }; + let options: GenerateFillScriptOptions; + + beforeEach(() => { + fillScript = createAutofillScriptMock({ script: [] }); + pageDetails = createAutofillPageDetailsMock(); + filledFields = {}; + options = createGenerateFillScriptOptionsMock(); + options.cipher.identity = mock(); + }); + + it("returns null if an identify is not found within the cipher", () => { + options.cipher.identity = null; + jest.spyOn(autofillService as any, "makeScriptAction"); + jest.spyOn(autofillService as any, "makeScriptActionWithValue"); + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(value).toBeNull(); + expect(autofillService["makeScriptAction"]).not.toHaveBeenCalled(); + expect(autofillService["makeScriptActionWithValue"]).not.toHaveBeenCalled(); + }); + + describe("given a set of page details that contains fields", () => { + const firstName = "John"; + const middleName = "A"; + const lastName = "Doe"; + + beforeEach(() => { + pageDetails.fields = []; + jest.spyOn(AutofillService, "forCustomFieldsOnly"); + jest.spyOn(autofillService as any, "isExcludedType"); + jest.spyOn(AutofillService as any, "isFieldMatch"); + jest.spyOn(autofillService as any, "makeScriptAction"); + jest.spyOn(autofillService as any, "makeScriptActionWithValue"); + }); + + it("will not attempt to match custom fields", () => { + const customField = createAutofillFieldMock({ tagName: "span" }); + pageDetails.fields.push(customField); + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(customField); + expect(autofillService["isExcludedType"]).not.toHaveBeenCalled(); + expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled(); + expect(value.script).toStrictEqual([]); + }); + + it("will not attempt to match a field that is of an excluded type", () => { + const excludedField = createAutofillFieldMock({ type: "hidden" }); + pageDetails.fields.push(excludedField); + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(excludedField); + expect(autofillService["isExcludedType"]).toHaveBeenCalledWith( + excludedField.type, + AutoFillConstants.ExcludedAutofillTypes + ); + expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled(); + expect(value.script).toStrictEqual([]); + }); + + it("will not attempt to match a field that is not viewable", () => { + const viewableField = createAutofillFieldMock({ viewable: false }); + pageDetails.fields.push(viewableField); + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(viewableField); + expect(autofillService["isExcludedType"]).toHaveBeenCalled(); + expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled(); + expect(value.script).toStrictEqual([]); + }); + + it("will match a full name field to the vault item identity value", () => { + const fullNameField = createAutofillFieldMock({ opid: "fullName", htmlName: "full-name" }); + pageDetails.fields = [fullNameField]; + options.cipher.identity.firstName = firstName; + options.cipher.identity.middleName = middleName; + options.cipher.identity.lastName = lastName; + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + fullNameField.htmlName, + IdentityAutoFillConstants.FullNameFieldNames, + IdentityAutoFillConstants.FullNameFieldNameValues + ); + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + `${firstName} ${middleName} ${lastName}`, + fullNameField, + filledFields + ); + expect(value.script[2]).toStrictEqual([ + "fill_by_opid", + fullNameField.opid, + `${firstName} ${middleName} ${lastName}`, + ]); + }); + + it("will match a full name field to the a vault item that only has a last name", () => { + const fullNameField = createAutofillFieldMock({ opid: "fullName", htmlName: "full-name" }); + pageDetails.fields = [fullNameField]; + options.cipher.identity.firstName = ""; + options.cipher.identity.middleName = ""; + options.cipher.identity.lastName = lastName; + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + fullNameField.htmlName, + IdentityAutoFillConstants.FullNameFieldNames, + IdentityAutoFillConstants.FullNameFieldNameValues + ); + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + lastName, + fullNameField, + filledFields + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", fullNameField.opid, lastName]); + }); + + it("will match first name, middle name, and last name fields to the vault item identity value", () => { + const firstNameField = createAutofillFieldMock({ + opid: "firstName", + htmlName: "first-name", + }); + const middleNameField = createAutofillFieldMock({ + opid: "middleName", + htmlName: "middle-name", + }); + const lastNameField = createAutofillFieldMock({ opid: "lastName", htmlName: "last-name" }); + pageDetails.fields = [firstNameField, middleNameField, lastNameField]; + options.cipher.identity.firstName = firstName; + options.cipher.identity.middleName = middleName; + options.cipher.identity.lastName = lastName; + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + firstNameField.htmlName, + IdentityAutoFillConstants.FirstnameFieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + middleNameField.htmlName, + IdentityAutoFillConstants.MiddlenameFieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + lastNameField.htmlName, + IdentityAutoFillConstants.LastnameFieldNames + ); + expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith( + fillScript, + options.cipher.identity, + expect.anything(), + filledFields, + firstNameField.opid + ); + expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith( + fillScript, + options.cipher.identity, + expect.anything(), + filledFields, + middleNameField.opid + ); + expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith( + fillScript, + options.cipher.identity, + expect.anything(), + filledFields, + lastNameField.opid + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", firstNameField.opid, firstName]); + expect(value.script[5]).toStrictEqual(["fill_by_opid", middleNameField.opid, middleName]); + expect(value.script[8]).toStrictEqual(["fill_by_opid", lastNameField.opid, lastName]); + }); + + it("will match title and email fields to the vault item identity value", () => { + const titleField = createAutofillFieldMock({ opid: "title", htmlName: "title" }); + const emailField = createAutofillFieldMock({ opid: "email", htmlName: "email" }); + pageDetails.fields = [titleField, emailField]; + const title = "Mr."; + const email = "email@example.com"; + options.cipher.identity.title = title; + options.cipher.identity.email = email; + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + titleField.htmlName, + IdentityAutoFillConstants.TitleFieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + emailField.htmlName, + IdentityAutoFillConstants.EmailFieldNames + ); + expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith( + fillScript, + options.cipher.identity, + expect.anything(), + filledFields, + titleField.opid + ); + expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith( + fillScript, + options.cipher.identity, + expect.anything(), + filledFields, + emailField.opid + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", titleField.opid, title]); + expect(value.script[5]).toStrictEqual(["fill_by_opid", emailField.opid, email]); + }); + + it("will match a full address field to the vault item identity values", () => { + const fullAddressField = createAutofillFieldMock({ + opid: "fullAddress", + htmlName: "address", + }); + pageDetails.fields = [fullAddressField]; + const address1 = "123 Main St."; + const address2 = "Apt. 1"; + const address3 = "P.O. Box 123"; + options.cipher.identity.address1 = address1; + options.cipher.identity.address2 = address2; + options.cipher.identity.address3 = address3; + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + fullAddressField.htmlName, + IdentityAutoFillConstants.AddressFieldNames, + IdentityAutoFillConstants.AddressFieldNameValues + ); + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + `${address1}, ${address2}, ${address3}`, + fullAddressField, + filledFields + ); + expect(value.script[2]).toStrictEqual([ + "fill_by_opid", + fullAddressField.opid, + `${address1}, ${address2}, ${address3}`, + ]); + }); + + it("will match address1, address2, address3, postalCode, city, state, country, phone, username, and company fields to their corresponding vault item identity values", () => { + const address1Field = createAutofillFieldMock({ opid: "address1", htmlName: "address-1" }); + const address2Field = createAutofillFieldMock({ opid: "address2", htmlName: "address-2" }); + const address3Field = createAutofillFieldMock({ opid: "address3", htmlName: "address-3" }); + const postalCodeField = createAutofillFieldMock({ + opid: "postalCode", + htmlName: "postal-code", + }); + const cityField = createAutofillFieldMock({ opid: "city", htmlName: "city" }); + const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" }); + const countryField = createAutofillFieldMock({ opid: "country", htmlName: "country" }); + const phoneField = createAutofillFieldMock({ opid: "phone", htmlName: "phone" }); + const usernameField = createAutofillFieldMock({ opid: "username", htmlName: "username" }); + const companyField = createAutofillFieldMock({ opid: "company", htmlName: "company" }); + pageDetails.fields = [ + address1Field, + address2Field, + address3Field, + postalCodeField, + cityField, + stateField, + countryField, + phoneField, + usernameField, + companyField, + ]; + const address1 = "123 Main St."; + const address2 = "Apt. 1"; + const address3 = "P.O. Box 123"; + const postalCode = "12345"; + const city = "City"; + const state = "State"; + const country = "Country"; + const phone = "123-456-7890"; + const username = "username"; + const company = "Company"; + options.cipher.identity.address1 = address1; + options.cipher.identity.address2 = address2; + options.cipher.identity.address3 = address3; + options.cipher.identity.postalCode = postalCode; + options.cipher.identity.city = city; + options.cipher.identity.state = state; + options.cipher.identity.country = country; + options.cipher.identity.phone = phone; + options.cipher.identity.username = username; + options.cipher.identity.company = company; + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + address1Field.htmlName, + IdentityAutoFillConstants.Address1FieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + address2Field.htmlName, + IdentityAutoFillConstants.Address2FieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + address3Field.htmlName, + IdentityAutoFillConstants.Address3FieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + postalCodeField.htmlName, + IdentityAutoFillConstants.PostalCodeFieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + cityField.htmlName, + IdentityAutoFillConstants.CityFieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + stateField.htmlName, + IdentityAutoFillConstants.StateFieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + countryField.htmlName, + IdentityAutoFillConstants.CountryFieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + phoneField.htmlName, + IdentityAutoFillConstants.PhoneFieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + usernameField.htmlName, + IdentityAutoFillConstants.UserNameFieldNames + ); + expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith( + companyField.htmlName, + IdentityAutoFillConstants.CompanyFieldNames + ); + expect(autofillService["makeScriptAction"]).toHaveBeenCalled(); + expect(value.script[2]).toStrictEqual(["fill_by_opid", address1Field.opid, address1]); + expect(value.script[5]).toStrictEqual(["fill_by_opid", address2Field.opid, address2]); + expect(value.script[8]).toStrictEqual(["fill_by_opid", address3Field.opid, address3]); + expect(value.script[11]).toStrictEqual(["fill_by_opid", cityField.opid, city]); + expect(value.script[14]).toStrictEqual(["fill_by_opid", postalCodeField.opid, postalCode]); + expect(value.script[17]).toStrictEqual(["fill_by_opid", companyField.opid, company]); + expect(value.script[20]).toStrictEqual(["fill_by_opid", phoneField.opid, phone]); + expect(value.script[23]).toStrictEqual(["fill_by_opid", usernameField.opid, username]); + expect(value.script[26]).toStrictEqual(["fill_by_opid", stateField.opid, state]); + expect(value.script[29]).toStrictEqual(["fill_by_opid", countryField.opid, country]); + }); + + it("will find the two character IsoState value for an identity cipher that contains the full name of a state", () => { + const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" }); + pageDetails.fields = [stateField]; + const state = "California"; + options.cipher.identity.state = state; + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + "CA", + expect.anything(), + expect.anything() + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", stateField.opid, "CA"]); + }); + + it("will find the two character IsoProvince value for an identity cipher that contains the full name of a province", () => { + const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" }); + pageDetails.fields = [stateField]; + const state = "Ontario"; + options.cipher.identity.state = state; + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + "ON", + expect.anything(), + expect.anything() + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", stateField.opid, "ON"]); + }); + + it("will find the two character IsoCountry value for an identity cipher that contains the full name of a country", () => { + const countryField = createAutofillFieldMock({ opid: "country", htmlName: "country" }); + pageDetails.fields = [countryField]; + const country = "Somalia"; + options.cipher.identity.country = country; + + const value = autofillService["generateIdentityFillScript"]( + fillScript, + pageDetails, + filledFields, + options + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + "SO", + expect.anything(), + expect.anything() + ); + expect(value.script[2]).toStrictEqual(["fill_by_opid", countryField.opid, "SO"]); + }); + }); + }); + + describe("isExcludedType", () => { + it("returns true if the passed type is within the excluded type list", () => { + const value = autofillService["isExcludedType"]( + "hidden", + AutoFillConstants.ExcludedAutofillTypes + ); + + expect(value).toBe(true); + }); + + it("returns true if the passed type is within the excluded type list", () => { + const value = autofillService["isExcludedType"]( + "text", + AutoFillConstants.ExcludedAutofillTypes + ); + + expect(value).toBe(false); + }); + }); + + describe("isFieldMatch", () => { + it("returns true if the passed value is equal to one of the values in the passed options list", () => { + const passedAttribute = "cc-name"; + const passedOptions = ["cc-name", "cc_full_name"]; + + const value = AutofillService["isFieldMatch"](passedAttribute, passedOptions); + + expect(value).toBe(true); + }); + + it("should returns true if the passed options contain a value within the containsOptions list and the passed value partial matches the option", () => { + const passedAttribute = "cc-name-full"; + const passedOptions = ["cc-name", "cc_full_name"]; + const containsOptions = ["cc-name"]; + + const value = AutofillService["isFieldMatch"]( + passedAttribute, + passedOptions, + containsOptions + ); + + expect(value).toBe(true); + }); + + it("returns false if the value is not a partial match to an option found within the containsOption list", () => { + const passedAttribute = "cc-full-name"; + const passedOptions = ["cc-name", "cc_full_name"]; + const containsOptions = ["cc-name"]; + + const value = AutofillService["isFieldMatch"]( + passedAttribute, + passedOptions, + containsOptions + ); + + expect(value).toBe(false); + }); + }); + + describe("makeScriptAction", () => { + let fillScript: AutofillScript; + let options: GenerateFillScriptOptions; + let mockLoginView: any; + let fillFields: { [key: string]: AutofillField }; + const filledFields = {}; + + beforeEach(() => { + fillScript = createAutofillScriptMock({}); + options = createGenerateFillScriptOptionsMock({}); + mockLoginView = mock() as any; + options.cipher.login = mockLoginView; + fillFields = { + "username-field": createAutofillFieldMock({ opid: "username-field" }), + }; + jest.spyOn(autofillService as any, "makeScriptActionWithValue"); + }); + + it("makes a call to makeScriptActionWithValue using the passed dataProp value", () => { + const dataProp = "username-field"; + + autofillService["makeScriptAction"]( + fillScript, + options.cipher.login, + fillFields, + filledFields, + dataProp + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + mockLoginView[dataProp], + fillFields[dataProp], + filledFields + ); + }); + + it("makes a call to makeScriptActionWithValue using the passed fieldProp value used for fillFields", () => { + const dataProp = "value"; + const fieldProp = "username-field"; + + autofillService["makeScriptAction"]( + fillScript, + options.cipher.login, + fillFields, + filledFields, + dataProp, + fieldProp + ); + + expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith( + fillScript, + mockLoginView[dataProp], + fillFields[fieldProp], + filledFields + ); + }); + }); + + describe("makeScriptActionWithValue", () => { + let fillScript: AutofillScript; + let options: GenerateFillScriptOptions; + let mockLoginView: any; + let fillFields: { [key: string]: AutofillField }; + const filledFields = {}; + + beforeEach(() => { + fillScript = createAutofillScriptMock({}); + options = createGenerateFillScriptOptionsMock({}); + mockLoginView = mock() as any; + options.cipher.login = mockLoginView; + fillFields = { + "username-field": createAutofillFieldMock({ opid: "username-field" }), + }; + jest.spyOn(autofillService as any, "makeScriptActionWithValue"); + jest.spyOn(AutofillService, "hasValue"); + jest.spyOn(AutofillService, "fillByOpid"); + }); + + it("will not add an autofill action to the fill script if the value does not exist", () => { + const dataValue = ""; + + autofillService["makeScriptActionWithValue"]( + fillScript, + dataValue, + fillFields["username-field"], + filledFields + ); + + expect(AutofillService.hasValue).toHaveBeenCalledWith(dataValue); + expect(AutofillService.fillByOpid).not.toHaveBeenCalled(); + }); + + it("will not add an autofill action to the fill script if a field is not passed", () => { + const dataValue = "username"; + + autofillService["makeScriptActionWithValue"](fillScript, dataValue, null, filledFields); + + expect(AutofillService.hasValue).toHaveBeenCalledWith(dataValue); + expect(AutofillService.fillByOpid).not.toHaveBeenCalled(); + }); + + it("will add an autofill action to the fill script", () => { + const dataValue = "username"; + + autofillService["makeScriptActionWithValue"]( + fillScript, + dataValue, + fillFields["username-field"], + filledFields + ); + + expect(AutofillService.hasValue).toHaveBeenCalledWith(dataValue); + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + fillFields["username-field"], + dataValue + ); + }); + + describe("given a autofill field value that indicates the field is a `select` input", () => { + it("will not add an autofil action to the fill script if the dataValue cannot be found in the select options", () => { + const dataValue = "username"; + const selectField = createAutofillFieldMock({ + opid: "username-field", + tagName: "select", + type: "select-one", + selectInfo: { + options: [["User Name", "Some Other Username Value"]], + }, + }); + + autofillService["makeScriptActionWithValue"]( + fillScript, + dataValue, + selectField, + filledFields + ); + + expect(AutofillService.hasValue).toHaveBeenCalledWith(dataValue); + expect(AutofillService.fillByOpid).not.toHaveBeenCalled(); + }); + + it("will update the data value to the value found in the select options, and add an autofill action to the fill script", () => { + const dataValue = "username"; + const selectField = createAutofillFieldMock({ + opid: "username-field", + tagName: "select", + type: "select-one", + selectInfo: { + options: [["username", "Some Other Username Value"]], + }, + }); + + autofillService["makeScriptActionWithValue"]( + fillScript, + dataValue, + selectField, + filledFields + ); + + expect(AutofillService.hasValue).toHaveBeenCalledWith(dataValue); + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + selectField, + "Some Other Username Value" + ); + }); + }); + }); + + describe("loadPasswordFields", () => { + let pageDetails: AutofillPageDetails; + let passwordField: AutofillField; + + beforeEach(() => { + pageDetails = createAutofillPageDetailsMock({}); + passwordField = createAutofillFieldMock({ + opid: "password-field", + type: "password", + form: "validFormId", + }); + jest.spyOn(AutofillService, "forCustomFieldsOnly"); + }); + + it("returns an empty array if passed a field that is a `span` element", () => { + const customField = createAutofillFieldMock({ tagName: "span" }); + pageDetails.fields = [customField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(customField); + expect(result).toStrictEqual([]); + }); + + it("returns an empty array if passed a disabled field", () => { + passwordField.disabled = true; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(result).toStrictEqual([]); + }); + + describe("given a field that is readonly", () => { + it("returns an empty array if the field cannot be readonly", () => { + passwordField.readonly = true; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(result).toStrictEqual([]); + }); + + it("returns the field within an array if the field can be readonly", () => { + passwordField.readonly = true; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, true, false, true); + + expect(result).toStrictEqual([passwordField]); + }); + }); + + describe("give a field that is not of type `password`", () => { + beforeEach(() => { + passwordField.type = "text"; + }); + + it("returns an empty array if the field type is not `text`", () => { + passwordField.type = "email"; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(result).toStrictEqual([]); + }); + + it("returns an empty array if the `htmlID`, `htmlName`, or `placeholder` of the field's values do not include the word `password`", () => { + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(result).toStrictEqual([]); + }); + + it("returns an empty array if the `htmlID` of the field is `null", () => { + passwordField.htmlID = null; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(result).toStrictEqual([]); + }); + + it("returns an empty array if the `htmlID` of the field is equal to `onetimepassword`", () => { + passwordField.htmlID = "onetimepassword"; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(result).toStrictEqual([]); + }); + + it("returns the field in an array if the field's htmlID contains the word `password`", () => { + passwordField.htmlID = "password"; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(result).toStrictEqual([passwordField]); + }); + + it("returns the field in an array if the field's htmlName contains the word `password`", () => { + passwordField.htmlName = "password"; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(result).toStrictEqual([passwordField]); + }); + + it("returns the field in an array if the field's placeholder contains the word `password`", () => { + passwordField.placeholder = "password"; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(result).toStrictEqual([passwordField]); + }); + }); + + describe("given a field that is not viewable", () => { + it("returns an empty array if the field cannot be hidden", () => { + passwordField.viewable = false; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(result).toStrictEqual([]); + }); + + it("returns the field within an array if the field can be hidden", () => { + passwordField.viewable = false; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, true, false, false, true); + + expect(result).toStrictEqual([passwordField]); + }); + }); + + describe("given a need for the passed to be empty", () => { + it("returns an empty array if the passed field contains a value that is not null or empty", () => { + passwordField.value = "Some Password Value"; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, true, false); + + expect(result).toStrictEqual([]); + }); + + it("returns the field within an array if the field contains a null value", () => { + passwordField.value = null; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, true, false); + + expect(result).toStrictEqual([passwordField]); + }); + + it("returns the field within an array if the field contains an empty value", () => { + passwordField.value = ""; + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, true, false); + + expect(result).toStrictEqual([passwordField]); + }); + }); + + describe("given a field with a new password", () => { + beforeEach(() => { + passwordField.autoCompleteType = "new-password"; + }); + + it("returns an empty array if not filling a new password and the autoCompleteType is `new-password`", () => { + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, false); + + expect(result).toStrictEqual([]); + }); + + it("returns the field within an array if filling a new password and the autoCompleteType is `new-password`", () => { + pageDetails.fields = [passwordField]; + + const result = AutofillService.loadPasswordFields(pageDetails, false, false, false, true); + + expect(result).toStrictEqual([passwordField]); + }); + }); + }); + + describe("findUsernameField", () => { + let pageDetails: AutofillPageDetails; + let usernameField: AutofillField; + let passwordField: AutofillField; + + beforeEach(() => { + pageDetails = createAutofillPageDetailsMock({}); + usernameField = createAutofillFieldMock({ + opid: "username-field", + type: "text", + form: "validFormId", + elementNumber: 0, + }); + passwordField = createAutofillFieldMock({ + opid: "password-field", + type: "password", + form: "validFormId", + elementNumber: 1, + }); + pageDetails.fields = [usernameField, passwordField]; + jest.spyOn(AutofillService, "forCustomFieldsOnly"); + jest.spyOn(autofillService as any, "findMatchingFieldIndex"); + }); + + it("returns null when passed a field that is a `span` element", () => { + const field = createAutofillFieldMock({ tagName: "span" }); + pageDetails.fields = [field]; + + const result = autofillService["findUsernameField"](pageDetails, field, false, false, false); + + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(field); + expect(result).toBe(null); + }); + + it("returns null when the passed username field has a larger elementNumber than the passed password field", () => { + usernameField.elementNumber = 2; + + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(null); + }); + + it("returns null if the passed username field is disabled", () => { + usernameField.disabled = true; + + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(null); + }); + + describe("given a field that is readonly", () => { + beforeEach(() => { + usernameField.readonly = true; + }); + + it("returns null if the field cannot be readonly", () => { + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(null); + }); + + it("returns the field if the field can be readonly", () => { + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + true, + false + ); + + expect(result).toBe(usernameField); + }); + }); + + describe("given a username field that does not contain a form that matches the password field", () => { + beforeEach(() => { + usernameField.form = "invalidFormId"; + usernameField.type = "tel"; + }); + + it("returns null if the field cannot be without a form", () => { + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(null); + }); + + it("returns the field if the username field can be without a form", () => { + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + false, + true + ); + + expect(result).toBe(usernameField); + }); + }); + + describe("given a field that is not viewable", () => { + beforeEach(() => { + usernameField.viewable = false; + usernameField.type = "email"; + }); + + it("returns null if the field cannot be hidden", () => { + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(null); + }); + + it("returns the field if the field can be hidden", () => { + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + true, + false, + false + ); + + expect(result).toBe(usernameField); + }); + }); + + it("returns null if the username field does not have a type of `text`, `email`, or `tel`", () => { + usernameField.type = "checkbox"; + + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(null); + }); + + it("returns the username field whose attributes most closely describe the username of the password field", () => { + const usernameField2 = createAutofillFieldMock({ + opid: "username-field-2", + type: "text", + form: "validFormId", + htmlName: "username", + elementNumber: 1, + }); + const usernameField3 = createAutofillFieldMock({ + opid: "username-field-3", + type: "text", + form: "validFormId", + elementNumber: 1, + }); + passwordField.elementNumber = 3; + pageDetails.fields = [usernameField, usernameField2, usernameField3, passwordField]; + + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(usernameField2); + expect(autofillService["findMatchingFieldIndex"]).toHaveBeenCalledTimes(2); + expect(autofillService["findMatchingFieldIndex"]).not.toHaveBeenCalledWith( + usernameField3, + AutoFillConstants.UsernameFieldNames + ); + }); + }); + + describe("findTotpField", () => { + let pageDetails: AutofillPageDetails; + let passwordField: AutofillField; + let totpField: AutofillField; + + beforeEach(() => { + pageDetails = createAutofillPageDetailsMock({}); + passwordField = createAutofillFieldMock({ + opid: "password-field", + type: "password", + form: "validFormId", + elementNumber: 0, + }); + totpField = createAutofillFieldMock({ + opid: "totp-field", + type: "text", + form: "validFormId", + htmlName: "totp", + elementNumber: 1, + }); + pageDetails.fields = [passwordField, totpField]; + jest.spyOn(AutofillService, "forCustomFieldsOnly"); + jest.spyOn(autofillService as any, "findMatchingFieldIndex"); + jest.spyOn(AutofillService, "fieldIsFuzzyMatch"); + }); + + it("returns null when passed a field that is a `span` element", () => { + const field = createAutofillFieldMock({ tagName: "span" }); + pageDetails.fields = [field]; + + const result = autofillService["findTotpField"](pageDetails, field, false, false, false); + + expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(field); + expect(result).toBe(null); + }); + + it("returns null if the passed totp field is disabled", () => { + totpField.disabled = true; + + const result = autofillService["findTotpField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(null); + }); + + describe("given a field that is readonly", () => { + beforeEach(() => { + totpField.readonly = true; + }); + + it("returns null if the field cannot be readonly", () => { + const result = autofillService["findTotpField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(null); + }); + + it("returns the field if the field can be readonly", () => { + const result = autofillService["findTotpField"]( + pageDetails, + passwordField, + false, + true, + false + ); + + expect(result).toBe(totpField); + }); + }); + + describe("given a totp field that does not contain a form that matches the password field", () => { + beforeEach(() => { + totpField.form = "invalidFormId"; + }); + + it("returns null if the field cannot be without a form", () => { + const result = autofillService["findTotpField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(null); + }); + + it("returns the field if the username field can be without a form", () => { + const result = autofillService["findTotpField"]( + pageDetails, + passwordField, + false, + false, + true + ); + + expect(result).toBe(totpField); + }); + }); + + describe("given a field that is not viewable", () => { + beforeEach(() => { + totpField.viewable = false; + totpField.type = "number"; + }); + + it("returns null if the field cannot be hidden", () => { + const result = autofillService["findTotpField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(null); + }); + + it("returns the field if the field can be hidden", () => { + const result = autofillService["findTotpField"]( + pageDetails, + passwordField, + true, + false, + false + ); + + expect(result).toBe(totpField); + }); + }); + + it("returns null if the totp field does not have a type of `text`, or `number`", () => { + totpField.type = "checkbox"; + + const result = autofillService["findTotpField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(null); + }); + + it("returns the field if the autoCompleteType is `one-time-code`", () => { + totpField.autoCompleteType = "one-time-code"; + jest.spyOn(autofillService as any, "findMatchingFieldIndex").mockReturnValueOnce(-1); + + const result = autofillService["findTotpField"]( + pageDetails, + passwordField, + false, + false, + false + ); + + expect(result).toBe(totpField); + }); + }); + + describe("findMatchingFieldIndex", () => { + beforeEach(() => { + jest.spyOn(autofillService as any, "fieldPropertyIsMatch"); + }); + + it("returns the index of a value that matches a property prefix", () => { + const attributes = [ + ["htmlID", "id"], + ["htmlName", "name"], + ["label-aria", "label"], + ["label-tag", "label"], + ["label-right", "label"], + ["label-left", "label"], + ["placeholder", "placeholder"], + ]; + const value = "username"; + + attributes.forEach((attribute) => { + const field = createAutofillFieldMock({ [attribute[0]]: value }); + + const result = autofillService["findMatchingFieldIndex"](field, [ + `${attribute[1]}=${value}`, + ]); + + expect(autofillService["fieldPropertyIsMatch"]).toHaveBeenCalledWith( + field, + attribute[0], + value + ); + expect(result).toBe(0); + }); + }); + + it("returns the index of a value that matches a property", () => { + const attributes = [ + "htmlID", + "htmlName", + "label-aria", + "label-tag", + "label-right", + "label-left", + "placeholder", + ]; + const value = "username"; + + attributes.forEach((attribute) => { + const field = createAutofillFieldMock({ [attribute]: value }); + + const result = autofillService["findMatchingFieldIndex"](field, [value]); + + expect(result).toBe(0); + }); + }); + }); + + describe("fieldPropertyIsPrefixMatch", () => { + it("returns true if the field contains a property whose value is a match", () => { + const field = createAutofillFieldMock({ htmlID: "username" }); + + const result = autofillService["fieldPropertyIsPrefixMatch"]( + field, + "htmlID", + "id=username", + "id" + ); + + expect(result).toBe(true); + }); + + it("returns false if the field contains a property whose value is not a match", () => { + const field = createAutofillFieldMock({ htmlID: "username" }); + + const result = autofillService["fieldPropertyIsPrefixMatch"]( + field, + "htmlID", + "id=some-othername", + "id" + ); + + expect(result).toBe(false); + }); + }); + + describe("fieldPropertyIsMatch", () => { + let field: AutofillField; + + beforeEach(() => { + field = createAutofillFieldMock(); + jest.spyOn(AutofillService, "hasValue"); + }); + + it("returns false if the property within the field does not have a value", () => { + field.htmlID = ""; + + const result = autofillService["fieldPropertyIsMatch"](field, "htmlID", "some-value"); + + expect(AutofillService.hasValue).toHaveBeenCalledWith(""); + expect(result).toBe(false); + }); + + it("returns true if the property within the field provides a value that is equal to the passed `name`", () => { + field.htmlID = "some-value"; + + const result = autofillService["fieldPropertyIsMatch"](field, "htmlID", "some-value"); + + expect(AutofillService.hasValue).toHaveBeenCalledWith("some-value"); + expect(result).toBe(true); + }); + + describe("given a passed `name` value that is expecting a regex check", () => { + it("returns false if the property within the field fails the `name` regex check", () => { + field.htmlID = "some-false-value"; + + const result = autofillService["fieldPropertyIsMatch"](field, "htmlID", "regex=some-value"); + + expect(result).toBe(false); + }); + + it("returns true if the property within the field equals the `name` regex check", () => { + field.htmlID = "some-value"; + + const result = autofillService["fieldPropertyIsMatch"](field, "htmlID", "regex=some-value"); + + expect(result).toBe(true); + }); + + it("returns true if the property within the field has a partial match to the `name` regex check", () => { + field.htmlID = "some-value"; + + const result = autofillService["fieldPropertyIsMatch"](field, "htmlID", "regex=value"); + + expect(result).toBe(true); + }); + + it("will log an error when the regex triggers a catch block", () => { + field.htmlID = "some-value"; + jest.spyOn(autofillService["logService"], "error"); + + const result = autofillService["fieldPropertyIsMatch"](field, "htmlID", "regex=+"); + + expect(autofillService["logService"].error).toHaveBeenCalled(); + expect(result).toBe(false); + }); + }); + + describe("given a passed `name` value that is checking comma separated values", () => { + it("returns false if the property within the field does not have a value that matches the values within the `name` CSV", () => { + field.htmlID = "some-false-value"; + + const result = autofillService["fieldPropertyIsMatch"]( + field, + "htmlID", + "csv=some-value,some-other-value,some-third-value" + ); + + expect(result).toBe(false); + }); + + it("returns true if the property within the field matches a value within the `name` CSV", () => { + field.htmlID = "some-other-value"; + + const result = autofillService["fieldPropertyIsMatch"]( + field, + "htmlID", + "csv=some-value,some-other-value,some-third-value" + ); + + expect(result).toBe(true); + }); + }); + }); + + describe("fieldIsFuzzyMatch", () => { + let field: AutofillField; + const fieldProperties = [ + "htmlID", + "htmlName", + "label-aria", + "label-tag", + "label-top", + "label-left", + "placeholder", + ]; + + beforeEach(() => { + field = createAutofillFieldMock(); + jest.spyOn(AutofillService, "hasValue"); + jest.spyOn(AutofillService as any, "fuzzyMatch"); + }); + + it("returns false if the field properties do not have any values", () => { + fieldProperties.forEach((property) => { + field[property] = ""; + }); + + const result = AutofillService["fieldIsFuzzyMatch"](field, ["some-value"]); + + expect(AutofillService.hasValue).toHaveBeenCalledTimes(7); + expect(AutofillService["fuzzyMatch"]).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("returns false if the field properties do not have a value that is a fuzzy match", () => { + fieldProperties.forEach((property) => { + field[property] = "some-false-value"; + + const result = AutofillService["fieldIsFuzzyMatch"](field, ["some-value"]); + + expect(AutofillService.hasValue).toHaveBeenCalled(); + expect(AutofillService["fuzzyMatch"]).toHaveBeenCalledWith( + ["some-value"], + "some-false-value" + ); + expect(result).toBe(false); + + field[property] = ""; + }); + }); + + it("returns true if the field property has a value that is a fuzzy match", () => { + fieldProperties.forEach((property) => { + field[property] = "some-value"; + + const result = AutofillService["fieldIsFuzzyMatch"](field, ["some-value"]); + + expect(AutofillService.hasValue).toHaveBeenCalled(); + expect(AutofillService["fuzzyMatch"]).toHaveBeenCalledWith(["some-value"], "some-value"); + expect(result).toBe(true); + + field[property] = ""; + }); + }); + }); + + describe("fuzzyMatch", () => { + it("returns false if the passed options is null", () => { + const result = AutofillService["fuzzyMatch"](null, "some-value"); + + expect(result).toBe(false); + }); + + it("returns false if the passed options contains an empty array", () => { + const result = AutofillService["fuzzyMatch"]([], "some-value"); + + expect(result).toBe(false); + }); + + it("returns false if the passed value is null", () => { + const result = AutofillService["fuzzyMatch"](["some-value"], null); + + expect(result).toBe(false); + }); + + it("returns false if the passed value is an empty string", () => { + const result = AutofillService["fuzzyMatch"](["some-value"], ""); + + expect(result).toBe(false); + }); + + it("returns false if the passed value is not present in the options array", () => { + const result = AutofillService["fuzzyMatch"](["some-value"], "some-other-value"); + + expect(result).toBe(false); + }); + + it("returns true if the passed value is within the options array", () => { + const result = AutofillService["fuzzyMatch"]( + ["some-other-value", "some-value"], + "some-value" + ); + + expect(result).toBe(true); + }); + }); + + describe("hasValue", () => { + it("returns false if the passed string is null", () => { + const result = AutofillService.hasValue(null); + + expect(result).toBe(false); + }); + + it("returns false if the passed string is an empty string", () => { + const result = AutofillService.hasValue(""); + + expect(result).toBe(false); + }); + + it("returns true if the passed string is not null or an empty string", () => { + const result = AutofillService.hasValue("some-value"); + + expect(result).toBe(true); + }); + }); + + describe("setFillScriptForFocus", () => { + let usernameField: AutofillField; + let passwordField: AutofillField; + let filledFields: { [key: string]: AutofillField }; + let fillScript: AutofillScript; + + beforeEach(() => { + usernameField = createAutofillFieldMock({ + opid: "username-field", + type: "text", + form: "validFormId", + elementNumber: 0, + }); + passwordField = createAutofillFieldMock({ + opid: "password-field", + type: "password", + form: "validFormId", + elementNumber: 1, + }); + filledFields = { + "username-field": usernameField, + "password-field": passwordField, + }; + fillScript = createAutofillScriptMock({ script: [] }); + }); + + it("returns a fill script with an unmodified actions list if an empty filledFields value is passed", () => { + const result = AutofillService.setFillScriptForFocus({}, fillScript); + + expect(result.script).toStrictEqual([]); + }); + + it("returns a fill script with the password field prioritized when adding a `focus_by_opid` action", () => { + const result = AutofillService.setFillScriptForFocus(filledFields, fillScript); + + expect(result.script).toStrictEqual([["focus_by_opid", "password-field"]]); + }); + + it("returns a fill script with the username field if a password field is not present when adding a `focus_by_opid` action", () => { + delete filledFields["password-field"]; + + const result = AutofillService.setFillScriptForFocus(filledFields, fillScript); + + expect(result.script).toStrictEqual([["focus_by_opid", "username-field"]]); + }); + }); + + describe("fillByOpid", () => { + let usernameField: AutofillField; + let fillScript: AutofillScript; + + beforeEach(() => { + usernameField = createAutofillFieldMock({ + opid: "username-field", + type: "text", + form: "validFormId", + elementNumber: 0, + }); + fillScript = createAutofillScriptMock({ script: [] }); + }); + + it("returns a list of fill script actions for the passed field", () => { + usernameField.maxLength = 5; + AutofillService.fillByOpid(fillScript, usernameField, "some-long-value"); + + expect(fillScript.script).toStrictEqual([ + ["click_on_opid", "username-field"], + ["focus_by_opid", "username-field"], + ["fill_by_opid", "username-field", "some-long-value"], + ]); + }); + + it("returns only the `fill_by_opid` action if the passed field is a `span` element", () => { + usernameField.tagName = "span"; + AutofillService.fillByOpid(fillScript, usernameField, "some-long-value"); + + expect(fillScript.script).toStrictEqual([ + ["fill_by_opid", "username-field", "some-long-value"], + ]); + }); + }); + + describe("forCustomFieldsOnly", () => { + it("returns a true value if the passed field has a tag name of `span`", () => { + const field = createAutofillFieldMock({ tagName: "span" }); + + const result = AutofillService.forCustomFieldsOnly(field); + + expect(result).toBe(true); + }); + + it("returns a false value if the passed field does not have a tag name of `span`", () => { + const field = createAutofillFieldMock({ tagName: "input" }); + + const result = AutofillService.forCustomFieldsOnly(field); + + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 090acf35dc3..aca72562287 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -1,16 +1,17 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { TotpService } from "@bitwarden/common/abstractions/totp.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { EventType, FieldType, UriMatchType } from "@bitwarden/common/enums"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; 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"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; -import { BrowserApi } from "../../browser/browserApi"; -import { BrowserStateService } from "../../services/abstractions/browser-state.service"; +import { BrowserApi } from "../../platform/browser/browser-api"; +import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; @@ -18,8 +19,9 @@ import AutofillScript from "../models/autofill-script"; import { AutoFillOptions, AutofillService as AutofillServiceInterface, - PageDetail, FormData, + GenerateFillScriptOptions, + PageDetail, } from "./abstractions/autofill.service"; import { AutoFillConstants, @@ -27,16 +29,6 @@ import { IdentityAutoFillConstants, } from "./autofill-constants"; -export interface GenerateFillScriptOptions { - skipUsernameOnlyFill: boolean; - onlyEmptyFields: boolean; - onlyVisibleFields: boolean; - fillNewPassword: boolean; - cipher: CipherView; - tabUrl: string; - defaultUriMatch: UriMatchType; -} - export default class AutofillService implements AutofillServiceInterface { constructor( private cipherService: CipherService, @@ -44,9 +36,44 @@ export default class AutofillService implements AutofillServiceInterface { private totpService: TotpService, private eventCollectionService: EventCollectionService, private logService: LogService, - private settingsService: SettingsService + private settingsService: SettingsService, + private userVerificationService: UserVerificationService ) {} + /** + * Injects the autofill scripts into the current tab and all frames + * found within the tab. Temporarily, will conditionally inject + * the refactor of the core autofill script if the feature flag + * is enabled. + * @param {chrome.runtime.MessageSender} sender + * @param {boolean} autofillV2 + * @returns {Promise} + */ + async injectAutofillScripts(sender: chrome.runtime.MessageSender, autofillV2 = false) { + const mainAutofillScript = autofillV2 ? `autofill-init.js` : "autofill.js"; + + const injectedScripts = [ + mainAutofillScript, + "autofiller.js", + "notificationBar.js", + "contextMenuHandler.js", + ]; + + for (const injectedScript of injectedScripts) { + await BrowserApi.executeScriptInTab(sender.tab.id, { + file: `content/${injectedScript}`, + frameId: sender.frameId, + runAt: "document_start", + }); + } + } + + /** + * Gets all forms with password fields and formats the data + * for both forms and password input elements. + * @param {AutofillPageDetails} pageDetails + * @returns {FormData[]} + */ getFormsWithPasswordFields(pageDetails: AutofillPageDetails): FormData[] { const formData: FormData[] = []; @@ -111,90 +138,93 @@ export default class AutofillService implements AutofillServiceInterface { } /** - * Autofills a given tab with a given login item - * @param options Instructions about the autofill operation, including tab and login item - * @returns The TOTP code of the successfully autofilled login, if any + * Autofill a given tab with a given login item + * @param {AutoFillOptions} options Instructions about the autofill operation, including tab and login item + * @returns {Promise} The TOTP code of the successfully autofilled login, if any */ - async doAutoFill(options: AutoFillOptions): Promise { + async doAutoFill(options: AutoFillOptions): Promise { const tab = options.tab; if (!tab || !options.cipher || !options.pageDetails || !options.pageDetails.length) { throw new Error("Nothing to auto-fill."); } - let totpPromise: Promise = null; + let totp: string | null = null; const canAccessPremium = await this.stateService.getCanAccessPremium(); const defaultUriMatch = (await this.stateService.getDefaultUriMatch()) ?? UriMatchType.Domain; let didAutofill = false; - options.pageDetails.forEach((pd) => { - // make sure we're still on correct tab - if (pd.tab.id !== tab.id || pd.tab.url !== tab.url) { - return; - } + await Promise.all( + options.pageDetails.map(async (pd) => { + // make sure we're still on correct tab + if (pd.tab.id !== tab.id || pd.tab.url !== tab.url) { + return; + } - const fillScript = this.generateFillScript(pd.details, { - skipUsernameOnlyFill: options.skipUsernameOnlyFill || false, - onlyEmptyFields: options.onlyEmptyFields || false, - onlyVisibleFields: options.onlyVisibleFields || false, - fillNewPassword: options.fillNewPassword || false, - cipher: options.cipher, - tabUrl: tab.url, - defaultUriMatch: defaultUriMatch, - }); + const fillScript = await this.generateFillScript(pd.details, { + skipUsernameOnlyFill: options.skipUsernameOnlyFill || false, + onlyEmptyFields: options.onlyEmptyFields || false, + onlyVisibleFields: options.onlyVisibleFields || false, + fillNewPassword: options.fillNewPassword || false, + allowTotpAutofill: options.allowTotpAutofill || false, + cipher: options.cipher, + tabUrl: tab.url, + defaultUriMatch: defaultUriMatch, + }); - if (!fillScript || !fillScript.script || !fillScript.script.length) { - return; - } + if (!fillScript || !fillScript.script || !fillScript.script.length) { + return; + } - if ( - fillScript.untrustedIframe && - options.allowUntrustedIframe != undefined && - !options.allowUntrustedIframe - ) { - this.logService.info("Auto-fill on page load was blocked due to an untrusted iframe."); - return; - } + if ( + fillScript.untrustedIframe && + options.allowUntrustedIframe != undefined && + !options.allowUntrustedIframe + ) { + this.logService.info("Auto-fill on page load was blocked due to an untrusted iframe."); + return; + } - // Add a small delay between operations - fillScript.properties.delay_between_operations = 20; + // Add a small delay between operations + fillScript.properties.delay_between_operations = 20; - didAutofill = true; - if (!options.skipLastUsed) { - this.cipherService.updateLastUsedDate(options.cipher.id); - } - - BrowserApi.tabSendMessage( - tab, - { - command: "fillForm", - fillScript: fillScript, - url: tab.url, - }, - { frameId: pd.frameId } - ); + didAutofill = true; + if (!options.skipLastUsed) { + this.cipherService.updateLastUsedDate(options.cipher.id); + } - if ( - options.cipher.type !== CipherType.Login || - totpPromise || - !options.cipher.login.totp || - (!canAccessPremium && !options.cipher.organizationUseTotp) - ) { - return; - } + BrowserApi.tabSendMessage( + tab, + { + command: "fillForm", + fillScript: fillScript, + url: tab.url, + }, + { frameId: pd.frameId } + ); - totpPromise = this.stateService.getDisableAutoTotpCopy().then((disabled) => { - if (!disabled) { - return this.totpService.getCode(options.cipher.login.totp); + if ( + options.cipher.type !== CipherType.Login || + totp !== null || + !options.cipher.login.totp || + (!canAccessPremium && !options.cipher.organizationUseTotp) + ) { + return; } - return null; - }); - }); + + totp = await this.stateService.getDisableAutoTotpCopy().then((disabled) => { + if (!disabled) { + return this.totpService.getCode(options.cipher.login.totp); + } + return null; + }); + }) + ); if (didAutofill) { this.eventCollectionService.collect(EventType.Cipher_ClientAutofilled, options.cipher.id); - if (totpPromise != null) { - return await totpPromise; + if (totp !== null) { + return totp; } else { return null; } @@ -204,17 +234,17 @@ export default class AutofillService implements AutofillServiceInterface { } /** - * Autofills the specified tab with the next login item from the cache - * @param pageDetails The data scraped from the page - * @param tab The tab to be autofilled - * @param fromCommand Whether the autofill is triggered by a keyboard shortcut (`true`) or autofill on page load (`false`) - * @returns The TOTP code of the successfully autofilled login, if any + * Autofill the specified tab with the next login item from the cache + * @param {PageDetail[]} pageDetails The data scraped from the page + * @param {chrome.tabs.Tab} tab The tab to be autofilled + * @param {boolean} fromCommand Whether the autofill is triggered by a keyboard shortcut (`true`) or autofill on page load (`false`) + * @returns {Promise} The TOTP code of the successfully autofilled login, if any */ async doAutoFillOnTab( pageDetails: PageDetail[], tab: chrome.tabs.Tab, fromCommand: boolean - ): Promise { + ): Promise { let cipher: CipherView; if (fromCommand) { cipher = await this.cipherService.getNextCipherForUrl(tab.url); @@ -230,7 +260,24 @@ export default class AutofillService implements AutofillServiceInterface { } } - if (cipher == null || cipher.reprompt !== CipherRepromptType.None) { + if (cipher == null || (cipher.reprompt === CipherRepromptType.Password && !fromCommand)) { + return null; + } + + if ( + cipher.reprompt === CipherRepromptType.Password && + // If the master password has is not available, reprompt will error + (await this.userVerificationService.hasMasterPasswordAndMasterKeyHash()) + ) { + if (fromCommand) { + this.cipherService.updateLastUsedIndexForUrl(tab.url); + } + + await BrowserApi.tabSendMessageData(tab, "passwordReprompt", { + cipherId: cipher.id, + action: "autofill", + }); + return null; } @@ -244,9 +291,10 @@ export default class AutofillService implements AutofillServiceInterface { onlyVisibleFields: !fromCommand, fillNewPassword: fromCommand, allowUntrustedIframe: fromCommand, + allowTotpAutofill: fromCommand, }); - // Update last used index as autofill has succeed + // Update last used index as autofill has succeeded if (fromCommand) { this.cipherService.updateLastUsedIndexForUrl(tab.url); } @@ -255,22 +303,59 @@ export default class AutofillService implements AutofillServiceInterface { } /** - * Autofills the active tab with the next login item from the cache - * @param pageDetails The data scraped from the page - * @param fromCommand Whether the autofill is triggered by a keyboard shortcut (`true`) or autofill on page load (`false`) - * @returns The TOTP code of the successfully autofilled login, if any + * Autofill the active tab with the next cipher from the cache + * @param {PageDetail[]} pageDetails The data scraped from the page + * @param {boolean} fromCommand Whether the autofill is triggered by a keyboard shortcut (`true`) or autofill on page load (`false`) + * @returns {Promise} The TOTP code of the successfully autofilled login, if any */ - async doAutoFillActiveTab(pageDetails: PageDetail[], fromCommand: boolean): Promise { + async doAutoFillActiveTab( + pageDetails: PageDetail[], + fromCommand: boolean, + cipherType?: CipherType + ): Promise { + if (!pageDetails[0]?.details?.fields?.length) { + return null; + } + const tab = await this.getActiveTab(); + if (!tab || !tab.url) { - return; + return null; } - return await this.doAutoFillOnTab(pageDetails, tab, fromCommand); - } + if (!cipherType || cipherType === CipherType.Login) { + return await this.doAutoFillOnTab(pageDetails, tab, fromCommand); + } + + // Cipher is a non-login type + const cipher: CipherView = ( + (await this.cipherService.getAllDecryptedForUrl(tab.url, [cipherType])) || [] + ).find(({ type }) => type === cipherType); - // Helpers + if (!cipher || cipher.reprompt !== CipherRepromptType.None) { + return null; + } + + return await this.doAutoFill({ + tab: tab, + cipher: cipher, + pageDetails: pageDetails, + skipLastUsed: !fromCommand, + skipUsernameOnlyFill: !fromCommand, + onlyEmptyFields: !fromCommand, + onlyVisibleFields: !fromCommand, + fillNewPassword: false, + allowUntrustedIframe: fromCommand, + allowTotpAutofill: false, + }); + } + /** + * Gets the active tab from the current window. + * Throws an error if no tab is found. + * @returns {Promise} + * @private + */ private async getActiveTab(): Promise { const tab = await BrowserApi.getTabFromCurrentWindow(); if (!tab) { @@ -280,15 +365,22 @@ export default class AutofillService implements AutofillServiceInterface { return tab; } - private generateFillScript( + /** + * Generates the autofill script for the specified page details and cipher. + * @param {AutofillPageDetails} pageDetails + * @param {GenerateFillScriptOptions} options + * @returns {Promise} + * @private + */ + private async generateFillScript( pageDetails: AutofillPageDetails, options: GenerateFillScriptOptions - ): AutofillScript { + ): Promise { if (!pageDetails || !options.cipher) { return null; } - let fillScript = new AutofillScript(pageDetails.documentUUID); + let fillScript = new AutofillScript(); const filledFields: { [id: string]: AutofillField } = {}; const fields = options.cipher.fields; @@ -333,7 +425,12 @@ export default class AutofillService implements AutofillServiceInterface { switch (options.cipher.type) { case CipherType.Login: - fillScript = this.generateLoginFillScript(fillScript, pageDetails, filledFields, options); + fillScript = await this.generateLoginFillScript( + fillScript, + pageDetails, + filledFields, + options + ); break; case CipherType.Card: fillScript = this.generateCardFillScript(fillScript, pageDetails, filledFields, options); @@ -353,20 +450,31 @@ export default class AutofillService implements AutofillServiceInterface { return fillScript; } - private generateLoginFillScript( + /** + * Generates the autofill script for the specified page details and login cipher item. + * @param {AutofillScript} fillScript + * @param {AutofillPageDetails} pageDetails + * @param {{[p: string]: AutofillField}} filledFields + * @param {GenerateFillScriptOptions} options + * @returns {Promise} + * @private + */ + private async generateLoginFillScript( fillScript: AutofillScript, pageDetails: AutofillPageDetails, filledFields: { [id: string]: AutofillField }, options: GenerateFillScriptOptions - ): AutofillScript { + ): Promise { if (!options.cipher.login) { return null; } const passwords: AutofillField[] = []; const usernames: AutofillField[] = []; + const totps: AutofillField[] = []; let pf: AutofillField = null; let username: AutofillField = null; + let totp: AutofillField = null; const login = options.cipher.login; fillScript.savedUrls = login?.uris?.filter((u) => u.match != UriMatchType.Never).map((u) => u.uri) ?? []; @@ -397,13 +505,6 @@ export default class AutofillService implements AutofillServiceInterface { continue; } - const passwordFieldsForForm: AutofillField[] = []; - passwordFields.forEach((passField) => { - if (formKey === passField.form) { - passwordFieldsForForm.push(passField); - } - }); - passwordFields.forEach((passField) => { pf = passField; passwords.push(pf); @@ -420,6 +521,19 @@ export default class AutofillService implements AutofillServiceInterface { usernames.push(username); } } + + if (options.allowTotpAutofill && login.totp) { + totp = this.findTotpField(pageDetails, pf, false, false, false); + + if (!totp && !options.onlyVisibleFields) { + // not able to find any viewable totp fields. maybe there are some "hidden" ones? + totp = this.findTotpField(pageDetails, pf, true, true, false); + } + + if (totp) { + totps.push(totp); + } + } }); } @@ -442,18 +556,42 @@ export default class AutofillService implements AutofillServiceInterface { usernames.push(username); } } + + if (options.allowTotpAutofill && login.totp && pf.elementNumber > 0) { + totp = this.findTotpField(pageDetails, pf, false, false, true); + + if (!totp && !options.onlyVisibleFields) { + // not able to find any viewable username fields. maybe there are some "hidden" ones? + totp = this.findTotpField(pageDetails, pf, true, true, true); + } + + if (totp) { + totps.push(totp); + } + } } - if (!passwordFields.length && !options.skipUsernameOnlyFill) { + if (!passwordFields.length) { // No password fields on this page. Let's try to just fuzzy fill the username. pageDetails.fields.forEach((f) => { if ( + !options.skipUsernameOnlyFill && f.viewable && (f.type === "text" || f.type === "email" || f.type === "tel") && AutofillService.fieldIsFuzzyMatch(f, AutoFillConstants.UsernameFieldNames) ) { usernames.push(f); } + + if ( + options.allowTotpAutofill && + f.viewable && + (f.type === "text" || f.type === "number") && + (AutofillService.fieldIsFuzzyMatch(f, AutoFillConstants.TotpFieldNames) || + f.autoCompleteType === "one-time-code") + ) { + totps.push(f); + } }); } @@ -477,16 +615,39 @@ export default class AutofillService implements AutofillServiceInterface { AutofillService.fillByOpid(fillScript, p, login.password); }); + if (options.allowTotpAutofill) { + await Promise.all( + totps.map(async (t) => { + if (Object.prototype.hasOwnProperty.call(filledFields, t.opid)) { + return; + } + + filledFields[t.opid] = t; + const totpValue = await this.totpService.getCode(login.totp); + AutofillService.fillByOpid(fillScript, t, totpValue); + }) + ); + } + fillScript = AutofillService.setFillScriptForFocus(filledFields, fillScript); return fillScript; } + /** + * Generates the autofill script for the specified page details and credit card cipher item. + * @param {AutofillScript} fillScript + * @param {AutofillPageDetails} pageDetails + * @param {{[p: string]: AutofillField}} filledFields + * @param {GenerateFillScriptOptions} options + * @returns {AutofillScript|null} + * @private + */ private generateCardFillScript( fillScript: AutofillScript, pageDetails: AutofillPageDetails, filledFields: { [id: string]: AutofillField }, options: GenerateFillScriptOptions - ): AutofillScript { + ): AutofillScript | null { if (!options.cipher.card) { return null; } @@ -678,6 +839,15 @@ export default class AutofillService implements AutofillServiceInterface { let exp: string = null; for (let i = 0; i < CreditCardAutoFillConstants.MonthAbbr.length; i++) { if ( + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.MonthAbbr[i] + + "/" + + CreditCardAutoFillConstants.YearAbbrLong[i] + ) + ) { + exp = fullMonth + "/" + fullYear; + } else if ( this.fieldAttrsContain( fillFields.exp, CreditCardAutoFillConstants.MonthAbbr[i] + @@ -690,12 +860,12 @@ export default class AutofillService implements AutofillServiceInterface { } else if ( this.fieldAttrsContain( fillFields.exp, - CreditCardAutoFillConstants.MonthAbbr[i] + + CreditCardAutoFillConstants.YearAbbrLong[i] + "/" + - CreditCardAutoFillConstants.YearAbbrLong[i] + CreditCardAutoFillConstants.MonthAbbr[i] ) ) { - exp = fullMonth + "/" + fullYear; + exp = fullYear + "/" + fullMonth; } else if ( this.fieldAttrsContain( fillFields.exp, @@ -709,12 +879,12 @@ export default class AutofillService implements AutofillServiceInterface { } else if ( this.fieldAttrsContain( fillFields.exp, - CreditCardAutoFillConstants.YearAbbrLong[i] + - "/" + - CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.MonthAbbr[i] + + "-" + + CreditCardAutoFillConstants.YearAbbrLong[i] ) ) { - exp = fullYear + "/" + fullMonth; + exp = fullMonth + "-" + fullYear; } else if ( this.fieldAttrsContain( fillFields.exp, @@ -728,12 +898,12 @@ export default class AutofillService implements AutofillServiceInterface { } else if ( this.fieldAttrsContain( fillFields.exp, - CreditCardAutoFillConstants.MonthAbbr[i] + + CreditCardAutoFillConstants.YearAbbrLong[i] + "-" + - CreditCardAutoFillConstants.YearAbbrLong[i] + CreditCardAutoFillConstants.MonthAbbr[i] ) ) { - exp = fullMonth + "-" + fullYear; + exp = fullYear + "-" + fullMonth; } else if ( this.fieldAttrsContain( fillFields.exp, @@ -747,12 +917,10 @@ export default class AutofillService implements AutofillServiceInterface { } else if ( this.fieldAttrsContain( fillFields.exp, - CreditCardAutoFillConstants.YearAbbrLong[i] + - "-" + - CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.YearAbbrLong[i] + CreditCardAutoFillConstants.MonthAbbr[i] ) ) { - exp = fullYear + "-" + fullMonth; + exp = fullYear + fullMonth; } else if ( this.fieldAttrsContain( fillFields.exp, @@ -764,10 +932,10 @@ export default class AutofillService implements AutofillServiceInterface { } else if ( this.fieldAttrsContain( fillFields.exp, - CreditCardAutoFillConstants.YearAbbrLong[i] + CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.YearAbbrLong[i] ) ) { - exp = fullYear + fullMonth; + exp = fullMonth + fullYear; } else if ( this.fieldAttrsContain( fillFields.exp, @@ -776,13 +944,6 @@ export default class AutofillService implements AutofillServiceInterface { partYear != null ) { exp = fullMonth + partYear; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.YearAbbrLong[i] - ) - ) { - exp = fullMonth + fullYear; } if (exp != null) { @@ -802,9 +963,10 @@ export default class AutofillService implements AutofillServiceInterface { /** * Determines whether an iframe is potentially dangerous ("untrusted") to autofill - * @param pageUrl The url of the page/iframe, usually from AutofillPageDetails - * @param options The GenerateFillScript options - * @returns `true` if the iframe is untrusted and a warning should be shown, `false` otherwise + * @param {string} pageUrl The url of the page/iframe, usually from AutofillPageDetails + * @param {GenerateFillScriptOptions} options The GenerateFillScript options + * @returns {boolean} `true` if the iframe is untrusted and a warning should be shown, `false` otherwise + * @private */ private inUntrustedIframe(pageUrl: string, options: GenerateFillScriptOptions): boolean { // If the pageUrl (from the content script) matches the tabUrl (from the sender tab), we are not in an iframe @@ -825,7 +987,15 @@ export default class AutofillService implements AutofillServiceInterface { return !matchesUri; } - private fieldAttrsContain(field: AutofillField, containsVal: string) { + /** + * Used when handling autofill on credit card fields. Determines whether + * the field has an attribute that matches the given value. + * @param {AutofillField} field + * @param {string} containsVal + * @returns {boolean} + * @private + */ + private fieldAttrsContain(field: AutofillField, containsVal: string): boolean { if (!field) { return false; } @@ -845,6 +1015,15 @@ export default class AutofillService implements AutofillServiceInterface { return doesContain; } + /** + * Generates the autofill script for the specified page details and identify cipher item. + * @param {AutofillScript} fillScript + * @param {AutofillPageDetails} pageDetails + * @param {{[p: string]: AutofillField}} filledFields + * @param {GenerateFillScriptOptions} options + * @returns {AutofillScript} + * @private + */ private generateIdentityFillScript( fillScript: AutofillScript, pageDetails: AutofillPageDetails, @@ -1079,10 +1258,29 @@ export default class AutofillService implements AutofillServiceInterface { return fillScript; } + /** + * Accepts an HTMLInputElement type value and a list of + * excluded types and returns true if the type is excluded. + * @param {string} type + * @param {string[]} excludedTypes + * @returns {boolean} + * @private + */ private isExcludedType(type: string, excludedTypes: string[]) { return excludedTypes.indexOf(type) > -1; } + /** + * Accepts the value of a field, a list of possible options that define if + * a field can be matched to a vault cipher, and a secondary optional list + * of options that define if a field can be matched to a vault cipher. Returns + * true if the field value matches one of the options. + * @param {string} value + * @param {string[]} options + * @param {string[]} containsOptions + * @returns {boolean} + * @private + */ private static isFieldMatch( value: string, options: string[], @@ -1104,6 +1302,17 @@ export default class AutofillService implements AutofillServiceInterface { return false; } + /** + * Helper method used to create a script action for a field. Conditionally + * accepts a fieldProp value that will be used in place of the dataProp value. + * @param {AutofillScript} fillScript + * @param cipherData + * @param {{[p: string]: AutofillField}} fillFields + * @param {{[p: string]: AutofillField}} filledFields + * @param {string} dataProp + * @param {string} fieldProp + * @private + */ private makeScriptAction( fillScript: AutofillScript, cipherData: any, @@ -1121,6 +1330,17 @@ export default class AutofillService implements AutofillServiceInterface { ); } + /** + * Handles updating the list of filled fields and adding a script action + * to the fill script. If a select field is passed as part of the fill options, + * we iterate over the options to check if the passed value matches one of the + * options. If it does, we add a script action to select the option. + * @param {AutofillScript} fillScript + * @param dataValue + * @param {AutofillField} field + * @param {{[p: string]: AutofillField}} filledFields + * @private + */ private makeScriptActionWithValue( fillScript: AutofillScript, dataValue: any, @@ -1160,6 +1380,16 @@ export default class AutofillService implements AutofillServiceInterface { } } + /** + * Accepts a pageDetails object with a list of fields and returns a list of + * fields that are likely to be password fields. + * @param {AutofillPageDetails} pageDetails + * @param {boolean} canBeHidden + * @param {boolean} canBeReadOnly + * @param {boolean} mustBeEmpty + * @param {boolean} fillNewPassword + * @returns {AutofillField[]} + */ static loadPasswordFields( pageDetails: AutofillPageDetails, canBeHidden: boolean, @@ -1221,13 +1451,24 @@ export default class AutofillService implements AutofillServiceInterface { return arr; } + /** + * Accepts a pageDetails object with a list of fields and returns a list of + * fields that are likely to be username fields. + * @param {AutofillPageDetails} pageDetails + * @param {AutofillField} passwordField + * @param {boolean} canBeHidden + * @param {boolean} canBeReadOnly + * @param {boolean} withoutForm + * @returns {AutofillField} + * @private + */ private findUsernameField( pageDetails: AutofillPageDetails, passwordField: AutofillField, canBeHidden: boolean, canBeReadOnly: boolean, withoutForm: boolean - ) { + ): AutofillField | null { let usernameField: AutofillField = null; for (let i = 0; i < pageDetails.fields.length; i++) { const f = pageDetails.fields[i]; @@ -1258,6 +1499,62 @@ export default class AutofillService implements AutofillServiceInterface { return usernameField; } + /** + * Accepts a pageDetails object with a list of fields and returns a list of + * fields that are likely to be TOTP fields. + * @param {AutofillPageDetails} pageDetails + * @param {AutofillField} passwordField + * @param {boolean} canBeHidden + * @param {boolean} canBeReadOnly + * @param {boolean} withoutForm + * @returns {AutofillField} + * @private + */ + private findTotpField( + pageDetails: AutofillPageDetails, + passwordField: AutofillField, + canBeHidden: boolean, + canBeReadOnly: boolean, + withoutForm: boolean + ): AutofillField | null { + let totpField: AutofillField = null; + for (let i = 0; i < pageDetails.fields.length; i++) { + const f = pageDetails.fields[i]; + if (AutofillService.forCustomFieldsOnly(f)) { + continue; + } + + if ( + !f.disabled && + (canBeReadOnly || !f.readonly) && + (withoutForm || f.form === passwordField.form) && + (canBeHidden || f.viewable) && + (f.type === "text" || f.type === "number") && + AutofillService.fieldIsFuzzyMatch(f, AutoFillConstants.TotpFieldNames) + ) { + totpField = f; + + if ( + this.findMatchingFieldIndex(f, AutoFillConstants.TotpFieldNames) > -1 || + f.autoCompleteType === "one-time-code" + ) { + // We found an exact match. No need to keep looking. + break; + } + } + } + + return totpField; + } + + /** + * Accepts a field and returns the index of the first matching property + * present in a list of attribute names. + * @param {AutofillField} field + * @param {string[]} names + * @returns {number} + * @private + */ private findMatchingFieldIndex(field: AutofillField, names: string[]): number { for (let i = 0; i < names.length; i++) { if (names[i].indexOf("=") > -1) { @@ -1267,6 +1564,12 @@ export default class AutofillService implements AutofillServiceInterface { if (this.fieldPropertyIsPrefixMatch(field, "htmlName", names[i], "name")) { return i; } + if (this.fieldPropertyIsPrefixMatch(field, "label-left", names[i], "label")) { + return i; + } + if (this.fieldPropertyIsPrefixMatch(field, "label-right", names[i], "label")) { + return i; + } if (this.fieldPropertyIsPrefixMatch(field, "label-tag", names[i], "label")) { return i; } @@ -1284,6 +1587,12 @@ export default class AutofillService implements AutofillServiceInterface { if (this.fieldPropertyIsMatch(field, "htmlName", names[i])) { return i; } + if (this.fieldPropertyIsMatch(field, "label-left", names[i])) { + return i; + } + if (this.fieldPropertyIsMatch(field, "label-right", names[i])) { + return i; + } if (this.fieldPropertyIsMatch(field, "label-tag", names[i])) { return i; } @@ -1298,6 +1607,17 @@ export default class AutofillService implements AutofillServiceInterface { return -1; } + /** + * Accepts a field, property, name, and prefix and returns true if the field + * contains a value that matches the given prefixed property. + * @param field + * @param {string} property + * @param {string} name + * @param {string} prefix + * @param {string} separator + * @returns {boolean} + * @private + */ private fieldPropertyIsPrefixMatch( field: any, property: string, @@ -1313,6 +1633,18 @@ export default class AutofillService implements AutofillServiceInterface { return false; } + /** + * Identifies if a given property within a field matches the value + * of the passed "name" parameter. If the name starts with "regex=", + * the value is tested against a case-insensitive regular expression. + * If the name starts with "csv=", the value is treated as a + * comma-separated list of values to match. + * @param field + * @param {string} property + * @param {string} name + * @returns {boolean} + * @private + */ private fieldPropertyIsMatch(field: any, property: string, name: string): boolean { let fieldVal = field[property] as string; if (!AutofillService.hasValue(fieldVal)) { @@ -1347,6 +1679,13 @@ export default class AutofillService implements AutofillServiceInterface { return fieldVal.toLowerCase() === name; } + /** + * Accepts a field and returns true if the field contains a + * value that matches any of the names in the provided list. + * @param {AutofillField} field + * @param {string[]} names + * @returns {boolean} + */ static fieldIsFuzzyMatch(field: AutofillField, names: string[]): boolean { if (AutofillService.hasValue(field.htmlID) && this.fuzzyMatch(names, field.htmlID)) { return true; @@ -1385,6 +1724,14 @@ export default class AutofillService implements AutofillServiceInterface { return false; } + /** + * Accepts a list of options and a value and returns + * true if the value matches any of the options. + * @param {string[]} options + * @param {string} value + * @returns {boolean} + * @private + */ private static fuzzyMatch(options: string[], value: string): boolean { if (options == null || options.length === 0 || value == null || value === "") { return false; @@ -1404,10 +1751,23 @@ export default class AutofillService implements AutofillServiceInterface { return false; } + /** + * Accepts a string and returns true if the + * string is not falsy and not empty. + * @param {string} str + * @returns {boolean} + */ static hasValue(str: string): boolean { - return str && str !== ""; + return Boolean(str && str !== ""); } + /** + * Sets the `focus_by_opid` autofill script + * action to the last field that was filled. + * @param {{[p: string]: AutofillField}} filledFields + * @param {AutofillScript} fillScript + * @returns {AutofillScript} + */ static setFillScriptForFocus( filledFields: { [id: string]: AutofillField }, fillScript: AutofillScript @@ -1436,6 +1796,13 @@ export default class AutofillService implements AutofillServiceInterface { return fillScript; } + /** + * Updates a fill script to place the `cilck_on_opid`, `focus_on_opid`, and `fill_by_opid` + * fill script actions associated with the provided field. + * @param {AutofillScript} fillScript + * @param {AutofillField} field + * @param {string} value + */ static fillByOpid(fillScript: AutofillScript, field: AutofillField, value: string): void { if (field.maxLength && value && value.length > field.maxLength) { value = value.substr(0, value.length); @@ -1447,6 +1814,12 @@ export default class AutofillService implements AutofillServiceInterface { fillScript.script.push(["fill_by_opid", field.opid, value]); } + /** + * Identifies if the field is a custom field, a custom + * field is defined as a field that is a `span` element. + * @param {AutofillField} field + * @returns {boolean} + */ static forCustomFieldsOnly(field: AutofillField): boolean { return field.tagName === "span"; } diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts new file mode 100644 index 00000000000..b21f530e572 --- /dev/null +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -0,0 +1,2345 @@ +import { mock } from "jest-mock-extended"; + +import AutofillField from "../models/autofill-field"; +import AutofillForm from "../models/autofill-form"; +import { + ElementWithOpId, + FillableFormFieldElement, + FormFieldElement, + FormElementWithAttribute, +} from "../types"; + +import CollectAutofillContentService from "./collect-autofill-content.service"; +import DomElementVisibilityService from "./dom-element-visibility.service"; + +const mockLoginForm = ` +
+
+ + +
+
+`; + +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", () => { + beforeEach(() => { + jest + .spyOn(collectAutofillContentService as any, "setupMutationObserver") + .mockImplementationOnce(() => { + collectAutofillContentService["mutationObserver"] = mock(); + }); + }); + + it("sets up the mutation observer the first time getPageDetails is called", async () => { + await collectAutofillContentService.getPageDetails(); + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["setupMutationObserver"]).toHaveBeenCalledTimes(1); + }); + + it("returns an object with empty forms and fields if no fields were found on a previous iteration", async () => { + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + jest.spyOn(collectAutofillContentService as any, "getFormattedPageDetails"); + jest.spyOn(collectAutofillContentService as any, "queryAutofillFormAndFieldElements"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData"); + + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["getFormattedPageDetails"]).toHaveBeenCalledWith({}, []); + expect( + collectAutofillContentService["queryAutofillFormAndFieldElements"] + ).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFormsData"]).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFieldsData"]).not.toHaveBeenCalled(); + }); + + it("returns an object with cached form and field data values", async () => { + 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.body.innerHTML = ` +
+ + + + +
+ `; + const formElement = document.getElementById(formId) as ElementWithOpId; + const autofillForm: AutofillForm = { + opid: "__form__0", + htmlAction: formAction, + htmlName: formName, + htmlID: formId, + htmlMethod: formMethod, + }; + const fieldElement = document.getElementById( + usernameFieldId + ) as ElementWithOpId; + const autofillField: AutofillField = { + 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, + }; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["autofillFormElements"] = new Map([ + [formElement, autofillForm], + ]); + collectAutofillContentService["autofillFieldElements"] = new Map([ + [fieldElement, autofillField], + ]); + jest.spyOn(collectAutofillContentService as any, "getFormattedPageDetails"); + jest.spyOn(collectAutofillContentService as any, "getFormattedAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "getFormattedAutofillFieldsData"); + jest.spyOn(collectAutofillContentService as any, "queryAutofillFormAndFieldElements"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData"); + + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["getFormattedPageDetails"]).toHaveBeenCalled(); + expect(collectAutofillContentService["getFormattedAutofillFormsData"]).toHaveBeenCalled(); + expect(collectAutofillContentService["getFormattedAutofillFieldsData"]).toHaveBeenCalled(); + expect( + collectAutofillContentService["queryAutofillFormAndFieldElements"] + ).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFormsData"]).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFieldsData"]).not.toHaveBeenCalled(); + }); + + it("returns an object containing information about the current 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), + }); + }); + + it("sets the noFieldsFond property to true if the page has no forms or fields", async function () { + document.body.innerHTML = ""; + collectAutofillContentService["noFieldsFound"] = false; + jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData"); + + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["buildAutofillFormsData"]).toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFieldsData"]).toHaveBeenCalled(); + expect(collectAutofillContentService["noFieldsFound"]).toBe(true); + }); + }); + + 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("will not attempt to gather data from a cached form element", () => { + const documentTitle = "Test Page"; + const formId = "validFormId"; + const formAction = "https://example.com/"; + const formMethod = "post"; + const formName = "validFormName"; + document.title = documentTitle; + document.body.innerHTML = ` +
+ + + + +
+ + `; + const formElement = document.getElementById(formId) as ElementWithOpId; + const existingAutofillForm: AutofillForm = { + opid: "__form__0", + htmlAction: formAction, + htmlName: formName, + htmlID: formId, + htmlMethod: formMethod, + }; + collectAutofillContentService["autofillFormElements"] = new Map([ + [formElement, existingAutofillForm], + ]); + const formElements = Array.from(document.querySelectorAll("form")); + jest.spyOn(collectAutofillContentService as any, "getFormActionAttribute"); + + const autofillFormsData = collectAutofillContentService["buildAutofillFormsData"]( + formElements as Node[] + ); + + expect(collectAutofillContentService["getFormActionAttribute"]).not.toHaveBeenCalled(); + expect(autofillFormsData).toStrictEqual({ __form__0: existingAutofillForm }); + }); + + 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 { formElements } = collectAutofillContentService["queryAutofillFormAndFieldElements"](); + const autofillFormsData = + collectAutofillContentService["buildAutofillFormsData"](formElements); + + 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 { formFieldElements } = + collectAutofillContentService["queryAutofillFormAndFieldElements"](); + const autofillFieldsPromise = collectAutofillContentService["buildAutofillFieldsData"]( + formFieldElements as FormFieldElement[] + ); + const autofillFieldsData = await Promise.resolve(autofillFieldsPromise); + + expect(collectAutofillContentService["getAutofillFieldElements"]).toHaveBeenCalledWith( + 100, + formFieldElements + ); + 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(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 an existing autofill field item if it exists", 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 = ` +
+ + +
+ `; + document.body.innerHTML = ` +
+ + +
+ `; + const existingFieldData: AutofillField = { + elementNumber: index, + htmlClass: usernameField.classes, + htmlID: usernameField.id, + htmlName: usernameField.name, + maxLength: usernameField.maxLength, + opid: `__${index}`, + tabindex: String(usernameField.tabIndex), + tagName: "input", + title: usernameField.title, + viewable: true, + }; + const usernameInput = document.getElementById( + usernameField.id + ) as ElementWithOpId; + usernameInput.opid = "__0"; + collectAutofillContentService["autofillFieldElements"].set(usernameInput, existingFieldData); + 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, + 0 + ); + + expect(collectAutofillContentService["getAutofillFieldMaxLength"]).not.toHaveBeenCalled(); + expect( + collectAutofillContentService["domElementVisibilityService"].isFormFieldViewable + ).not.toHaveBeenCalled(); + expect(collectAutofillContentService["getPropertyOrAttribute"]).not.toHaveBeenCalled(); + expect(collectAutofillContentService["getElementValue"]).not.toHaveBeenCalled(); + expect(autofillFieldItem).toEqual(existingFieldData); + }); + + 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']")); + }); + + it("removes any new lines generated for the query selector", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labels = collectAutofillContentService["queryElementLabels"](element); + + expect(labels).toEqual(document.querySelectorAll("label[for='username-id']")); + }); + }); + + 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: [] }); + }); + }); + + describe("getShadowRoot", () => { + it("returns null if the passed node is not an HTMLElement instance", () => { + const textNode = document.createTextNode("Hello, world!"); + const shadowRoot = collectAutofillContentService["getShadowRoot"](textNode); + + expect(shadowRoot).toEqual(null); + }); + + it("returns a value provided by Chrome's openOrClosedShadowRoot API", () => { + // eslint-disable-next-line + // @ts-ignore + globalThis.chrome.dom = { + openOrClosedShadowRoot: jest.fn(), + }; + const element = document.createElement("div"); + collectAutofillContentService["getShadowRoot"](element); + + // eslint-disable-next-line + // @ts-ignore + expect(chrome.dom.openOrClosedShadowRoot).toBeCalled(); + }); + }); + + describe("buildTreeWalkerNodesQueryResults", () => { + it("will recursively call itself if a shadowDOM element is found and will observe the element for mutations", () => { + collectAutofillContentService["mutationObserver"] = mock({ + observe: jest.fn(), + }); + jest.spyOn(collectAutofillContentService as any, "buildTreeWalkerNodesQueryResults"); + const shadowRoot = document.createElement("div"); + jest + .spyOn(collectAutofillContentService as any, "getShadowRoot") + .mockReturnValueOnce(shadowRoot); + const callbackFilter = jest.fn(); + + collectAutofillContentService["buildTreeWalkerNodesQueryResults"]( + document.body, + [], + callbackFilter, + true + ); + + expect(collectAutofillContentService["buildTreeWalkerNodesQueryResults"]).toBeCalledTimes(2); + expect(collectAutofillContentService["mutationObserver"].observe).toBeCalled(); + }); + + it("will not observe the shadowDOM element if required to skip", () => { + collectAutofillContentService["mutationObserver"] = mock({ + observe: jest.fn(), + }); + const shadowRoot = document.createElement("div"); + jest + .spyOn(collectAutofillContentService as any, "getShadowRoot") + .mockReturnValueOnce(shadowRoot); + const callbackFilter = jest.fn(); + + collectAutofillContentService["buildTreeWalkerNodesQueryResults"]( + document.body, + [], + callbackFilter, + false + ); + + expect(collectAutofillContentService["mutationObserver"].observe).not.toBeCalled(); + }); + }); + + describe("setupMutationObserver", () => { + it("sets up a mutation observer and observes the document element", () => { + jest.spyOn(MutationObserver.prototype, "observe"); + + collectAutofillContentService["setupMutationObserver"](); + + expect(collectAutofillContentService["mutationObserver"]).toBeInstanceOf(MutationObserver); + expect(collectAutofillContentService["mutationObserver"].observe).toBeCalled(); + }); + }); + + describe("handleMutationObserverMutation", () => { + it("will set the domRecentlyMutated value to true and the noFieldsFound value to false if a form or field node has been added ", () => { + const form = document.createElement("form"); + document.body.appendChild(form); + const addedNodes = document.querySelectorAll("form"); + const removedNodes = document.querySelectorAll("li"); + + const mutationRecord: MutationRecord = { + type: "childList", + addedNodes: addedNodes, + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: removedNodes, + target: document.body, + }; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + collectAutofillContentService["currentLocationHref"] = window.location.href; + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true); + expect(collectAutofillContentService["noFieldsFound"]).toEqual(false); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).toBeCalledWith( + removedNodes, + true + ); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).toBeCalledWith( + addedNodes + ); + }); + + it("will handle updating the autofill element if any attribute mutations are encountered", () => { + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "value", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.body, + }; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + collectAutofillContentService["currentLocationHref"] = window.location.href; + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(false); + expect(collectAutofillContentService["noFieldsFound"]).toEqual(true); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).not.toBeCalled(); + expect(collectAutofillContentService["handleAutofillElementAttributeMutation"]).toBeCalled(); + }); + + it("will handle window location mutations", () => { + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "value", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.body, + }; + collectAutofillContentService["currentLocationHref"] = "https://someotherurl.com"; + jest.spyOn(collectAutofillContentService as any, "handleWindowLocationMutation"); + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(collectAutofillContentService["handleWindowLocationMutation"]).toBeCalled(); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).not.toBeCalled(); + expect( + collectAutofillContentService["handleAutofillElementAttributeMutation"] + ).not.toBeCalled(); + }); + }); + + describe("deleteCachedAutofillElement", () => { + it("removes the autofill form element from the map of elements", () => { + const formElement = document.createElement("form") as ElementWithOpId; + const autofillForm: AutofillForm = { + opid: "1234", + htmlName: "formEl", + htmlID: "formEl-id", + htmlAction: "https://example.com", + htmlMethod: "POST", + }; + collectAutofillContentService["autofillFormElements"] = new Map([ + [formElement, autofillForm], + ]); + + collectAutofillContentService["deleteCachedAutofillElement"](formElement); + + expect(collectAutofillContentService["autofillFormElements"].size).toEqual(0); + }); + + it("removes the autofill field element form the map of elements", () => { + const fieldElement = document.createElement("input") as ElementWithOpId; + const autofillField: AutofillField = { + elementNumber: 0, + htmlClass: "", + tabindex: "", + title: "", + viewable: false, + opid: "1234", + htmlName: "username", + htmlID: "username-id", + htmlType: "text", + htmlAutocomplete: "username", + htmlAutofocus: false, + htmlDisabled: false, + htmlMaxLength: 999, + htmlReadonly: false, + htmlRequired: false, + htmlValue: "jsmith", + }; + collectAutofillContentService["autofillFieldElements"] = new Map([ + [fieldElement, autofillField], + ]); + + collectAutofillContentService["deleteCachedAutofillElement"](fieldElement); + + expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0); + }); + }); + + describe("handleWindowLocationMutation", () => { + it("will set the current location to the global location href, set the dom recently mutated flag and the no fields found flag, clear out the autofill form and field maps, and update the autofill elements after mutation", () => { + collectAutofillContentService["currentLocationHref"] = "https://example.com/login"; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + jest.spyOn(collectAutofillContentService as any, "updateAutofillElementsAfterMutation"); + + collectAutofillContentService["handleWindowLocationMutation"](); + + expect(collectAutofillContentService["currentLocationHref"]).toEqual(window.location.href); + expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true); + expect(collectAutofillContentService["noFieldsFound"]).toEqual(false); + expect(collectAutofillContentService["updateAutofillElementsAfterMutation"]).toBeCalled(); + expect(collectAutofillContentService["autofillFormElements"].size).toEqual(0); + expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0); + }); + }); + + describe("handleAutofillElementAttributeMutation", () => { + it("returns early if the target node is not an HTMLElement instance", () => { + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "value", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.createTextNode("Hello, world!"), + }; + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + + collectAutofillContentService["handleAutofillElementAttributeMutation"](mutationRecord); + + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).not.toBeCalled(); + }); + + it("will update the autofill form element data if the target node can be found in the autofillFormElements map", () => { + const targetNode = document.createElement("form") as ElementWithOpId; + targetNode.setAttribute("name", "username"); + targetNode.setAttribute("value", "jsmith"); + const autofillForm: AutofillForm = { + opid: "1234", + htmlName: "formEl", + htmlID: "formEl-id", + htmlAction: "https://example.com", + htmlMethod: "POST", + }; + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "id", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: targetNode, + }; + collectAutofillContentService["autofillFormElements"] = new Map([[targetNode, autofillForm]]); + jest.spyOn(collectAutofillContentService as any, "updateAutofillFormElementData"); + + collectAutofillContentService["handleAutofillElementAttributeMutation"](mutationRecord); + + expect(collectAutofillContentService["updateAutofillFormElementData"]).toBeCalledWith( + mutationRecord.attributeName, + mutationRecord.target, + autofillForm + ); + }); + + it("will update the autofill field element data if the target node can be found in the autofillFieldElements map", () => { + const targetNode = document.createElement("input") as ElementWithOpId; + targetNode.setAttribute("name", "username"); + targetNode.setAttribute("value", "jsmith"); + const autofillField: AutofillField = { + elementNumber: 0, + htmlClass: "", + tabindex: "", + title: "", + viewable: false, + opid: "1234", + htmlName: "username", + htmlID: "username-id", + htmlType: "text", + htmlAutocomplete: "username", + htmlAutofocus: false, + htmlDisabled: false, + htmlMaxLength: 999, + htmlReadonly: false, + htmlRequired: false, + htmlValue: "jsmith", + }; + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "id", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: targetNode, + }; + collectAutofillContentService["autofillFieldElements"] = new Map([ + [targetNode, autofillField], + ]); + jest.spyOn(collectAutofillContentService as any, "updateAutofillFieldElementData"); + + collectAutofillContentService["handleAutofillElementAttributeMutation"](mutationRecord); + + expect(collectAutofillContentService["updateAutofillFieldElementData"]).toBeCalledWith( + mutationRecord.attributeName, + mutationRecord.target, + autofillField + ); + }); + }); + + describe("updateAutofillFormElementData", () => { + const formElement = document.createElement("form") as ElementWithOpId; + const autofillForm: AutofillForm = { + opid: "1234", + htmlName: "formEl", + htmlID: "formEl-id", + htmlAction: "https://example.com", + htmlMethod: "POST", + }; + const updatedAttributes = ["action", "name", "id", "method"]; + + updatedAttributes.forEach((attribute) => { + it(`will update the ${attribute} value for the form element`, () => { + jest.spyOn(collectAutofillContentService["autofillFormElements"], "set"); + + collectAutofillContentService["updateAutofillFormElementData"]( + attribute, + formElement, + autofillForm + ); + + expect(collectAutofillContentService["autofillFormElements"].set).toBeCalledWith( + formElement, + autofillForm + ); + }); + }); + + it("will not update an attribute value if it is not present in the updateActions object", () => { + jest.spyOn(collectAutofillContentService["autofillFormElements"], "set"); + + collectAutofillContentService["updateAutofillFormElementData"]( + "aria-label", + formElement, + autofillForm + ); + + expect(collectAutofillContentService["autofillFormElements"].set).not.toBeCalled(); + }); + }); + + describe("updateAutofillFieldElementData", () => { + const fieldElement = document.createElement("input") as ElementWithOpId; + const autofillField: AutofillField = { + htmlClass: "value", + htmlID: "", + htmlName: "", + opid: "", + tabindex: "", + title: "", + viewable: false, + elementNumber: 0, + }; + const updatedAttributes = [ + "maxlength", + "name", + "id", + "type", + "autocomplete", + "class", + "tabindex", + "title", + "value", + "rel", + "tagname", + "checked", + "disabled", + "readonly", + "data-label", + "aria-label", + "aria-hidden", + "aria-disabled", + "aria-haspopup", + "data-stripe", + ]; + + updatedAttributes.forEach((attribute) => { + it(`will update the ${attribute} value for the field element`, async () => { + jest.spyOn(collectAutofillContentService["autofillFieldElements"], "set"); + + await collectAutofillContentService["updateAutofillFieldElementData"]( + attribute, + fieldElement, + autofillField + ); + + expect(collectAutofillContentService["autofillFieldElements"].set).toBeCalledWith( + fieldElement, + autofillField + ); + }); + }); + + it("will check the dom element's visibility if the `style` or `class` attribute has updated ", async () => { + jest.spyOn( + collectAutofillContentService["domElementVisibilityService"], + "isFormFieldViewable" + ); + const attributes = ["class", "style"]; + + for (const attribute of attributes) { + await collectAutofillContentService["updateAutofillFieldElementData"]( + attribute, + fieldElement, + autofillField + ); + + expect( + collectAutofillContentService["domElementVisibilityService"].isFormFieldViewable + ).toBeCalledWith(fieldElement); + } + }); + + it("will not update an attribute value if it is not present in the updateActions object", async () => { + jest.spyOn(collectAutofillContentService["autofillFieldElements"], "set"); + + await collectAutofillContentService["updateAutofillFieldElementData"]( + "random-attribute", + fieldElement, + autofillField + ); + + expect(collectAutofillContentService["autofillFieldElements"].set).not.toBeCalled(); + }); + }); +}); 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..4780c294ab1 --- /dev/null +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -0,0 +1,1199 @@ +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 { + UpdateAutofillDataAttributeParams, + AutofillFieldElements, + AutofillFormElements, + CollectAutofillContentService as CollectAutofillContentServiceInterface, +} from "./abstractions/collect-autofill-content.service"; +import DomElementVisibilityService from "./dom-element-visibility.service"; + +class CollectAutofillContentService implements CollectAutofillContentServiceInterface { + private readonly domElementVisibilityService: DomElementVisibilityService; + private noFieldsFound = false; + private domRecentlyMutated = true; + private autofillFormElements: AutofillFormElements = new Map(); + private autofillFieldElements: AutofillFieldElements = new Map(); + private currentLocationHref = ""; + private mutationObserver: MutationObserver; + private updateAutofillElementsAfterMutationTimeout: NodeJS.Timeout; + private readonly updateAfterMutationTimeoutDelay = 1000; + + constructor(domElementVisibilityService: DomElementVisibilityService) { + this.domElementVisibilityService = domElementVisibilityService; + } + + /** + * Builds the data for all forms and fields found within the page DOM. + * Sets up a mutation observer to verify DOM changes and returns early + * with cached data if no changes are detected. + * @returns {Promise} + * @public + */ + async getPageDetails(): Promise { + if (!this.mutationObserver) { + this.setupMutationObserver(); + } + + if (!this.domRecentlyMutated && this.noFieldsFound) { + return this.getFormattedPageDetails({}, []); + } + + if ( + !this.domRecentlyMutated && + this.autofillFormElements.size && + this.autofillFieldElements.size + ) { + return this.getFormattedPageDetails( + this.getFormattedAutofillFormsData(), + this.getFormattedAutofillFieldsData() + ); + } + + const { formElements, formFieldElements } = this.queryAutofillFormAndFieldElements(); + const autofillFormsData: Record = + this.buildAutofillFormsData(formElements); + const autofillFieldsData: AutofillField[] = await this.buildAutofillFieldsData( + formFieldElements as FormFieldElement[] + ); + this.sortAutofillFieldElementsMap(); + + if (!Object.values(autofillFormsData).length || !autofillFieldsData.length) { + this.noFieldsFound = true; + } + + this.domRecentlyMutated = false; + return this.getFormattedPageDetails(autofillFormsData, autofillFieldsData); + } + + /** + * 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 cachedFormFieldElements = Array.from(this.autofillFieldElements.keys()); + const formFieldElements = cachedFormFieldElements?.length + ? cachedFormFieldElements + : this.getAutofillFieldElements(); + const fieldElementsWithOpid = formFieldElements.filter( + (fieldElement) => (fieldElement as ElementWithOpId).opid === opid + ) as ElementWithOpId[]; + + if (!fieldElementsWithOpid.length) { + const elementIndex = parseInt(opid.split("__")[1], 10); + + return formFieldElements[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 nodes that match the given filter callback + * and returns a collection of nodes. + * @param {Node} rootNode + * @param {Function} filterCallback + * @param {boolean} isObservingShadowRoot + * @returns {Node[]} + */ + queryAllTreeWalkerNodes( + rootNode: Node, + filterCallback: CallableFunction, + isObservingShadowRoot = true + ): Node[] { + const treeWalkerQueryResults: Node[] = []; + + this.buildTreeWalkerNodesQueryResults( + rootNode, + treeWalkerQueryResults, + filterCallback, + isObservingShadowRoot + ); + + return treeWalkerQueryResults; + } + + /** + * Sorts the AutofillFieldElements map by the elementNumber property. + * @private + */ + private sortAutofillFieldElementsMap() { + if (!this.autofillFieldElements.size) { + return; + } + + this.autofillFieldElements = new Map( + [...this.autofillFieldElements].sort((a, b) => a[1].elementNumber - b[1].elementNumber) + ); + } + + /** + * Formats and returns the AutofillPageDetails object + * @param {Record} autofillFormsData + * @param {AutofillField[]} autofillFieldsData + * @returns {AutofillPageDetails} + * @private + */ + private getFormattedPageDetails( + autofillFormsData: Record, + autofillFieldsData: AutofillField[] + ): AutofillPageDetails { + return { + title: document.title, + url: (document.defaultView || window).location.href, + documentUrl: document.location.href, + forms: autofillFormsData, + fields: autofillFieldsData, + collectedTimestamp: Date.now(), + }; + } + + /** + * Queries the DOM for all the forms elements and + * returns a collection of AutofillForm objects. + * @returns {Record} + * @private + */ + private buildAutofillFormsData(formElements: Node[]): Record { + for (let index = 0; index < formElements.length; index++) { + const formElement = formElements[index] as ElementWithOpId; + formElement.opid = `__form__${index}`; + + const existingAutofillForm = this.autofillFormElements.get(formElement); + if (existingAutofillForm) { + existingAutofillForm.opid = formElement.opid; + this.autofillFormElements.set(formElement, existingAutofillForm); + continue; + } + + this.autofillFormElements.set(formElement, { + opid: formElement.opid, + htmlAction: this.getFormActionAttribute(formElement), + htmlName: this.getPropertyOrAttribute(formElement, "name"), + htmlID: this.getPropertyOrAttribute(formElement, "id"), + htmlMethod: this.getPropertyOrAttribute(formElement, "method"), + }); + } + + return this.getFormattedAutofillFormsData(); + } + + /** + * Returns the action attribute of the form element. If the action attribute + * is a relative path, it will be converted to an absolute path. + * @param {ElementWithOpId} element + * @returns {string} + * @private + */ + private getFormActionAttribute(element: ElementWithOpId): string { + return new URL(this.getPropertyOrAttribute(element, "action"), window.location.href).href; + } + + /** + * Iterates over all known form elements and returns an AutofillForm object + * containing a key value pair of the form element's opid and the form data. + * @returns {Record} + * @private + */ + private getFormattedAutofillFormsData(): Record { + const autofillForms: Record = {}; + const autofillFormElements = Array.from(this.autofillFormElements); + for (let index = 0; index < autofillFormElements.length; index++) { + const [formElement, autofillForm] = autofillFormElements[index]; + autofillForms[formElement.opid] = autofillForm; + } + + return autofillForms; + } + + /** + * Queries the DOM for all the field elements and + * returns a list of AutofillField objects. + * @returns {Promise} + * @private + */ + private async buildAutofillFieldsData( + formFieldElements: FormFieldElement[] + ): Promise { + const autofillFieldElements = this.getAutofillFieldElements(100, formFieldElements); + 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 + * @param {FormFieldElement[]} previouslyFoundFormFieldElements - The list of all the field elements + * @returns {FormFieldElement[]} + * @private + */ + private getAutofillFieldElements( + fieldsLimit?: number, + previouslyFoundFormFieldElements?: FormFieldElement[] + ): FormFieldElement[] { + const formFieldElements = + previouslyFoundFormFieldElements || + (this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => + this.isNodeFormFieldElement(node) + ) as FormFieldElement[]); + + 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 existingAutofillField = this.autofillFieldElements.get(element); + if (existingAutofillField) { + existingAutofillField.opid = element.opid; + existingAutofillField.elementNumber = index; + this.autofillFieldElements.set(element, existingAutofillField); + + return existingAutofillField; + } + + 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.getAttributeLowerCase(element, "tagName"), + }; + + if (element instanceof HTMLSpanElement) { + this.autofillFieldElements.set(element, autofillFieldBase); + return autofillFieldBase; + } + + let autofillFieldLabels = {}; + const elementType = this.getAttributeLowerCase(element, "type"); + 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"), + }; + } + + const autofillField = { + ...autofillFieldBase, + ...autofillFieldLabels, + rel: this.getPropertyOrAttribute(element, "rel"), + type: elementType, + value: this.getElementValue(element), + checked: this.getAttributeBoolean(element, "checked"), + autoCompleteType: this.getAutoCompleteAttribute(element), + disabled: this.getAttributeBoolean(element, "disabled"), + readonly: this.getAttributeBoolean(element, "readonly"), + selectInfo: + element instanceof HTMLSelectElement ? this.getSelectElementOptions(element) : null, + form: element.form ? this.getPropertyOrAttribute(element.form, "opid") : null, + "aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true), + "aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true), + "aria-haspopup": this.getAttributeBoolean(element, "aria-haspopup", true), + "data-stripe": this.getPropertyOrAttribute(element, "data-stripe"), + }; + + this.autofillFieldElements.set(element, autofillField); + return autofillField; + }; + + /** + * Identifies the autocomplete attribute associated with an element and returns + * the value of the attribute if it is not set to "off". + * @param {ElementWithOpId} element + * @returns {string} + * @private + */ + private getAutoCompleteAttribute(element: ElementWithOpId): string { + const autoCompleteType = + this.getPropertyOrAttribute(element, "x-autocompletetype") || + this.getPropertyOrAttribute(element, "autocompletetype") || + this.getPropertyOrAttribute(element, "autocomplete"); + return autoCompleteType !== "off" ? autoCompleteType : null; + } + + /** + * Returns a boolean representing the attribute value of an element. + * @param {ElementWithOpId} element + * @param {string} attributeName + * @param {boolean} checkString + * @returns {boolean} + * @private + */ + private getAttributeBoolean( + element: ElementWithOpId, + attributeName: string, + checkString = false + ): boolean { + if (checkString) { + return this.getPropertyOrAttribute(element, attributeName) === "true"; + } + + return Boolean(this.getPropertyOrAttribute(element, attributeName)); + } + + /** + * Returns the attribute of an element as a lowercase value. + * @param {ElementWithOpId} element + * @param {string} attributeName + * @returns {string} + * @private + */ + private getAttributeLowerCase( + element: ElementWithOpId, + attributeName: string + ): string { + return this.getPropertyOrAttribute(element, attributeName)?.toLowerCase(); + } + + /** + * Returns the value of an element's property or attribute. + * @returns {AutofillField[]} + * @private + */ + private getFormattedAutofillFieldsData(): AutofillField[] { + return Array.from(this.autofillFieldElements.values()); + } + + /** + * 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); + for (let labelIndex = 0; labelIndex < labelElements?.length; labelIndex++) { + labelElementsSet.add(labelElements[labelIndex]); + } + + 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 (element.getRootNode() as Document | ShadowRoot).querySelectorAll( + labelQuerySelectors.replace(/\n/g, "") + ); + } + + /** + * 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 Array.from(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 = Array.from(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 }; + } + + /** + * Queries all potential form and field elements from the DOM and returns + * a collection of form and field elements. Leverages the TreeWalker API + * to deep query Shadow DOM elements. + * @returns {{formElements: Node[], formFieldElements: Node[]}} + * @private + */ + private queryAutofillFormAndFieldElements(): { + formElements: Node[]; + formFieldElements: Node[]; + } { + const formElements: Node[] = []; + const formFieldElements: Node[] = []; + this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => { + if (node instanceof HTMLFormElement) { + formElements.push(node); + return true; + } + + if (this.isNodeFormFieldElement(node)) { + formFieldElements.push(node); + return true; + } + + return false; + }); + + return { formElements, formFieldElements }; + } + + /** + * Checks if the passed node is a form field element. + * @param {Node} node + * @returns {boolean} + * @private + */ + private isNodeFormFieldElement(node: Node): boolean { + const nodeIsSpanElementWithAutofillAttribute = + node instanceof HTMLSpanElement && node.hasAttribute("data-bwautofill"); + + const ignoredInputTypes = new Set(["hidden", "submit", "reset", "button", "image", "file"]); + const nodeIsValidInputElement = + node instanceof HTMLInputElement && !ignoredInputTypes.has(node.type); + + const nodeIsTextAreaOrSelectElement = + node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement; + + const nodeIsNonIgnoredFillableControlElement = + (nodeIsTextAreaOrSelectElement || nodeIsValidInputElement) && + !node.hasAttribute("data-bwignore"); + + return nodeIsSpanElementWithAutofillAttribute || nodeIsNonIgnoredFillableControlElement; + } + + /** + * Attempts to get the ShadowRoot of the passed node. If support for the + * extension based openOrClosedShadowRoot API is available, it will be used. + * @param {Node} node + * @returns {ShadowRoot | null} + * @private + */ + private getShadowRoot(node: Node): ShadowRoot | null { + if (!(node instanceof HTMLElement)) { + return null; + } + + if ((chrome as any).dom?.openOrClosedShadowRoot) { + return (chrome as any).dom.openOrClosedShadowRoot(node); + } + + return (node as any).openOrClosedShadowRoot || node.shadowRoot; + } + + /** + * Recursively builds a collection of nodes that match the given filter callback. + * If a node has a ShadowRoot, it will be observed for mutations. + * @param {Node} rootNode + * @param {Node[]} treeWalkerQueryResults + * @param {Function} filterCallback + * @param {boolean} isObservingShadowRoot + * @private + */ + private buildTreeWalkerNodesQueryResults( + rootNode: Node, + treeWalkerQueryResults: Node[], + filterCallback: CallableFunction, + isObservingShadowRoot: boolean + ) { + const treeWalker = document?.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT); + let currentNode = treeWalker?.currentNode; + + while (currentNode) { + if (filterCallback(currentNode)) { + treeWalkerQueryResults.push(currentNode); + } + + const nodeShadowRoot = this.getShadowRoot(currentNode); + if (nodeShadowRoot) { + if (isObservingShadowRoot) { + this.mutationObserver.observe(nodeShadowRoot, { + attributes: true, + childList: true, + subtree: true, + }); + } + + this.buildTreeWalkerNodesQueryResults( + nodeShadowRoot, + treeWalkerQueryResults, + filterCallback, + isObservingShadowRoot + ); + } + + currentNode = treeWalker?.nextNode(); + } + } + + /** + * Sets up a mutation observer on the body of the document. Observes changes to + * DOM elements to ensure we have an updated set of autofill field data. + * @private + */ + private setupMutationObserver() { + this.currentLocationHref = globalThis.location.href; + this.mutationObserver = new MutationObserver(this.handleMutationObserverMutation); + this.mutationObserver.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + }); + } + + /** + * Handles observed DOM mutations and identifies if a mutation is related to + * an autofill element. If so, it will update the autofill element data. + * @param {MutationRecord[]} mutations + * @private + */ + private handleMutationObserverMutation = (mutations: MutationRecord[]) => { + if (this.currentLocationHref !== globalThis.location.href) { + this.handleWindowLocationMutation(); + + return; + } + + for (let mutationsIndex = 0; mutationsIndex < mutations.length; mutationsIndex++) { + const mutation = mutations[mutationsIndex]; + if ( + mutation.type === "childList" && + (this.isAutofillElementNodeMutated(mutation.removedNodes, true) || + this.isAutofillElementNodeMutated(mutation.addedNodes)) + ) { + this.domRecentlyMutated = true; + this.noFieldsFound = false; + continue; + } + + if (mutation.type === "attributes") { + this.handleAutofillElementAttributeMutation(mutation); + } + } + + if (this.domRecentlyMutated) { + this.updateAutofillElementsAfterMutation(); + } + }; + + /** + * Handles a mutation to the window location. Clears the autofill elements + * and updates the autofill elements after a timeout. + * @private + */ + private handleWindowLocationMutation() { + this.currentLocationHref = globalThis.location.href; + + this.domRecentlyMutated = true; + this.noFieldsFound = false; + + this.autofillFormElements.clear(); + this.autofillFieldElements.clear(); + + this.updateAutofillElementsAfterMutation(); + } + + /** + * Checks if the passed nodes either contain or are autofill elements. + * @param {NodeList} nodes + * @param {boolean} isRemovingNodes + * @returns {boolean} + * @private + */ + private isAutofillElementNodeMutated(nodes: NodeList, isRemovingNodes = false): boolean { + if (!nodes.length) { + return false; + } + + let isElementMutated = false; + const mutatedElements = []; + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index]; + if (!(node instanceof HTMLElement)) { + continue; + } + + if (node instanceof HTMLFormElement || this.isNodeFormFieldElement(node)) { + isElementMutated = true; + mutatedElements.push(node); + continue; + } + + const childNodes = this.queryAllTreeWalkerNodes( + node, + (node: Node) => node instanceof HTMLFormElement || this.isNodeFormFieldElement(node) + ) as HTMLElement[]; + if (childNodes.length) { + isElementMutated = true; + mutatedElements.push(...childNodes); + } + } + + if (isRemovingNodes) { + for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) { + this.deleteCachedAutofillElement( + mutatedElements[elementIndex] as + | ElementWithOpId + | ElementWithOpId + ); + } + } + + return isElementMutated; + } + + /** + * Deletes any cached autofill elements that have been + * removed from the DOM. + * @param {ElementWithOpId | ElementWithOpId} element + * @private + */ + private deleteCachedAutofillElement( + element: ElementWithOpId | ElementWithOpId + ) { + if (element instanceof HTMLFormElement && this.autofillFormElements.has(element)) { + this.autofillFormElements.delete(element); + return; + } + + if (this.autofillFieldElements.has(element)) { + this.autofillFieldElements.delete(element); + } + } + + /** + * Updates the autofill elements after a DOM mutation has occurred. + * Is debounced to prevent excessive updates. + * @private + */ + private updateAutofillElementsAfterMutation() { + if (this.updateAutofillElementsAfterMutationTimeout) { + clearTimeout(this.updateAutofillElementsAfterMutationTimeout); + } + + this.updateAutofillElementsAfterMutationTimeout = setTimeout( + this.getPageDetails.bind(this), + this.updateAfterMutationTimeoutDelay + ); + } + + /** + * Handles observed DOM mutations related to an autofill element attribute. + * @param {MutationRecord} mutation + * @private + */ + private handleAutofillElementAttributeMutation(mutation: MutationRecord) { + const targetElement = mutation.target; + if (!(targetElement instanceof HTMLElement)) { + return; + } + + const attributeName = mutation.attributeName?.toLowerCase(); + const autofillForm = this.autofillFormElements.get( + targetElement as ElementWithOpId + ); + + if (autofillForm) { + this.updateAutofillFormElementData( + attributeName, + targetElement as ElementWithOpId, + autofillForm + ); + + return; + } + + const autofillField = this.autofillFieldElements.get( + targetElement as ElementWithOpId + ); + if (!autofillField) { + return; + } + + this.updateAutofillFieldElementData( + attributeName, + targetElement as ElementWithOpId, + autofillField + ); + } + + /** + * Updates the autofill form element data based on the passed attribute name. + * @param {string} attributeName + * @param {ElementWithOpId} element + * @param {AutofillForm} dataTarget + * @private + */ + private updateAutofillFormElementData( + attributeName: string, + element: ElementWithOpId, + dataTarget: AutofillForm + ) { + const updateAttribute = (dataTargetKey: string) => { + this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey }); + }; + const updateActions: Record = { + action: () => (dataTarget.htmlAction = this.getFormActionAttribute(element)), + name: () => updateAttribute("htmlName"), + id: () => updateAttribute("htmlID"), + method: () => updateAttribute("htmlMethod"), + }; + + if (!updateActions[attributeName]) { + return; + } + + updateActions[attributeName](); + this.autofillFormElements.set(element, dataTarget); + } + + /** + * Updates the autofill field element data based on the passed attribute name. + * @param {string} attributeName + * @param {ElementWithOpId} element + * @param {AutofillField} dataTarget + * @returns {Promise} + * @private + */ + private async updateAutofillFieldElementData( + attributeName: string, + element: ElementWithOpId, + dataTarget: AutofillField + ) { + const updateAttribute = (dataTargetKey: string) => { + this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey }); + }; + const updateActions: Record = { + maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)), + id: () => updateAttribute("htmlID"), + name: () => updateAttribute("htmlName"), + class: () => updateAttribute("htmlClass"), + tabindex: () => updateAttribute("tabindex"), + title: () => updateAttribute("tabindex"), + rel: () => updateAttribute("rel"), + tagname: () => (dataTarget.tagName = this.getAttributeLowerCase(element, "tagName")), + type: () => (dataTarget.type = this.getAttributeLowerCase(element, "type")), + value: () => (dataTarget.value = this.getElementValue(element)), + checked: () => (dataTarget.checked = this.getAttributeBoolean(element, "checked")), + disabled: () => (dataTarget.disabled = this.getAttributeBoolean(element, "disabled")), + readonly: () => (dataTarget.readonly = this.getAttributeBoolean(element, "readonly")), + autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + "data-label": () => updateAttribute("label-data"), + "aria-label": () => updateAttribute("label-aria"), + "aria-hidden": () => + (dataTarget["aria-hidden"] = this.getAttributeBoolean(element, "aria-hidden", true)), + "aria-disabled": () => + (dataTarget["aria-disabled"] = this.getAttributeBoolean(element, "aria-disabled", true)), + "aria-haspopup": () => + (dataTarget["aria-haspopup"] = this.getAttributeBoolean(element, "aria-haspopup", true)), + "data-stripe": () => updateAttribute("data-stripe"), + }; + + if (!updateActions[attributeName]) { + return; + } + + updateActions[attributeName](); + + const visibilityAttributesSet = new Set(["class", "style"]); + if ( + visibilityAttributesSet.has(attributeName) && + !dataTarget.htmlClass?.includes("com-bitwarden-browser-animated-fill") + ) { + dataTarget.viewable = await this.domElementVisibilityService.isFormFieldViewable(element); + } + + this.autofillFieldElements.set(element, dataTarget); + } + + /** + * Gets the attribute value for the passed element, and returns it. If the dataTarget + * and dataTargetKey are passed, it will set the value of the dataTarget[dataTargetKey]. + * @param UpdateAutofillDataAttributeParams + * @returns {string} + * @private + */ + private updateAutofillDataAttribute({ + element, + attributeName, + dataTarget, + dataTargetKey, + }: UpdateAutofillDataAttributeParams) { + const attributeValue = this.getPropertyOrAttribute(element, attributeName); + if (dataTarget && dataTargetKey) { + dataTarget[dataTargetKey] = attributeValue; + } + + return attributeValue; + } +} + +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..2797ee0eb3d --- /dev/null +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.ts @@ -0,0 +1,201 @@ +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 elementRootNode = targetElement.getRootNode(); + const rootElement = + elementRootNode instanceof ShadowRoot ? elementRootNode : targetElement.ownerDocument; + const elementAtCenterPoint = rootElement.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..0ab74875fbf --- /dev/null +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.spec.ts @@ -0,0 +1,1081 @@ +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, "tabURLChanged"); + 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["tabURLChanged"]).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, "tabURLChanged"); + 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["tabURLChanged"]).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, "tabURLChanged"); + jest.spyOn(insertAutofillContentService as any, "runFillScriptAction"); + + insertAutofillContentService.fillForm(fillScript); + + expect(insertAutofillContentService["fillingWithinSandboxedIframe"]).toHaveBeenCalled(); + expect(insertAutofillContentService["userCancelledInsecureUrlAutofill"]).toHaveBeenCalled(); + expect( + insertAutofillContentService["userCancelledUntrustedIframeAutofill"] + ).not.toHaveBeenCalled(); + expect(insertAutofillContentService["tabURLChanged"]).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, "tabURLChanged").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["tabURLChanged"]).not.toHaveBeenCalled(); + expect(insertAutofillContentService["runFillScriptAction"]).not.toHaveBeenCalled(); + }); + + it("returns early if the page location origin does not match against any of the cipher saved URLs", () => { + 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, "tabURLChanged").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["tabURLChanged"]).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, "tabURLChanged").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["tabURLChanged"]).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..ad40b76fbcd --- /dev/null +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -0,0 +1,376 @@ +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) || + this.tabURLChanged(fillScript.savedUrls) + ) { + return; + } + + fillScript.script.forEach(this.runFillScriptAction); + } + + /** + * Determines if the page URL no longer matches one of the cipher's savedURL domains + * @param {string[] | null} savedUrls + * @returns {boolean} + * @private + */ + private tabURLChanged(savedUrls?: AutofillScript["savedUrls"]): boolean { + return savedUrls && !savedUrls.some((url) => url.startsWith(window.location.origin)); + } + + /** + * 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:" || + !this.isPasswordFieldWithinDocument() + ) { + return false; + } + + const confirmationWarning = [ + chrome.i18n.getMessage("insecurePageWarning"), + chrome.i18n.getMessage("insecurePageWarningFillPrompt", [window.location.hostname]), + ].join("\n\n"); + + return !confirm(confirmationWarning); + } + + /** + * Checks if there is a password field within the current document. Includes + * password fields that are present within the shadow DOM. + * @returns {boolean} + * @private + */ + private isPasswordFieldWithinDocument(): boolean { + return Boolean( + this.collectAutofillContentService.queryAllTreeWalkerNodes( + document.documentElement, + (node: Node) => node instanceof HTMLInputElement && node.type === "password", + false + )?.length + ); + } + + /** + * 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 new file mode 100644 index 00000000000..8a97e397477 --- /dev/null +++ b/apps/browser/src/autofill/types/index.ts @@ -0,0 +1,63 @@ +import { Region } from "@bitwarden/common/platform/abstractions/environment.service"; +import { VaultTimeoutAction } from "@bitwarden/common/src/enums/vault-timeout-action.enum"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; + +export type UserSettings = { + avatarColor: string | null; + environmentUrls: { + api: string | null; + base: string | null; + events: string | null; + icons: string | null; + identity: string | null; + keyConnector: string | null; + notifications: string | null; + webVault: string | null; + }; + pinProtected: { [key: string]: any }; + region: Region; + serverConfig: { + environment: { + api: string | null; + cloudRegion: string | null; + identity: string | null; + notifications: string | null; + sso: string | null; + vault: string | null; + }; + featureStates: { [key: string]: any }; + gitHash: string; + server: { [key: string]: any }; + utcDate: string; + version: string; + }; + settings: { + equivalentDomains: string[][]; + }; + neverDomains?: { [key: string]: any }; + disableAddLoginNotification?: boolean; + disableChangedPasswordNotification?: boolean; + 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; + +export type AutofillCipherTypeId = CipherType.Login | CipherType.Card | CipherType.Identity; diff --git a/apps/browser/src/background/commands.background.ts b/apps/browser/src/background/commands.background.ts index b53c809c357..0cbf91c6666 100644 --- a/apps/browser/src/background/commands.background.ts +++ b/apps/browser/src/background/commands.background.ts @@ -1,10 +1,10 @@ -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; +import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; -import { BrowserApi } from "../browser/browserApi"; +import { BrowserApi } from "../platform/browser/browser-api"; import MainBackground from "./main.background"; import LockedVaultPendingNotificationsItem from "./models/lockedVaultPendingNotificationsItem"; diff --git a/apps/browser/src/background/idle.background.ts b/apps/browser/src/background/idle.background.ts index 0037340f03d..7200301c795 100644 --- a/apps/browser/src/background/idle.background.ts +++ b/apps/browser/src/background/idle.background.ts @@ -1,8 +1,8 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; -import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; +import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; -import { BrowserStateService } from "../services/abstractions/browser-state.service"; +import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; const IdleInterval = 60 * 5; // 5 minutes diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index d47a6ae7c93..caac8e6bb81 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1,72 +1,75 @@ import { AvatarUpdateService as AvatarUpdateServiceAbstraction } from "@bitwarden/common/abstractions/account/avatar-update.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; -import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/abstractions/appId.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; -import { ConfigApiServiceAbstraction } from "@bitwarden/common/abstractions/config/config-api.service.abstraction"; -import { ConfigServiceAbstraction } from "@bitwarden/common/abstractions/config/config.service.abstraction"; -import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/abstractions/crypto.service"; -import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/abstractions/cryptoFunction.service"; -import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; +import { DevicesServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices.service.abstraction"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; -import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/abstractions/file-upload/file-upload.service"; -import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService as LogServiceAbstraction } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/abstractions/messaging.service"; import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; -import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/abstractions/platformUtils.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; import { SettingsService as SettingsServiceAbstraction } from "@bitwarden/common/abstractions/settings.service"; -import { - AbstractMemoryStorageService, - AbstractStorageService, -} from "@bitwarden/common/abstractions/storage.service"; -import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/abstractions/system.service"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/abstractions/totp.service"; -import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/abstractions/userVerification/userVerification-api.service.abstraction"; -import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; -import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; -import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service"; -import { CollectionService as CollectionServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/collection.service"; -import { InternalOrganizationService as InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService as InternalPolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { CollectionService } from "@bitwarden/common/admin-console/services/collection.service"; import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction"; +import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; +import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; -import { StateFactory } from "@bitwarden/common/factories/stateFactory"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; +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 { 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"; +import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; +import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService as LogServiceAbstraction } 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 { + AbstractMemoryStorageService, + AbstractStorageService, +} from "@bitwarden/common/platform/abstractions/storage.service"; +import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service"; +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 { 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 { 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"; import { ApiService } from "@bitwarden/common/services/api.service"; -import { AppIdService } from "@bitwarden/common/services/appId.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; -import { ConfigService } from "@bitwarden/common/services/config/config.service"; -import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service"; -import { ContainerService } from "@bitwarden/common/services/container.service"; -import { EncryptServiceImplementation } from "@bitwarden/common/services/cryptography/encrypt.service.implementation"; -import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/services/cryptography/multithread-encrypt.service.implementation"; +import { DevicesServiceImplementation } from "@bitwarden/common/services/devices/devices.service.implementation"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; -import { FileUploadService } from "@bitwarden/common/services/file-upload/file-upload.service"; -import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; import { NotificationsService } from "@bitwarden/common/services/notifications.service"; import { SearchService } from "@bitwarden/common/services/search.service"; -import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service"; -import { SystemService } from "@bitwarden/common/services/system.service"; import { TotpService } from "@bitwarden/common/services/totp.service"; -import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vaultTimeout/vaultTimeoutSettings.service"; -import { WebCryptoFunctionService } from "@bitwarden/common/services/webCryptoFunction.service"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { PasswordGenerationService, PasswordGenerationServiceAbstraction, @@ -75,10 +78,15 @@ import { UsernameGenerationService, UsernameGenerationServiceAbstraction, } from "@bitwarden/common/tools/generator/username"; +import { + PasswordStrengthService, + PasswordStrengthServiceAbstraction, +} from "@bitwarden/common/tools/password-strength"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService as CollectionServiceAbstraction } from "@bitwarden/common/vault/abstractions/collection.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; import { InternalFolderService as InternalFolderServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -86,6 +94,7 @@ import { SyncNotifierService as SyncNotifierServiceAbstraction } from "@bitwarde import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/services/collection.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; import { SyncNotifierService } from "@bitwarden/common/vault/services/sync/sync-notifier.service"; @@ -94,6 +103,12 @@ import { VaultExportService, VaultExportServiceAbstraction, } from "@bitwarden/exporter/vault-export"; +import { + ImportApiServiceAbstraction, + ImportApiService, + ImportServiceAbstraction, + ImportService, +} from "@bitwarden/importer"; import { BrowserOrganizationService } from "../admin-console/services/browser-organization.service"; import { BrowserPolicyService } from "../admin-console/services/browser-policy.service"; @@ -105,25 +120,27 @@ import { ContextMenuClickedHandler } from "../autofill/browser/context-menu-clic import { MainContextMenuHandler } from "../autofill/browser/main-context-menu-handler"; import { AutofillService as AutofillServiceAbstraction } from "../autofill/services/abstractions/autofill.service"; import AutofillService from "../autofill/services/autofill.service"; -import { BrowserApi } from "../browser/browserApi"; import { SafariApp } from "../browser/safariApp"; -import { flagEnabled } from "../flags"; -import { UpdateBadge } from "../listeners/update-badge"; import { Account } from "../models/account"; -import { BrowserStateService as StateServiceAbstraction } from "../services/abstractions/browser-state.service"; -import { BrowserEnvironmentService } from "../services/browser-environment.service"; -import { BrowserI18nService } from "../services/browser-i18n.service"; +import { BrowserApi } from "../platform/browser/browser-api"; +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"; +import BrowserLocalStorageService from "../platform/services/browser-local-storage.service"; +import BrowserMessagingPrivateModeBackgroundService from "../platform/services/browser-messaging-private-mode-background.service"; +import BrowserMessagingService from "../platform/services/browser-messaging.service"; +import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service"; +import { BrowserStateService } from "../platform/services/browser-state.service"; +import { KeyGenerationService } from "../platform/services/key-generation.service"; +import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; import { BrowserSendService } from "../services/browser-send.service"; import { BrowserSettingsService } from "../services/browser-settings.service"; -import { BrowserStateService } from "../services/browser-state.service"; -import { BrowserCryptoService } from "../services/browserCrypto.service"; -import BrowserLocalStorageService from "../services/browserLocalStorage.service"; -import BrowserMessagingService from "../services/browserMessaging.service"; -import BrowserMessagingPrivateModeBackgroundService from "../services/browserMessagingPrivateModeBackground.service"; -import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service"; -import { KeyGenerationService } from "../services/keyGeneration.service"; -import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service"; -import VaultTimeoutService from "../services/vaultTimeout/vaultTimeout.service"; +import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; import { BrowserFolderService } from "../vault/services/browser-folder.service"; import { VaultFilterService } from "../vault/services/vault-filter.service"; @@ -151,20 +168,22 @@ export default class MainBackground { cipherService: CipherServiceAbstraction; folderService: InternalFolderServiceAbstraction; collectionService: CollectionServiceAbstraction; - vaultTimeoutService: VaultTimeoutServiceAbstraction; + vaultTimeoutService: VaultTimeoutService; vaultTimeoutSettingsService: VaultTimeoutSettingsServiceAbstraction; syncService: SyncServiceAbstraction; passwordGenerationService: PasswordGenerationServiceAbstraction; + passwordStrengthService: PasswordStrengthServiceAbstraction; totpService: TotpServiceAbstraction; autofillService: AutofillServiceAbstraction; containerService: ContainerService; auditService: AuditServiceAbstraction; authService: AuthServiceAbstraction; + importApiService: ImportApiServiceAbstraction; + importService: ImportServiceAbstraction; exportService: VaultExportServiceAbstraction; searchService: SearchServiceAbstraction; notificationsService: NotificationsServiceAbstraction; stateService: StateServiceAbstraction; - stateMigrationService: StateMigrationService; systemService: SystemServiceAbstraction; eventCollectionService: EventCollectionServiceAbstraction; eventUploadService: EventUploadServiceAbstraction; @@ -188,8 +207,13 @@ export default class MainBackground { avatarUpdateService: AvatarUpdateServiceAbstraction; mainContextMenuHandler: MainContextMenuHandler; cipherContextMenuHandler: CipherContextMenuHandler; - configService: ConfigServiceAbstraction; + configService: BrowserConfigService; configApiService: ConfigApiServiceAbstraction; + devicesApiService: DevicesApiServiceAbstraction; + devicesService: DevicesServiceAbstraction; + deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction; + authRequestCryptoService: AuthRequestCryptoServiceAbstraction; + browserPopoutWindowService: BrowserPopoutWindowService; // Passed to the popup for Safari to workaround issues with theming, downloading, etc. backgroundWindow = window; @@ -244,17 +268,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( @@ -312,23 +330,6 @@ export default class MainBackground { ); this.searchService = new SearchService(this.logService, this.i18nService); - this.cipherService = new CipherService( - this.cryptoService, - this.settingsService, - this.apiService, - this.i18nService, - this.searchService, - this.stateService, - this.encryptService, - this.cipherFileUploadService - ); - this.folderService = new BrowserFolderService( - this.cryptoService, - this.i18nService, - this.cipherService, - this.stateService - ); - this.folderApiService = new FolderApiService(this.folderService, this.apiService); this.collectionService = new CollectionService( this.cryptoService, this.i18nService, @@ -352,14 +353,9 @@ export default class MainBackground { this.cryptoFunctionService, logoutCallback ); - this.vaultFilterService = new VaultFilterService( - this.stateService, - this.organizationService, - this.folderService, - this.cipherService, - this.collectionService, - this.policyService - ); + + this.passwordStrengthService = new PasswordStrengthService(); + this.passwordGenerationService = new PasswordGenerationService( this.cryptoService, this.policyService, @@ -377,6 +373,23 @@ export default class MainBackground { that.runtimeBackground.processMessage(message, that as any, null); }; })(); + + this.devicesApiService = new DevicesApiServiceImplementation(this.apiService); + this.deviceTrustCryptoService = new DeviceTrustCryptoService( + this.cryptoFunctionService, + this.cryptoService, + this.encryptService, + this.stateService, + this.appIdService, + this.devicesApiService, + this.i18nService, + this.platformUtilsService + ); + + this.devicesService = new DevicesServiceImplementation(this.devicesApiService); + + this.authRequestCryptoService = new AuthRequestCryptoServiceImplementation(this.cryptoService); + this.authService = new AuthService( this.cryptoService, this.apiService, @@ -391,15 +404,66 @@ export default class MainBackground { this.twoFactorService, this.i18nService, this.encryptService, - this.passwordGenerationService, - this.policyService + this.passwordStrengthService, + this.policyService, + this.deviceTrustCryptoService, + this.authRequestCryptoService + ); + + this.userVerificationApiService = new UserVerificationApiService(this.apiService); + + this.userVerificationService = new UserVerificationService( + this.stateService, + this.cryptoService, + this.i18nService, + this.userVerificationApiService + ); + + this.configApiService = new ConfigApiService(this.apiService, this.authService); + + this.configService = new BrowserConfigService( + this.stateService, + this.configApiService, + this.authService, + this.environmentService, + this.logService, + true + ); + + this.cipherService = new CipherService( + this.cryptoService, + this.settingsService, + this.apiService, + this.i18nService, + this.searchService, + this.stateService, + this.encryptService, + this.cipherFileUploadService, + this.configService ); + this.folderService = new BrowserFolderService( + this.cryptoService, + this.i18nService, + this.cipherService, + this.stateService + ); + this.folderApiService = new FolderApiService(this.folderService, this.apiService); this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService( this.cryptoService, this.tokenService, this.policyService, - this.stateService + this.stateService, + this.userVerificationService + ); + + this.vaultFilterService = new VaultFilterService( + this.stateService, + this.organizationService, + this.folderService, + this.cipherService, + this.collectionService, + this.policyService ); this.vaultTimeoutService = new VaultTimeoutService( @@ -410,7 +474,6 @@ export default class MainBackground { this.platformUtilsService, this.messagingService, this.searchService, - this.keyConnectorService, this.stateService, this.authService, this.vaultTimeoutSettingsService, @@ -461,15 +524,29 @@ export default class MainBackground { this.eventUploadService ); this.totpService = new TotpService(this.cryptoFunctionService, this.logService); + this.autofillService = new AutofillService( this.cipherService, this.stateService, this.totpService, this.eventCollectionService, this.logService, - this.settingsService + this.settingsService, + this.userVerificationService ); this.auditService = new AuditService(this.cryptoFunctionService, this.apiService); + + this.importApiService = new ImportApiService(this.apiService); + + this.importService = new ImportService( + this.cipherService, + this.folderService, + this.importApiService, + this.i18nService, + this.collectionService, + this.cryptoService + ); + this.exportService = new VaultExportService( this.folderService, this.cipherService, @@ -490,20 +567,7 @@ export default class MainBackground { this.messagingService ); - this.userVerificationApiService = new UserVerificationApiService(this.apiService); - - this.userVerificationService = new UserVerificationService( - this.cryptoService, - this.i18nService, - this.userVerificationApiService - ); - - this.configService = new ConfigService( - this.stateService, - this.configApiService, - this.authService, - this.environmentService - ); + this.browserPopoutWindowService = new BrowserPopoutWindowService(); const systemUtilsServiceReloadCallback = () => { const forceWindowReload = @@ -531,11 +595,13 @@ export default class MainBackground { this.platformUtilsService as BrowserPlatformUtilsService, this.i18nService, this.notificationsService, + this.stateService, this.systemService, this.environmentService, this.messagingService, this.logService, - this.configService + this.configService, + this.browserPopoutWindowService ); this.nativeMessagingBackground = new NativeMessagingBackground( this.cryptoService, @@ -562,7 +628,8 @@ export default class MainBackground { this.authService, this.policyService, this.folderService, - this.stateService + this.stateService, + this.environmentService ); this.tabsBackground = new TabsBackground(this, this.notificationBackground); @@ -589,8 +656,10 @@ export default class MainBackground { }, this.authService, this.cipherService, + this.stateService, this.totpService, - this.eventCollectionService + this.eventCollectionService, + this.userVerificationService ); this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler); @@ -635,13 +704,14 @@ export default class MainBackground { await this.stateService.init(); - await (this.vaultTimeoutService as VaultTimeoutService).init(true); + await this.vaultTimeoutService.init(true); await (this.i18nService as BrowserI18nService).init(); await (this.eventUploadService as EventUploadService).init(true); await this.runtimeBackground.init(); await this.notificationBackground.init(); await this.commandsBackground.init(); + this.configService.init(); this.twoFactorService.init(); await this.tabsBackground.init(); @@ -668,6 +738,9 @@ export default class MainBackground { return new Promise((resolve) => { setTimeout(async () => { await this.environmentService.setUrlsFromStorage(); + // Workaround to ignore stateService.activeAccount until URLs are set + // TODO: Remove this when implementing ticket PM-2637 + this.environmentService.initialized = true; if (!this.isPrivateMode) { await this.refreshBadge(); } diff --git a/apps/browser/src/background/models/add-unlock-vault-queue-message.ts b/apps/browser/src/background/models/add-unlock-vault-queue-message.ts new file mode 100644 index 00000000000..9ddde271008 --- /dev/null +++ b/apps/browser/src/background/models/add-unlock-vault-queue-message.ts @@ -0,0 +1,6 @@ +import NotificationQueueMessage from "./notificationQueueMessage"; +import { NotificationQueueMessageType } from "./notificationQueueMessageType"; + +export default class AddUnlockVaultQueueMessage extends NotificationQueueMessage { + type: NotificationQueueMessageType.UnlockVault; +} diff --git a/apps/browser/src/background/models/addLoginQueueMessage.ts b/apps/browser/src/background/models/addLoginQueueMessage.ts index 7409100902a..d5db5db135b 100644 --- a/apps/browser/src/background/models/addLoginQueueMessage.ts +++ b/apps/browser/src/background/models/addLoginQueueMessage.ts @@ -1,4 +1,4 @@ -import { Utils } from "@bitwarden/common/misc/utils"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; diff --git a/apps/browser/src/background/models/lockedVaultPendingNotificationsItem.ts b/apps/browser/src/background/models/lockedVaultPendingNotificationsItem.ts index ec697b16994..53f8405cd50 100644 --- a/apps/browser/src/background/models/lockedVaultPendingNotificationsItem.ts +++ b/apps/browser/src/background/models/lockedVaultPendingNotificationsItem.ts @@ -1,6 +1,9 @@ export default class LockedVaultPendingNotificationsItem { commandToRetry: { - msg: any; + msg: { + command: string; + data?: any; + }; sender: chrome.runtime.MessageSender; }; target: string; diff --git a/apps/browser/src/background/models/notificationQueueMessageType.ts b/apps/browser/src/background/models/notificationQueueMessageType.ts index f5e4115c4f5..2ce1a1840d8 100644 --- a/apps/browser/src/background/models/notificationQueueMessageType.ts +++ b/apps/browser/src/background/models/notificationQueueMessageType.ts @@ -1,4 +1,5 @@ export enum NotificationQueueMessageType { AddLogin = 0, ChangePassword = 1, + UnlockVault = 2, } diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 7e4b91694c9..88fd81a3a70 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -1,18 +1,22 @@ -import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { Utils } from "@bitwarden/common/misc/utils"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; - -import { BrowserApi } from "../browser/browserApi"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { + MasterKey, + SymmetricCryptoKey, + UserKey, +} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; + +import { BrowserApi } from "../platform/browser/browser-api"; import RuntimeBackground from "./runtime.background"; @@ -42,6 +46,7 @@ type ReceiveMessage = { // Unlock key keyB64?: string; + userKeyB64?: string; }; type ReceiveMessageOuter = { @@ -59,8 +64,8 @@ export class NativeMessagingBackground { private port: browser.runtime.Port | chrome.runtime.Port; private resolver: any = null; - private privateKey: ArrayBuffer = null; - private publicKey: ArrayBuffer = null; + private privateKey: Uint8Array = null; + private publicKey: Uint8Array = null; private secureSetupResolve: any = null; private sharedSecret: SymmetricCryptoKey; private appId: string; @@ -129,7 +134,7 @@ export class NativeMessagingBackground { const encrypted = Utils.fromB64ToArray(message.sharedSecret); const decrypted = await this.cryptoFunctionService.rsaDecrypt( - encrypted.buffer, + encrypted, this.privateKey, EncryptionAlgorithm ); @@ -320,16 +325,55 @@ export class NativeMessagingBackground { } if (message.response === "unlocked") { - await this.cryptoService.setKey( - new SymmetricCryptoKey(Utils.fromB64ToArray(message.keyB64).buffer) - ); + try { + if (message.userKeyB64) { + const userKey = new SymmetricCryptoKey( + Utils.fromB64ToArray(message.userKeyB64) + ) as UserKey; + await this.cryptoService.setUserKey(userKey); + } else if (message.keyB64) { + // Backwards compatibility to support cases in which the user hasn't updated their desktop app + // TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3472) + let encUserKey = await this.stateService.getEncryptedCryptoSymmetricKey(); + encUserKey ||= await this.stateService.getMasterKeyEncryptedUserKey(); + if (!encUserKey) { + throw new Error("No encrypted user key found"); + } + const masterKey = new SymmetricCryptoKey( + Utils.fromB64ToArray(message.keyB64) + ) as MasterKey; + const userKey = await this.cryptoService.decryptUserKeyWithMasterKey( + masterKey, + new EncString(encUserKey) + ); + await this.cryptoService.setMasterKey(masterKey); + await this.cryptoService.setUserKey(userKey); + } else { + throw new Error("No key received"); + } + } catch (e) { + this.logService.error("Unable to set key: " + e); + this.messagingService.send("showDialog", { + title: { key: "biometricsFailedTitle" }, + content: { key: "biometricsFailedDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "danger", + }); + + // Exit early + if (this.resolver) { + this.resolver(message); + } + return; + } // Verify key is correct by attempting to decrypt a secret try { await this.cryptoService.getFingerprint(await this.stateService.getUserId()); } catch (e) { this.logService.error("Unable to verify key: " + e); - await this.cryptoService.clearKey(); + await this.cryptoService.clearKeys(); this.showWrongUserDialog(); // Exit early @@ -378,9 +422,10 @@ export class NativeMessagingBackground { } private async showFingerprintDialog() { - const fingerprint = ( - await this.cryptoService.getFingerprint(await this.stateService.getUserId(), this.publicKey) - ).join(" "); + const fingerprint = await this.cryptoService.getFingerprint( + await this.stateService.getUserId(), + this.publicKey + ); this.messagingService.send("showNativeMessagingFinterprintDialog", { fingerprint: fingerprint, diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index 7a6f293f8d1..e921a736f71 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -1,15 +1,19 @@ -import { ConfigServiceAbstraction } from "@bitwarden/common/abstractions/config/config.service.abstraction"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; -import { SystemService } from "@bitwarden/common/abstractions/system.service"; -import { Utils } from "@bitwarden/common/misc/utils"; +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"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { AutofillService } from "../autofill/services/abstractions/autofill.service"; -import { BrowserApi } from "../browser/browserApi"; -import { BrowserEnvironmentService } from "../services/browser-environment.service"; -import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service"; +import { BrowserApi } from "../platform/browser/browser-api"; +import { BrowserPopoutWindowService } from "../platform/popup/abstractions/browser-popout-window.service"; +import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; +import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; +import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service"; import MainBackground from "./main.background"; import LockedVaultPendingNotificationsItem from "./models/lockedVaultPendingNotificationsItem"; @@ -26,11 +30,13 @@ export default class RuntimeBackground { private platformUtilsService: BrowserPlatformUtilsService, private i18nService: I18nService, private notificationsService: NotificationsService, + private stateService: BrowserStateService, private systemService: SystemService, private environmentService: BrowserEnvironmentService, private messagingService: MessagingService, private logService: LogService, - private configService: ConfigServiceAbstraction + private configService: ConfigServiceAbstraction, + private browserPopoutWindowService: BrowserPopoutWindowService ) { // onInstalled listener must be wired up before anything else, so we do it in the ctor chrome.runtime.onInstalled.addListener((details: any) => { @@ -59,6 +65,8 @@ export default class RuntimeBackground { } async processMessage(msg: any, sender: chrome.runtime.MessageSender, sendResponse: any) { + const cipherId = msg.data?.cipherId; + switch (msg.command) { case "loggedIn": case "unlocked": { @@ -66,7 +74,7 @@ export default class RuntimeBackground { if (this.lockedVaultPendingNotifications?.length > 0) { item = this.lockedVaultPendingNotifications.pop(); - BrowserApi.closeBitwardenExtensionTab(); + await this.browserPopoutWindowService.closeUnlockPrompt(); } await this.main.refreshBadge(); @@ -98,22 +106,48 @@ export default class RuntimeBackground { await this.main.refreshMenu(); }, 2000); this.main.avatarUpdateService.loadColorFromState(); - this.configService.fetchServerConfig(); + this.configService.triggerServerConfigFetch(); } break; case "openPopup": await this.main.openPopup(); break; case "promptForLogin": - BrowserApi.openBitwardenExtensionTab("popup/index.html", true); + case "bgReopenPromptForLogin": + await this.browserPopoutWindowService.openUnlockPrompt(sender.tab?.windowId); + break; + case "passwordReprompt": + if (cipherId) { + await this.browserPopoutWindowService.openPasswordRepromptPrompt(sender.tab?.windowId, { + cipherId: cipherId, + senderTabId: sender.tab.id, + action: msg.data?.action, + }); + } break; case "openAddEditCipher": { - const addEditCipherUrl = - msg.data?.cipherId == null - ? "popup/index.html#/edit-cipher" - : "popup/index.html#/edit-cipher?cipherId=" + msg.data.cipherId; + const isNewCipher = !cipherId; + const cipherType = msg.data?.cipherType; + const senderTab = sender.tab; + + if (!senderTab) { + break; + } + + if (isNewCipher) { + await this.browserPopoutWindowService.openCipherCreation(senderTab.windowId, { + cipherType, + senderTabId: senderTab.id, + senderTabURI: senderTab.url, + }); + } else { + await this.browserPopoutWindowService.openCipherEdit(senderTab.windowId, { + cipherId, + senderTabId: senderTab.id, + senderTabURI: senderTab.url, + }); + } - BrowserApi.openBitwardenExtensionTab(addEditCipherUrl, true); break; } case "closeTab": @@ -121,6 +155,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; @@ -138,6 +178,7 @@ export default class RuntimeBackground { switch (msg.sender) { case "autofiller": case "autofill_cmd": { + this.stateService.setLastActive(new Date().getTime()); const totpCode = await this.autofillService.doAutoFillActiveTab( [ { @@ -153,6 +194,34 @@ export default class RuntimeBackground { } break; } + case "autofill_card": { + await this.autofillService.doAutoFillActiveTab( + [ + { + frameId: sender.frameId, + tab: msg.tab, + details: msg.details, + }, + ], + false, + CipherType.Card + ); + break; + } + case "autofill_identity": { + await this.autofillService.doAutoFillActiveTab( + [ + { + frameId: sender.frameId, + tab: msg.tab, + details: msg.details, + }, + ], + false, + CipherType.Identity + ); + break; + } case "contextMenu": clearTimeout(this.autofillTimeout); this.pageDetailsToAutoFill.push({ @@ -224,6 +293,7 @@ export default class RuntimeBackground { cipher: this.main.loginToAutoFill, pageDetails: this.pageDetailsToAutoFill, fillNewPassword: true, + allowTotpAutofill: true, }); if (totpCode != null) { diff --git a/apps/browser/src/background/service_factories/cipher-file-upload-service.factory.ts b/apps/browser/src/background/service-factories/cipher-file-upload-service.factory.ts similarity index 76% rename from apps/browser/src/background/service_factories/cipher-file-upload-service.factory.ts rename to apps/browser/src/background/service-factories/cipher-file-upload-service.factory.ts index ef2be8fa761..7c83958927f 100644 --- a/apps/browser/src/background/service_factories/cipher-file-upload-service.factory.ts +++ b/apps/browser/src/background/service-factories/cipher-file-upload-service.factory.ts @@ -1,12 +1,19 @@ import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; -import { apiServiceFactory, ApiServiceInitOptions } from "./api-service.factory"; -import { FactoryOptions, CachedServices, factory } from "./factory-options"; import { - fileUploadServiceFactory, + ApiServiceInitOptions, + apiServiceFactory, +} from "../../platform/background/service-factories/api-service.factory"; +import { + FactoryOptions, + CachedServices, + factory, +} from "../../platform/background/service-factories/factory-options"; +import { FileUploadServiceInitOptions, -} from "./file-upload-service.factory"; + fileUploadServiceFactory, +} from "../../platform/background/service-factories/file-upload-service.factory"; type CipherFileUploadServiceFactoyOptions = FactoryOptions; diff --git a/apps/browser/src/background/service_factories/cipher-file-upload.service.factory.ts b/apps/browser/src/background/service-factories/cipher-file-upload.service.factory.ts similarity index 76% rename from apps/browser/src/background/service_factories/cipher-file-upload.service.factory.ts rename to apps/browser/src/background/service-factories/cipher-file-upload.service.factory.ts index ef2be8fa761..7c83958927f 100644 --- a/apps/browser/src/background/service_factories/cipher-file-upload.service.factory.ts +++ b/apps/browser/src/background/service-factories/cipher-file-upload.service.factory.ts @@ -1,12 +1,19 @@ import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; -import { apiServiceFactory, ApiServiceInitOptions } from "./api-service.factory"; -import { FactoryOptions, CachedServices, factory } from "./factory-options"; import { - fileUploadServiceFactory, + ApiServiceInitOptions, + apiServiceFactory, +} from "../../platform/background/service-factories/api-service.factory"; +import { + FactoryOptions, + CachedServices, + factory, +} from "../../platform/background/service-factories/factory-options"; +import { FileUploadServiceInitOptions, -} from "./file-upload-service.factory"; + fileUploadServiceFactory, +} from "../../platform/background/service-factories/file-upload-service.factory"; type CipherFileUploadServiceFactoyOptions = FactoryOptions; diff --git a/apps/browser/src/background/service-factories/devices-api-service.factory.ts b/apps/browser/src/background/service-factories/devices-api-service.factory.ts new file mode 100644 index 00000000000..8999b7c2c72 --- /dev/null +++ b/apps/browser/src/background/service-factories/devices-api-service.factory.ts @@ -0,0 +1,28 @@ +import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; +import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; + +import { + ApiServiceInitOptions, + apiServiceFactory, +} from "../../platform/background/service-factories/api-service.factory"; +import { + FactoryOptions, + CachedServices, + factory, +} from "../../platform/background/service-factories/factory-options"; + +type DevicesApiServiceFactoryOptions = FactoryOptions; + +export type DevicesApiServiceInitOptions = DevicesApiServiceFactoryOptions & ApiServiceInitOptions; + +export function devicesApiServiceFactory( + cache: { devicesApiService?: DevicesApiServiceAbstraction } & CachedServices, + opts: DevicesApiServiceInitOptions +): Promise { + return factory( + cache, + "devicesApiService", + opts, + async () => new DevicesApiServiceImplementation(await apiServiceFactory(cache, opts)) + ); +} diff --git a/apps/browser/src/background/service_factories/event-collection-service.factory.ts b/apps/browser/src/background/service-factories/event-collection-service.factory.ts similarity index 84% rename from apps/browser/src/background/service_factories/event-collection-service.factory.ts rename to apps/browser/src/background/service-factories/event-collection-service.factory.ts index 4008091c222..5ed6c24ca4b 100644 --- a/apps/browser/src/background/service_factories/event-collection-service.factory.ts +++ b/apps/browser/src/background/service-factories/event-collection-service.factory.ts @@ -5,6 +5,15 @@ import { organizationServiceFactory, OrganizationServiceInitOptions, } from "../../admin-console/background/service-factories/organization-service.factory"; +import { + FactoryOptions, + CachedServices, + factory, +} from "../../platform/background/service-factories/factory-options"; +import { + stateServiceFactory, + StateServiceInitOptions, +} from "../../platform/background/service-factories/state-service.factory"; import { cipherServiceFactory, CipherServiceInitOptions, @@ -14,8 +23,6 @@ import { eventUploadServiceFactory, EventUploadServiceInitOptions, } from "./event-upload-service.factory"; -import { FactoryOptions, CachedServices, factory } from "./factory-options"; -import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; type EventCollectionServiceOptions = FactoryOptions; diff --git a/apps/browser/src/background/service_factories/event-upload-service.factory.ts b/apps/browser/src/background/service-factories/event-upload-service.factory.ts similarity index 63% rename from apps/browser/src/background/service_factories/event-upload-service.factory.ts rename to apps/browser/src/background/service-factories/event-upload-service.factory.ts index c0ed0e5426d..f9e72395a74 100644 --- a/apps/browser/src/background/service_factories/event-upload-service.factory.ts +++ b/apps/browser/src/background/service-factories/event-upload-service.factory.ts @@ -1,10 +1,23 @@ import { EventUploadService as AbstractEventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; -import { apiServiceFactory, ApiServiceInitOptions } from "./api-service.factory"; -import { FactoryOptions, CachedServices, factory } from "./factory-options"; -import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; -import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; +import { + ApiServiceInitOptions, + apiServiceFactory, +} from "../../platform/background/service-factories/api-service.factory"; +import { + FactoryOptions, + CachedServices, + factory, +} from "../../platform/background/service-factories/factory-options"; +import { + logServiceFactory, + LogServiceInitOptions, +} from "../../platform/background/service-factories/log-service.factory"; +import { + stateServiceFactory, + StateServiceInitOptions, +} from "../../platform/background/service-factories/state-service.factory"; type EventUploadServiceOptions = FactoryOptions; diff --git a/apps/browser/src/background/service_factories/password-generation-service.factory.ts b/apps/browser/src/background/service-factories/password-generation-service.factory.ts similarity index 72% rename from apps/browser/src/background/service_factories/password-generation-service.factory.ts rename to apps/browser/src/background/service-factories/password-generation-service.factory.ts index cffbf376d4f..d97e8ce98a6 100644 --- a/apps/browser/src/background/service_factories/password-generation-service.factory.ts +++ b/apps/browser/src/background/service-factories/password-generation-service.factory.ts @@ -7,10 +7,19 @@ import { policyServiceFactory, PolicyServiceInitOptions, } from "../../admin-console/background/service-factories/policy-service.factory"; - -import { cryptoServiceFactory, CryptoServiceInitOptions } from "./crypto-service.factory"; -import { CachedServices, factory, FactoryOptions } from "./factory-options"; -import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; +import { + CryptoServiceInitOptions, + cryptoServiceFactory, +} from "../../platform/background/service-factories/crypto-service.factory"; +import { + CachedServices, + factory, + FactoryOptions, +} from "../../platform/background/service-factories/factory-options"; +import { + stateServiceFactory, + StateServiceInitOptions, +} from "../../platform/background/service-factories/state-service.factory"; type PasswordGenerationServiceFactoryOptions = FactoryOptions; diff --git a/apps/browser/src/background/service_factories/search-service.factory.ts b/apps/browser/src/background/service-factories/search-service.factory.ts similarity index 64% rename from apps/browser/src/background/service_factories/search-service.factory.ts rename to apps/browser/src/background/service-factories/search-service.factory.ts index eb6213c2b39..6ff9691c524 100644 --- a/apps/browser/src/background/service_factories/search-service.factory.ts +++ b/apps/browser/src/background/service-factories/search-service.factory.ts @@ -1,9 +1,19 @@ import { SearchService as AbstractSearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/services/search.service"; -import { CachedServices, factory, FactoryOptions } from "./factory-options"; -import { i18nServiceFactory, I18nServiceInitOptions } from "./i18n-service.factory"; -import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; +import { + CachedServices, + factory, + FactoryOptions, +} from "../../platform/background/service-factories/factory-options"; +import { + i18nServiceFactory, + I18nServiceInitOptions, +} from "../../platform/background/service-factories/i18n-service.factory"; +import { + logServiceFactory, + LogServiceInitOptions, +} from "../../platform/background/service-factories/log-service.factory"; type SearchServiceFactoryOptions = FactoryOptions; diff --git a/apps/browser/src/background/service_factories/send-service.factory.ts b/apps/browser/src/background/service-factories/send-service.factory.ts similarity index 56% rename from apps/browser/src/background/service_factories/send-service.factory.ts rename to apps/browser/src/background/service-factories/send-service.factory.ts index 2da4d88a1d8..bc0f83787fd 100644 --- a/apps/browser/src/background/service_factories/send-service.factory.ts +++ b/apps/browser/src/background/service-factories/send-service.factory.ts @@ -1,13 +1,25 @@ import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { cryptoFunctionServiceFactory } from "../../platform/background/service-factories/crypto-function-service.factory"; +import { + CryptoServiceInitOptions, + cryptoServiceFactory, +} from "../../platform/background/service-factories/crypto-service.factory"; +import { + FactoryOptions, + CachedServices, + factory, +} from "../../platform/background/service-factories/factory-options"; +import { + i18nServiceFactory, + I18nServiceInitOptions, +} from "../../platform/background/service-factories/i18n-service.factory"; +import { + stateServiceFactory, + StateServiceInitOptions, +} from "../../platform/background/service-factories/state-service.factory"; import { BrowserSendService } from "../../services/browser-send.service"; -import { cryptoFunctionServiceFactory } from "./crypto-function-service.factory"; -import { cryptoServiceFactory, CryptoServiceInitOptions } from "./crypto-service.factory"; -import { FactoryOptions, CachedServices, factory } from "./factory-options"; -import { i18nServiceFactory, I18nServiceInitOptions } from "./i18n-service.factory"; -import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; - type SendServiceFactoryOptions = FactoryOptions; export type SendServiceInitOptions = SendServiceFactoryOptions & diff --git a/apps/browser/src/background/service_factories/settings-service.factory.ts b/apps/browser/src/background/service-factories/settings-service.factory.ts similarity index 72% rename from apps/browser/src/background/service_factories/settings-service.factory.ts rename to apps/browser/src/background/service-factories/settings-service.factory.ts index 73e0ae52032..17e22a6678d 100644 --- a/apps/browser/src/background/service_factories/settings-service.factory.ts +++ b/apps/browser/src/background/service-factories/settings-service.factory.ts @@ -1,10 +1,16 @@ import { SettingsService as AbstractSettingsService } from "@bitwarden/common/abstractions/settings.service"; +import { + FactoryOptions, + CachedServices, + factory, +} from "../../platform/background/service-factories/factory-options"; +import { + stateServiceFactory, + StateServiceInitOptions, +} from "../../platform/background/service-factories/state-service.factory"; import { BrowserSettingsService } from "../../services/browser-settings.service"; -import { FactoryOptions, CachedServices, factory } from "./factory-options"; -import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; - type SettingsServiceFactoryOptions = FactoryOptions; export type SettingsServiceInitOptions = SettingsServiceFactoryOptions & StateServiceInitOptions; diff --git a/apps/browser/src/background/service_factories/vault-timeout-service.factory.ts b/apps/browser/src/background/service-factories/vault-timeout-service.factory.ts similarity index 74% rename from apps/browser/src/background/service_factories/vault-timeout-service.factory.ts rename to apps/browser/src/background/service-factories/vault-timeout-service.factory.ts index b69d8d1d65f..b019db3297d 100644 --- a/apps/browser/src/background/service_factories/vault-timeout-service.factory.ts +++ b/apps/browser/src/background/service-factories/vault-timeout-service.factory.ts @@ -1,39 +1,45 @@ -import { VaultTimeoutService as AbstractVaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; +import { VaultTimeoutService as AbstractVaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; -import { - collectionServiceFactory, - CollectionServiceInitOptions, -} from "../../admin-console/background/service-factories/collection-service.factory"; import { authServiceFactory, AuthServiceInitOptions, } from "../../auth/background/service-factories/auth-service.factory"; import { - keyConnectorServiceFactory, - KeyConnectorServiceInitOptions, -} from "../../auth/background/service-factories/key-connector-service.factory"; -import VaultTimeoutService from "../../services/vaultTimeout/vaultTimeout.service"; + CryptoServiceInitOptions, + cryptoServiceFactory, +} from "../../platform/background/service-factories/crypto-service.factory"; +import { + CachedServices, + factory, + FactoryOptions, +} from "../../platform/background/service-factories/factory-options"; +import { + messagingServiceFactory, + MessagingServiceInitOptions, +} from "../../platform/background/service-factories/messaging-service.factory"; +import { + platformUtilsServiceFactory, + PlatformUtilsServiceInitOptions, +} from "../../platform/background/service-factories/platform-utils-service.factory"; +import { + StateServiceInitOptions, + stateServiceFactory, +} from "../../platform/background/service-factories/state-service.factory"; +import VaultTimeoutService from "../../services/vault-timeout/vault-timeout.service"; import { cipherServiceFactory, CipherServiceInitOptions, } from "../../vault/background/service_factories/cipher-service.factory"; +import { + collectionServiceFactory, + CollectionServiceInitOptions, +} from "../../vault/background/service_factories/collection-service.factory"; import { folderServiceFactory, FolderServiceInitOptions, } from "../../vault/background/service_factories/folder-service.factory"; -import { cryptoServiceFactory, CryptoServiceInitOptions } from "./crypto-service.factory"; -import { CachedServices, factory, FactoryOptions } from "./factory-options"; -import { messagingServiceFactory, MessagingServiceInitOptions } from "./messaging-service.factory"; -import { - platformUtilsServiceFactory, - PlatformUtilsServiceInitOptions, -} from "./platform-utils-service.factory"; import { searchServiceFactory, SearchServiceInitOptions } from "./search-service.factory"; -import { - stateServiceFactory as stateServiceFactory, - StateServiceInitOptions, -} from "./state-service.factory"; import { vaultTimeoutSettingsServiceFactory, VaultTimeoutSettingsServiceInitOptions, @@ -54,7 +60,6 @@ export type VaultTimeoutServiceInitOptions = VaultTimeoutServiceFactoryOptions & PlatformUtilsServiceInitOptions & MessagingServiceInitOptions & SearchServiceInitOptions & - KeyConnectorServiceInitOptions & StateServiceInitOptions & AuthServiceInitOptions & VaultTimeoutSettingsServiceInitOptions; @@ -76,7 +81,6 @@ export function vaultTimeoutServiceFactory( await platformUtilsServiceFactory(cache, opts), await messagingServiceFactory(cache, opts), await searchServiceFactory(cache, opts), - await keyConnectorServiceFactory(cache, opts), await stateServiceFactory(cache, opts), await authServiceFactory(cache, opts), await vaultTimeoutSettingsServiceFactory(cache, opts), diff --git a/apps/browser/src/background/service_factories/vault-timeout-settings-service.factory.ts b/apps/browser/src/background/service-factories/vault-timeout-settings-service.factory.ts similarity index 58% rename from apps/browser/src/background/service_factories/vault-timeout-settings-service.factory.ts rename to apps/browser/src/background/service-factories/vault-timeout-settings-service.factory.ts index d349771f428..eda86c0a156 100644 --- a/apps/browser/src/background/service_factories/vault-timeout-settings-service.factory.ts +++ b/apps/browser/src/background/service-factories/vault-timeout-settings-service.factory.ts @@ -1,5 +1,5 @@ -import { VaultTimeoutSettingsService as AbstractVaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service"; -import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vaultTimeout/vaultTimeoutSettings.service"; +import { VaultTimeoutSettingsService as AbstractVaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { policyServiceFactory, @@ -9,13 +9,23 @@ import { tokenServiceFactory, TokenServiceInitOptions, } from "../../auth/background/service-factories/token-service.factory"; - -import { cryptoServiceFactory, CryptoServiceInitOptions } from "./crypto-service.factory"; -import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { - stateServiceFactory as stateServiceFactory, + userVerificationServiceFactory, + UserVerificationServiceInitOptions, +} from "../../auth/background/service-factories/user-verification-service.factory"; +import { + CryptoServiceInitOptions, + cryptoServiceFactory, +} from "../../platform/background/service-factories/crypto-service.factory"; +import { + CachedServices, + factory, + FactoryOptions, +} from "../../platform/background/service-factories/factory-options"; +import { StateServiceInitOptions, -} from "./state-service.factory"; + stateServiceFactory, +} from "../../platform/background/service-factories/state-service.factory"; type VaultTimeoutSettingsServiceFactoryOptions = FactoryOptions; @@ -23,7 +33,8 @@ export type VaultTimeoutSettingsServiceInitOptions = VaultTimeoutSettingsService CryptoServiceInitOptions & TokenServiceInitOptions & PolicyServiceInitOptions & - StateServiceInitOptions; + StateServiceInitOptions & + UserVerificationServiceInitOptions; export function vaultTimeoutSettingsServiceFactory( cache: { vaultTimeoutSettingsService?: AbstractVaultTimeoutSettingsService } & CachedServices, @@ -38,7 +49,8 @@ export function vaultTimeoutSettingsServiceFactory( await cryptoServiceFactory(cache, opts), await tokenServiceFactory(cache, opts), await policyServiceFactory(cache, opts), - await stateServiceFactory(cache, opts) + await stateServiceFactory(cache, opts), + await userVerificationServiceFactory(cache, opts) ) ); } diff --git a/apps/browser/src/background/service_factories/state-migration-service.factory.ts b/apps/browser/src/background/service_factories/state-migration-service.factory.ts deleted file mode 100644 index 5c7fba3ed85..00000000000 --- a/apps/browser/src/background/service_factories/state-migration-service.factory.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { StateFactory } from "@bitwarden/common/factories/stateFactory"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; -import { StateMigrationService } from "@bitwarden/common/services/stateMigration.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/background/webRequest.background.ts b/apps/browser/src/background/webRequest.background.ts index ca8ed05ea3e..6dd50c23e26 100644 --- a/apps/browser/src/background/webRequest.background.ts +++ b/apps/browser/src/background/webRequest.background.ts @@ -1,10 +1,10 @@ -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { UriMatchType } from "@bitwarden/common/enums"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { BrowserApi } from "../browser/browserApi"; +import { BrowserApi } from "../platform/browser/browser-api"; export default class WebRequestBackground { private pendingAuthRequests: any[] = []; diff --git a/apps/browser/src/browser/safariApp.ts b/apps/browser/src/browser/safariApp.ts index 7a86295df67..683c9ef08a0 100644 --- a/apps/browser/src/browser/safariApp.ts +++ b/apps/browser/src/browser/safariApp.ts @@ -1,4 +1,4 @@ -import { BrowserApi } from "./browserApi"; +import { BrowserApi } from "../platform/browser/browser-api"; export class SafariApp { static sendMessageToApp(command: string, data: any = null, resolveNow = false): Promise { diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 270112ab2a5..41efa59632b 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.4.0", + "version": "2023.9.2", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", @@ -17,12 +17,7 @@ "content_scripts": [ { "all_frames": true, - "js": [ - "content/autofill.js", - "content/autofiller.js", - "content/notificationBar.js", - "content/contextMenuHandler.js" - ], + "js": ["content/trigger-autofill-script-injection.js"], "matches": ["http://*/*", "https://*/*", "file:///*"], "run_at": "document_start" }, @@ -105,7 +100,7 @@ "applications": { "gecko": { "id": "{446900e4-71c2-419f-a6a7-df9c091e268b}", - "strict_min_version": "42.0" + "strict_min_version": "91.0" } }, "sidebar_action": { diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index a66932ed1ec..ba8668984de 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.4.0", + "version": "2023.9.2", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", @@ -113,7 +113,7 @@ "applications": { "gecko": { "id": "{446900e4-71c2-419f-a6a7-df9c091e268b}", - "strict_min_version": "42.0" + "strict_min_version": "91.0" } }, "sidebar_action": { diff --git a/apps/browser/src/models/account.ts b/apps/browser/src/models/account.ts index cfbcbecf979..57d7844fde6 100644 --- a/apps/browser/src/models/account.ts +++ b/apps/browser/src/models/account.ts @@ -3,7 +3,7 @@ import { Jsonify } from "type-fest"; import { Account as BaseAccount, AccountSettings as BaseAccountSettings, -} from "@bitwarden/common/models/domain/account"; +} from "@bitwarden/common/platform/models/domain/account"; import { BrowserComponentState } from "./browserComponentState"; import { BrowserGroupingsComponentState } from "./browserGroupingsComponentState"; diff --git a/apps/browser/src/models/browserGroupingsComponentState.ts b/apps/browser/src/models/browserGroupingsComponentState.ts index 32375463701..57e80216c23 100644 --- a/apps/browser/src/models/browserGroupingsComponentState.ts +++ b/apps/browser/src/models/browserGroupingsComponentState.ts @@ -1,8 +1,8 @@ -import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { BrowserComponentState } from "./browserComponentState"; diff --git a/apps/browser/src/models/browserSendComponentState.ts b/apps/browser/src/models/browserSendComponentState.ts index d4086f3703b..9158efc21d4 100644 --- a/apps/browser/src/models/browserSendComponentState.ts +++ b/apps/browser/src/models/browserSendComponentState.ts @@ -1,4 +1,4 @@ -import { Utils } from "@bitwarden/common/misc/utils"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify"; diff --git a/apps/browser/src/alarms/alarm-state.ts b/apps/browser/src/platform/alarms/alarm-state.ts similarity index 95% rename from apps/browser/src/alarms/alarm-state.ts rename to apps/browser/src/platform/alarms/alarm-state.ts index 695f8642809..c58d6035175 100644 --- a/apps/browser/src/alarms/alarm-state.ts +++ b/apps/browser/src/platform/alarms/alarm-state.ts @@ -1,5 +1,5 @@ -import { clearClipboardAlarmName } from "../autofill/clipboard"; -import { BrowserApi } from "../browser/browserApi"; +import { clearClipboardAlarmName } from "../../autofill/clipboard"; +import { BrowserApi } from "../browser/browser-api"; export const alarmKeys = [clearClipboardAlarmName] as const; export type AlarmKeys = (typeof alarmKeys)[number]; diff --git a/apps/browser/src/alarms/on-alarm-listener.ts b/apps/browser/src/platform/alarms/on-alarm-listener.ts similarity index 86% rename from apps/browser/src/alarms/on-alarm-listener.ts rename to apps/browser/src/platform/alarms/on-alarm-listener.ts index a476b6e4961..25516930233 100644 --- a/apps/browser/src/alarms/on-alarm-listener.ts +++ b/apps/browser/src/platform/alarms/on-alarm-listener.ts @@ -1,4 +1,4 @@ -import { ClearClipboard, clearClipboardAlarmName } from "../autofill/clipboard"; +import { ClearClipboard, clearClipboardAlarmName } from "../../autofill/clipboard"; import { alarmKeys, clearAlarmTime, getAlarmTime } from "./alarm-state"; diff --git a/apps/browser/src/alarms/register-alarms.ts b/apps/browser/src/platform/alarms/register-alarms.ts similarity index 100% rename from apps/browser/src/alarms/register-alarms.ts rename to apps/browser/src/platform/alarms/register-alarms.ts diff --git a/apps/browser/src/background.html b/apps/browser/src/platform/background.html similarity index 100% rename from apps/browser/src/background.html rename to apps/browser/src/platform/background.html diff --git a/apps/browser/src/background.ts b/apps/browser/src/platform/background.ts similarity index 84% rename from apps/browser/src/background.ts rename to apps/browser/src/platform/background.ts index 2c888cfd4b4..f7913dade9f 100644 --- a/apps/browser/src/background.ts +++ b/apps/browser/src/platform/background.ts @@ -1,12 +1,14 @@ +import MainBackground from "../background/main.background"; + import { onAlarmListener } from "./alarms/on-alarm-listener"; import { registerAlarms } from "./alarms/register-alarms"; -import MainBackground from "./background/main.background"; -import { BrowserApi } from "./browser/browserApi"; +import { BrowserApi } from "./browser/browser-api"; import { contextMenusClickedListener, onCommandListener, onInstallListener, runtimeMessageListener, + windowsOnFocusChangedListener, tabsOnActivatedListener, tabsOnReplacedListener, tabsOnUpdatedListener, @@ -17,6 +19,7 @@ if (BrowserApi.manifestVersion === 3) { chrome.runtime.onInstalled.addListener(onInstallListener); chrome.alarms.onAlarm.addListener(onAlarmListener); registerAlarms(); + chrome.windows.onFocusChanged.addListener(windowsOnFocusChangedListener); chrome.tabs.onActivated.addListener(tabsOnActivatedListener); chrome.tabs.onReplaced.addListener(tabsOnReplacedListener); chrome.tabs.onUpdated.addListener(tabsOnUpdatedListener); diff --git a/apps/browser/src/background/service_factories/api-service.factory.ts b/apps/browser/src/platform/background/service-factories/api-service.factory.ts similarity index 88% rename from apps/browser/src/background/service_factories/api-service.factory.ts rename to apps/browser/src/platform/background/service-factories/api-service.factory.ts index 830d51a1a2d..bcde07fbb20 100644 --- a/apps/browser/src/background/service_factories/api-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/api-service.factory.ts @@ -4,14 +4,18 @@ import { ApiService } from "@bitwarden/common/services/api.service"; import { tokenServiceFactory, TokenServiceInitOptions, -} from "../../auth/background/service-factories/token-service.factory"; +} from "../../../auth/background/service-factories/token-service.factory"; +import { + CachedServices, + factory, + FactoryOptions, +} from "../../background/service-factories/factory-options"; import { AppIdServiceInitOptions, appIdServiceFactory } from "./app-id-service.factory"; import { environmentServiceFactory, EnvironmentServiceInitOptions, } from "./environment-service.factory"; -import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { PlatformUtilsServiceInitOptions, platformUtilsServiceFactory, diff --git a/apps/browser/src/background/service_factories/app-id-service.factory.ts b/apps/browser/src/platform/background/service-factories/app-id-service.factory.ts similarity index 84% rename from apps/browser/src/background/service_factories/app-id-service.factory.ts rename to apps/browser/src/platform/background/service-factories/app-id-service.factory.ts index 743c8eb5bc6..30397d737ef 100644 --- a/apps/browser/src/background/service_factories/app-id-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/app-id-service.factory.ts @@ -1,7 +1,7 @@ import { DiskStorageOptions } from "@koa/multer"; -import { AppIdService as AbstractAppIdService } from "@bitwarden/common/abstractions/appId.service"; -import { AppIdService } from "@bitwarden/common/services/appId.service"; +import { AppIdService as AbstractAppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { FactoryOptions, CachedServices, factory } from "./factory-options"; import { diskStorageServiceFactory } from "./storage-service.factory"; diff --git a/apps/browser/src/platform/background/service-factories/config-api.service.factory.ts b/apps/browser/src/platform/background/service-factories/config-api.service.factory.ts new file mode 100644 index 00000000000..e9e1b86488a --- /dev/null +++ b/apps/browser/src/platform/background/service-factories/config-api.service.factory.ts @@ -0,0 +1,32 @@ +import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; +import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; + +import { + authServiceFactory, + AuthServiceInitOptions, +} from "../../../auth/background/service-factories/auth-service.factory"; + +import { apiServiceFactory, ApiServiceInitOptions } from "./api-service.factory"; +import { FactoryOptions, CachedServices, factory } from "./factory-options"; + +type ConfigApiServiceFactoyOptions = FactoryOptions; + +export type ConfigApiServiceInitOptions = ConfigApiServiceFactoyOptions & + ApiServiceInitOptions & + AuthServiceInitOptions; + +export function configApiServiceFactory( + cache: { configApiService?: ConfigApiServiceAbstraction } & CachedServices, + opts: ConfigApiServiceInitOptions +): Promise { + return factory( + cache, + "configApiService", + opts, + async () => + new ConfigApiService( + await apiServiceFactory(cache, opts), + await authServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/platform/background/service-factories/config-service.factory.ts b/apps/browser/src/platform/background/service-factories/config-service.factory.ts new file mode 100644 index 00000000000..a5dc6016c65 --- /dev/null +++ b/apps/browser/src/platform/background/service-factories/config-service.factory.ts @@ -0,0 +1,49 @@ +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; + +import { + authServiceFactory, + AuthServiceInitOptions, +} from "../../../auth/background/service-factories/auth-service.factory"; + +import { configApiServiceFactory, ConfigApiServiceInitOptions } from "./config-api.service.factory"; +import { + environmentServiceFactory, + EnvironmentServiceInitOptions, +} from "./environment-service.factory"; +import { FactoryOptions, CachedServices, factory } from "./factory-options"; +import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; +import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; + +type ConfigServiceFactoryOptions = FactoryOptions & { + configServiceOptions?: { + subscribe?: boolean; + }; +}; + +export type ConfigServiceInitOptions = ConfigServiceFactoryOptions & + StateServiceInitOptions & + ConfigApiServiceInitOptions & + AuthServiceInitOptions & + EnvironmentServiceInitOptions & + LogServiceInitOptions; + +export function configServiceFactory( + cache: { configService?: ConfigServiceAbstraction } & CachedServices, + opts: ConfigServiceInitOptions +): Promise { + return factory( + cache, + "configService", + opts, + async () => + new ConfigService( + await stateServiceFactory(cache, opts), + await configApiServiceFactory(cache, opts), + await authServiceFactory(cache, opts), + await environmentServiceFactory(cache, opts), + await logServiceFactory(cache, opts), + opts.configServiceOptions?.subscribe ?? true + ) + ); +} diff --git a/apps/browser/src/background/service_factories/crypto-function-service.factory.ts b/apps/browser/src/platform/background/service-factories/crypto-function-service.factory.ts similarity index 75% rename from apps/browser/src/background/service_factories/crypto-function-service.factory.ts rename to apps/browser/src/platform/background/service-factories/crypto-function-service.factory.ts index 6a092091746..bcfffb6bd08 100644 --- a/apps/browser/src/background/service_factories/crypto-function-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/crypto-function-service.factory.ts @@ -1,5 +1,5 @@ -import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; -import { WebCryptoFunctionService } from "@bitwarden/common/services/webCryptoFunction.service"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; diff --git a/apps/browser/src/background/service_factories/crypto-service.factory.ts b/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts similarity index 77% rename from apps/browser/src/background/service_factories/crypto-service.factory.ts rename to apps/browser/src/platform/background/service-factories/crypto-service.factory.ts index 784314b12d2..7f66a4f6fe7 100644 --- a/apps/browser/src/background/service_factories/crypto-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts @@ -1,6 +1,14 @@ -import { CryptoService as AbstractCryptoService } from "@bitwarden/common/abstractions/crypto.service"; +import { CryptoService as AbstractCryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { BrowserCryptoService } from "../../services/browserCrypto.service"; +import { + StateServiceInitOptions, + stateServiceFactory, +} from "../../../platform/background/service-factories/state-service.factory"; +import { + LogServiceInitOptions, + logServiceFactory, +} from "../../background/service-factories/log-service.factory"; +import { BrowserCryptoService } from "../../services/browser-crypto.service"; import { cryptoFunctionServiceFactory, @@ -8,12 +16,10 @@ import { } from "./crypto-function-service.factory"; import { encryptServiceFactory, EncryptServiceInitOptions } from "./encrypt-service.factory"; import { FactoryOptions, CachedServices, factory } from "./factory-options"; -import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; import { - platformUtilsServiceFactory, PlatformUtilsServiceInitOptions, + platformUtilsServiceFactory, } from "./platform-utils-service.factory"; -import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; type CryptoServiceFactoryOptions = FactoryOptions; diff --git a/apps/browser/src/background/service_factories/encrypt-service.factory.ts b/apps/browser/src/platform/background/service-factories/encrypt-service.factory.ts similarity index 78% rename from apps/browser/src/background/service_factories/encrypt-service.factory.ts rename to apps/browser/src/platform/background/service-factories/encrypt-service.factory.ts index 5b2a3766a3f..75e8c1974e2 100644 --- a/apps/browser/src/background/service_factories/encrypt-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/encrypt-service.factory.ts @@ -1,11 +1,15 @@ -import { EncryptServiceImplementation } from "@bitwarden/common/services/cryptography/encrypt.service.implementation"; +import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; + +import { + LogServiceInitOptions, + logServiceFactory, +} from "../../background/service-factories/log-service.factory"; import { cryptoFunctionServiceFactory, CryptoFunctionServiceInitOptions, } from "./crypto-function-service.factory"; -import { CachedServices, factory, FactoryOptions } from "./factory-options"; -import { LogServiceInitOptions, logServiceFactory } from "./log-service.factory"; +import { FactoryOptions, CachedServices, factory } from "./factory-options"; type EncryptServiceFactoryOptions = FactoryOptions & { encryptServiceOptions: { diff --git a/apps/browser/src/background/service_factories/environment-service.factory.ts b/apps/browser/src/platform/background/service-factories/environment-service.factory.ts similarity index 100% rename from apps/browser/src/background/service_factories/environment-service.factory.ts rename to apps/browser/src/platform/background/service-factories/environment-service.factory.ts diff --git a/apps/browser/src/background/service_factories/factory-options.ts b/apps/browser/src/platform/background/service-factories/factory-options.ts similarity index 100% rename from apps/browser/src/background/service_factories/factory-options.ts rename to apps/browser/src/platform/background/service-factories/factory-options.ts diff --git a/apps/browser/src/background/service_factories/file-upload-service.factory.ts b/apps/browser/src/platform/background/service-factories/file-upload-service.factory.ts similarity index 68% rename from apps/browser/src/background/service_factories/file-upload-service.factory.ts rename to apps/browser/src/platform/background/service-factories/file-upload-service.factory.ts index 5ead09f929c..d890a3b37a7 100644 --- a/apps/browser/src/background/service_factories/file-upload-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/file-upload-service.factory.ts @@ -1,7 +1,12 @@ -import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/abstractions/file-upload/file-upload.service"; -import { FileUploadService } from "@bitwarden/common/services/file-upload/file-upload.service"; +import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; +import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; + +import { + CachedServices, + factory, + FactoryOptions, +} from "../../background/service-factories/factory-options"; -import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; type FileUploadServiceFactoryOptions = FactoryOptions; diff --git a/apps/browser/src/background/service_factories/i18n-service.factory.ts b/apps/browser/src/platform/background/service-factories/i18n-service.factory.ts similarity index 91% rename from apps/browser/src/background/service_factories/i18n-service.factory.ts rename to apps/browser/src/platform/background/service-factories/i18n-service.factory.ts index 1ba61d70b34..3dd7e1814ff 100644 --- a/apps/browser/src/background/service_factories/i18n-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/i18n-service.factory.ts @@ -1,5 +1,5 @@ -import { I18nService as AbstractI18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { I18nService as BaseI18nService } from "@bitwarden/common/services/i18n.service"; +import { I18nService as AbstractI18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service"; import I18nService from "../../services/i18n.service"; diff --git a/apps/browser/src/background/service_factories/key-generation-service.factory.ts b/apps/browser/src/platform/background/service-factories/key-generation-service.factory.ts similarity index 82% rename from apps/browser/src/background/service_factories/key-generation-service.factory.ts rename to apps/browser/src/platform/background/service-factories/key-generation-service.factory.ts index d6d31b6326e..7dbcf3fa79e 100644 --- a/apps/browser/src/background/service_factories/key-generation-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/key-generation-service.factory.ts @@ -1,10 +1,10 @@ -import { KeyGenerationService } from "../../services/keyGeneration.service"; +import { KeyGenerationService } from "../../services/key-generation.service"; import { cryptoFunctionServiceFactory, CryptoFunctionServiceInitOptions, } from "./crypto-function-service.factory"; -import { CachedServices, factory, FactoryOptions } from "./factory-options"; +import { FactoryOptions, CachedServices, factory } from "./factory-options"; type KeyGenerationServiceFactoryOptions = FactoryOptions; diff --git a/apps/browser/src/background/service_factories/log-service.factory.ts b/apps/browser/src/platform/background/service-factories/log-service.factory.ts similarity index 71% rename from apps/browser/src/background/service_factories/log-service.factory.ts rename to apps/browser/src/platform/background/service-factories/log-service.factory.ts index 286bafd9516..69e49fabb70 100644 --- a/apps/browser/src/background/service_factories/log-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/log-service.factory.ts @@ -1,8 +1,8 @@ -import { LogService } from "@bitwarden/common/abstractions/log.service"; import { LogLevelType } from "@bitwarden/common/enums"; -import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; -import { CachedServices, factory, FactoryOptions } from "./factory-options"; +import { FactoryOptions, CachedServices, factory } from "./factory-options"; type LogServiceFactoryOptions = FactoryOptions & { logServiceOptions: { diff --git a/apps/browser/src/background/service_factories/messaging-service.factory.ts b/apps/browser/src/platform/background/service-factories/messaging-service.factory.ts similarity index 65% rename from apps/browser/src/background/service_factories/messaging-service.factory.ts rename to apps/browser/src/platform/background/service-factories/messaging-service.factory.ts index 633f1b2d578..0d0c797056e 100644 --- a/apps/browser/src/background/service_factories/messaging-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/messaging-service.factory.ts @@ -1,8 +1,11 @@ -import { MessagingService as AbstractMessagingService } from "@bitwarden/common/abstractions/messaging.service"; +import { MessagingService as AbstractMessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import BrowserMessagingService from "../../services/browserMessaging.service"; - -import { CachedServices, factory, FactoryOptions } from "./factory-options"; +import { + CachedServices, + factory, + FactoryOptions, +} from "../../background/service-factories/factory-options"; +import BrowserMessagingService from "../../services/browser-messaging.service"; type MessagingServiceFactoryOptions = FactoryOptions; diff --git a/apps/browser/src/background/service_factories/platform-utils-service.factory.ts b/apps/browser/src/platform/background/service-factories/platform-utils-service.factory.ts similarity index 85% rename from apps/browser/src/background/service_factories/platform-utils-service.factory.ts rename to apps/browser/src/platform/background/service-factories/platform-utils-service.factory.ts index da25e51ce0c..5748c523f70 100644 --- a/apps/browser/src/background/service_factories/platform-utils-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/platform-utils-service.factory.ts @@ -1,6 +1,6 @@ -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import BrowserPlatformUtilsService from "../../services/browserPlatformUtils.service"; +import BrowserPlatformUtilsService from "../../services/browser-platform-utils.service"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { MessagingServiceInitOptions, messagingServiceFactory } from "./messaging-service.factory"; diff --git a/apps/browser/src/background/service_factories/state-service.factory.ts b/apps/browser/src/platform/background/service-factories/state-service.factory.ts similarity index 77% rename from apps/browser/src/background/service_factories/state-service.factory.ts rename to apps/browser/src/platform/background/service-factories/state-service.factory.ts index 6d2c5cb4fa7..7d3aaf9b6f3 100644 --- a/apps/browser/src/background/service_factories/state-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/state-service.factory.ts @@ -1,15 +1,11 @@ -import { StateFactory } from "@bitwarden/common/factories/stateFactory"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; +import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; -import { Account } from "../../models/account"; +import { Account } from "../../../models/account"; 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/background/service_factories/storage-service.factory.ts b/apps/browser/src/platform/background/service-factories/storage-service.factory.ts similarity index 81% rename from apps/browser/src/background/service_factories/storage-service.factory.ts rename to apps/browser/src/platform/background/service-factories/storage-service.factory.ts index c30bda731e6..9de8f1cffca 100644 --- a/apps/browser/src/background/service_factories/storage-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/storage-service.factory.ts @@ -1,18 +1,18 @@ import { AbstractMemoryStorageService, AbstractStorageService, -} from "@bitwarden/common/abstractions/storage.service"; -import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; +} from "@bitwarden/common/platform/abstractions/storage.service"; +import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; -import { BrowserApi } from "../../browser/browserApi"; -import BrowserLocalStorageService from "../../services/browserLocalStorage.service"; -import { LocalBackedSessionStorageService } from "../../services/localBackedSessionStorage.service"; +import { BrowserApi } from "../../browser/browser-api"; +import BrowserLocalStorageService from "../../services/browser-local-storage.service"; +import { LocalBackedSessionStorageService } from "../../services/local-backed-session-storage.service"; -import { encryptServiceFactory, EncryptServiceInitOptions } from "./encrypt-service.factory"; +import { EncryptServiceInitOptions, encryptServiceFactory } from "./encrypt-service.factory"; import { CachedServices, factory, FactoryOptions } from "./factory-options"; import { - keyGenerationServiceFactory, KeyGenerationServiceInitOptions, + keyGenerationServiceFactory, } from "./key-generation-service.factory"; type StorageServiceFactoryOptions = FactoryOptions; 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/browser/browserApi.ts b/apps/browser/src/platform/browser/browser-api.ts similarity index 67% rename from apps/browser/src/browser/browserApi.ts rename to apps/browser/src/platform/browser/browser-api.ts index b1313ebda6f..5d0c2f1a519 100644 --- a/apps/browser/src/browser/browserApi.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -1,7 +1,7 @@ -import { DeviceType } from "@bitwarden/common/enums/device-type.enum"; +import { DeviceType } from "@bitwarden/common/enums"; -import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service"; -import { TabMessage } from "../types/tab-messages"; +import { TabMessage } from "../../types/tab-messages"; +import BrowserPlatformUtilsService from "../services/browser-platform-utils.service"; export class BrowserApi { static isWebExtensionsApi: boolean = typeof browser !== "undefined"; @@ -17,6 +17,24 @@ export class BrowserApi { return chrome.runtime.getManifest().manifest_version; } + static getWindow(windowId?: number): Promise | void { + if (!windowId) { + return; + } + + return new Promise((resolve) => + chrome.windows.get(windowId, { populate: true }, (window) => resolve(window)) + ); + } + + static async createWindow(options: chrome.windows.CreateData): Promise { + return new Promise((resolve) => + chrome.windows.create(options, (window) => { + resolve(window); + }) + ); + } + static async getTabFromCurrentWindowId(): Promise | null { return await BrowserApi.tabsQueryFirst({ active: true, @@ -24,11 +42,20 @@ export class BrowserApi { }); } - static async getTab(tabId: number) { - if (tabId == null) { + static async getTab(tabId: number): Promise | null { + if (!tabId) { return null; } - return await chrome.tabs.get(tabId); + + if (BrowserApi.manifestVersion === 3) { + return await chrome.tabs.get(tabId); + } + + return new Promise((resolve) => + chrome.tabs.get(tabId, (tab) => { + resolve(tab); + }) + ); } static async getTabFromCurrentWindow(): Promise | null { @@ -105,6 +132,10 @@ export class BrowserApi { chrome.tabs.sendMessage(tabId, message, options, responseCallback); } + static async removeTab(tabId: number) { + await chrome.tabs.remove(tabId); + } + static async getPrivateModeWindows(): Promise { return (await browser.windows.getAll()).filter((win) => win.incognito); } @@ -165,32 +196,54 @@ export class BrowserApi { } const tabToClose = tabs[tabs.length - 1]; - chrome.tabs.remove(tabToClose.id); + BrowserApi.removeTab(tabToClose.id); } + // Keep track of all the events registered in a Safari popup so we can remove + // them when the popup gets unloaded, otherwise we cause a memory leak private static registeredMessageListeners: any[] = []; + private static registeredStorageChangeListeners: any[] = []; static messageListener( name: string, callback: (message: any, sender: chrome.runtime.MessageSender, response: any) => void ) { + // eslint-disable-next-line no-restricted-syntax chrome.runtime.onMessage.addListener(callback); - // Keep track of all the events registered in a Safari popup so we can remove - // them when the popup gets unloaded, otherwise we cause a memory leak if (BrowserApi.isSafariApi && !BrowserApi.isBackgroundPage(window)) { BrowserApi.registeredMessageListeners.push(callback); + BrowserApi.setupUnloadListeners(); + } + } - // The MDN recommend using 'visibilitychange' but that event is fired any time the popup window is obscured as well - // 'pagehide' works just like 'unload' but is compatible with the back/forward cache, so we prefer using that one - window.onpagehide = () => { - for (const callback of BrowserApi.registeredMessageListeners) { - chrome.runtime.onMessage.removeListener(callback); - } - }; + static storageChangeListener( + callback: Parameters[0] + ) { + // eslint-disable-next-line no-restricted-syntax + chrome.storage.onChanged.addListener(callback); + + if (BrowserApi.isSafariApi && !BrowserApi.isBackgroundPage(window)) { + BrowserApi.registeredStorageChangeListeners.push(callback); + BrowserApi.setupUnloadListeners(); } } + // Setup the event to destroy all the listeners when the popup gets unloaded in Safari, otherwise we get a memory leak + private static setupUnloadListeners() { + // The MDN recommend using 'visibilitychange' but that event is fired any time the popup window is obscured as well + // 'pagehide' works just like 'unload' but is compatible with the back/forward cache, so we prefer using that one + window.onpagehide = () => { + for (const callback of BrowserApi.registeredMessageListeners) { + chrome.runtime.onMessage.removeListener(callback); + } + + for (const callback of BrowserApi.registeredStorageChangeListeners) { + chrome.storage.onChanged.removeListener(callback); + } + }; + } + static sendMessage(subscriber: string, arg: any = {}) { const message = Object.assign({}, { command: subscriber }, arg); return chrome.runtime.sendMessage(message); @@ -227,10 +280,12 @@ export class BrowserApi { } } - static reloadOpenWindows() { + static reloadOpenWindows(exemptCurrentHref = false) { + const currentHref = window.location.href; const views = chrome.extension.getViews() as Window[]; views .filter((w) => w.location.href != null && !w.location.href.includes("background.html")) + .filter((w) => !exemptCurrentHref || w.location.href !== currentHref) .forEach((w) => { w.location.reload(); }); @@ -275,4 +330,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/decorators/dev-flag.decorator.spec.ts b/apps/browser/src/platform/decorators/dev-flag.decorator.spec.ts similarity index 100% rename from apps/browser/src/decorators/dev-flag.decorator.spec.ts rename to apps/browser/src/platform/decorators/dev-flag.decorator.spec.ts diff --git a/apps/browser/src/decorators/dev-flag.decorator.ts b/apps/browser/src/platform/decorators/dev-flag.decorator.ts similarity index 100% rename from apps/browser/src/decorators/dev-flag.decorator.ts rename to apps/browser/src/platform/decorators/dev-flag.decorator.ts diff --git a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts b/apps/browser/src/platform/decorators/session-sync-observable/browser-session.decorator.spec.ts similarity index 95% rename from apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts rename to apps/browser/src/platform/decorators/session-sync-observable/browser-session.decorator.spec.ts index ab29c86e984..5ddc3f8e07e 100644 --- a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.spec.ts +++ b/apps/browser/src/platform/decorators/session-sync-observable/browser-session.decorator.spec.ts @@ -1,7 +1,7 @@ import { BehaviorSubject } from "rxjs"; -import { AbstractMemoryStorageService } from "@bitwarden/common/abstractions/storage.service"; -import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; +import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { BrowserStateService } from "../../services/browser-state.service"; diff --git a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts b/apps/browser/src/platform/decorators/session-sync-observable/browser-session.decorator.ts similarity index 98% rename from apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts rename to apps/browser/src/platform/decorators/session-sync-observable/browser-session.decorator.ts index dbb45ddba8e..b325f6b9a56 100644 --- a/apps/browser/src/decorators/session-sync-observable/browser-session.decorator.ts +++ b/apps/browser/src/platform/decorators/session-sync-observable/browser-session.decorator.ts @@ -1,6 +1,6 @@ import { Constructor } from "type-fest"; -import { AbstractMemoryStorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { SessionStorable } from "./session-storable"; import { SessionSyncer } from "./session-syncer"; diff --git a/apps/browser/src/decorators/session-sync-observable/index.ts b/apps/browser/src/platform/decorators/session-sync-observable/index.ts similarity index 100% rename from apps/browser/src/decorators/session-sync-observable/index.ts rename to apps/browser/src/platform/decorators/session-sync-observable/index.ts diff --git a/apps/browser/src/decorators/session-sync-observable/session-storable.ts b/apps/browser/src/platform/decorators/session-sync-observable/session-storable.ts similarity index 100% rename from apps/browser/src/decorators/session-sync-observable/session-storable.ts rename to apps/browser/src/platform/decorators/session-sync-observable/session-storable.ts diff --git a/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.spec.ts b/apps/browser/src/platform/decorators/session-sync-observable/session-sync.decorator.spec.ts similarity index 100% rename from apps/browser/src/decorators/session-sync-observable/session-sync.decorator.spec.ts rename to apps/browser/src/platform/decorators/session-sync-observable/session-sync.decorator.spec.ts diff --git a/apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts b/apps/browser/src/platform/decorators/session-sync-observable/session-sync.decorator.ts similarity index 100% rename from apps/browser/src/decorators/session-sync-observable/session-sync.decorator.ts rename to apps/browser/src/platform/decorators/session-sync-observable/session-sync.decorator.ts diff --git a/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.spec.ts similarity index 98% rename from apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts rename to apps/browser/src/platform/decorators/session-sync-observable/session-syncer.spec.ts index 63312376293..afa66db0454 100644 --- a/apps/browser/src/decorators/session-sync-observable/session-syncer.spec.ts +++ b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.spec.ts @@ -2,9 +2,9 @@ import { awaitAsync } from "@bitwarden/angular/../test-utils"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, ReplaySubject } from "rxjs"; -import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; +import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; -import { BrowserApi } from "../../browser/browserApi"; +import { BrowserApi } from "../../browser/browser-api"; import { SessionSyncer } from "./session-syncer"; import { SyncedItemMetadata } from "./sync-item-metadata"; diff --git a/apps/browser/src/decorators/session-sync-observable/session-syncer.ts b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts similarity index 95% rename from apps/browser/src/decorators/session-sync-observable/session-syncer.ts rename to apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts index 8751a12d1df..001c546b9c6 100644 --- a/apps/browser/src/decorators/session-sync-observable/session-syncer.ts +++ b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts @@ -1,9 +1,9 @@ import { BehaviorSubject, concatMap, ReplaySubject, skip, Subject, Subscription } from "rxjs"; -import { AbstractMemoryStorageService } from "@bitwarden/common/abstractions/storage.service"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { BrowserApi } from "../../browser/browserApi"; +import { BrowserApi } from "../../browser/browser-api"; import { SyncedItemMetadata } from "./sync-item-metadata"; diff --git a/apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts b/apps/browser/src/platform/decorators/session-sync-observable/sync-item-metadata.ts similarity index 100% rename from apps/browser/src/decorators/session-sync-observable/sync-item-metadata.ts rename to apps/browser/src/platform/decorators/session-sync-observable/sync-item-metadata.ts diff --git a/apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts b/apps/browser/src/platform/decorators/session-sync-observable/synced-item-metadata.spec.ts similarity index 100% rename from apps/browser/src/decorators/session-sync-observable/synced-item-metadata.spec.ts rename to apps/browser/src/platform/decorators/session-sync-observable/synced-item-metadata.spec.ts diff --git a/apps/browser/src/flags.ts b/apps/browser/src/platform/flags.ts similarity index 86% rename from apps/browser/src/flags.ts rename to apps/browser/src/platform/flags.ts index 5159cac9683..270835bc6a1 100644 --- a/apps/browser/src/flags.ts +++ b/apps/browser/src/platform/flags.ts @@ -4,9 +4,9 @@ import { devFlagValue as baseDevFlagValue, SharedFlags, SharedDevFlags, -} from "@bitwarden/common/misc/flags"; +} from "@bitwarden/common/platform/misc/flags"; -import { GroupPolicyEnvironment } from "./admin-console/types/group-policy-environment"; +import { GroupPolicyEnvironment } from "../admin-console/types/group-policy-environment"; // required to avoid linting errors when there are no flags /* eslint-disable-next-line @typescript-eslint/ban-types */ diff --git a/apps/browser/src/globals.d.ts b/apps/browser/src/platform/globals.d.ts similarity index 100% rename from apps/browser/src/globals.d.ts rename to apps/browser/src/platform/globals.d.ts diff --git a/apps/browser/src/listeners/combine.spec.ts b/apps/browser/src/platform/listeners/combine.spec.ts similarity index 100% rename from apps/browser/src/listeners/combine.spec.ts rename to apps/browser/src/platform/listeners/combine.spec.ts diff --git a/apps/browser/src/listeners/combine.ts b/apps/browser/src/platform/listeners/combine.ts similarity index 86% rename from apps/browser/src/listeners/combine.ts rename to apps/browser/src/platform/listeners/combine.ts index b87631de977..91d2af7ba55 100644 --- a/apps/browser/src/listeners/combine.ts +++ b/apps/browser/src/platform/listeners/combine.ts @@ -1,4 +1,4 @@ -import { CachedServices } from "../background/service_factories/factory-options"; +import { CachedServices } from "../background/service-factories/factory-options"; type Listener = (...args: [...T, CachedServices]) => Promise; diff --git a/apps/browser/src/listeners/index.ts b/apps/browser/src/platform/listeners/index.ts similarity index 69% rename from apps/browser/src/listeners/index.ts rename to apps/browser/src/platform/listeners/index.ts index e9dc0908278..60e304402aa 100644 --- a/apps/browser/src/listeners/index.ts +++ b/apps/browser/src/platform/listeners/index.ts @@ -1,11 +1,16 @@ -import { CipherContextMenuHandler } from "../autofill/browser/cipher-context-menu-handler"; -import { ContextMenuClickedHandler } from "../autofill/browser/context-menu-clicked-handler"; +import { CipherContextMenuHandler } from "../../autofill/browser/cipher-context-menu-handler"; +import { ContextMenuClickedHandler } from "../../autofill/browser/context-menu-clicked-handler"; import { combine } from "./combine"; -import { onCommandListener } from "./onCommandListener"; -import { onInstallListener } from "./onInstallListener"; +import { onCommandListener } from "./on-command-listener"; +import { onInstallListener } from "./on-install-listener"; import { UpdateBadge } from "./update-badge"; +const windowsOnFocusChangedListener = combine([ + UpdateBadge.windowsOnFocusChangedListener, + CipherContextMenuHandler.windowsOnFocusChangedListener, +]); + const tabsOnActivatedListener = combine([ UpdateBadge.tabsOnActivatedListener, CipherContextMenuHandler.tabsOnActivatedListener, @@ -33,6 +38,7 @@ const runtimeMessageListener = combine< ]); export { + windowsOnFocusChangedListener, tabsOnActivatedListener, tabsOnReplacedListener, tabsOnUpdatedListener, diff --git a/apps/browser/src/listeners/onCommandListener.ts b/apps/browser/src/platform/listeners/on-command-listener.ts similarity index 70% rename from apps/browser/src/listeners/onCommandListener.ts rename to apps/browser/src/platform/listeners/on-command-listener.ts index 7ea7de9a0f0..0e2cf03828d 100644 --- a/apps/browser/src/listeners/onCommandListener.ts +++ b/apps/browser/src/platform/listeners/on-command-listener.ts @@ -1,20 +1,20 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { StateFactory } from "@bitwarden/common/factories/stateFactory"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; +import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; -import { authServiceFactory } from "../auth/background/service-factories/auth-service.factory"; -import { autofillServiceFactory } from "../autofill/background/service_factories/autofill-service.factory"; -import { GeneratePasswordToClipboardCommand } from "../autofill/clipboard"; -import { AutofillTabCommand } from "../autofill/commands/autofill-tab-command"; -import { CachedServices } from "../background/service_factories/factory-options"; -import { logServiceFactory } from "../background/service_factories/log-service.factory"; +import { authServiceFactory } from "../../auth/background/service-factories/auth-service.factory"; +import { autofillServiceFactory } from "../../autofill/background/service_factories/autofill-service.factory"; +import { GeneratePasswordToClipboardCommand } from "../../autofill/clipboard"; +import { AutofillTabCommand } from "../../autofill/commands/autofill-tab-command"; +import { Account } from "../../models/account"; +import { stateServiceFactory } from "../../platform/background/service-factories/state-service.factory"; import { passwordGenerationServiceFactory, PasswordGenerationServiceInitOptions, -} from "../background/service_factories/password-generation-service.factory"; -import { stateServiceFactory } from "../background/service_factories/state-service.factory"; -import { BrowserApi } from "../browser/browserApi"; -import { Account } from "../models/account"; +} from "../../tools/background/service_factories/password-generation-service.factory"; +import { CachedServices } from "../background/service-factories/factory-options"; +import { logServiceFactory } from "../background/service-factories/log-service.factory"; +import { BrowserApi } from "../browser/browser-api"; export const onCommandListener = async (command: string, tab: chrome.tabs.Tab) => { switch (command) { @@ -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(), }, @@ -91,12 +88,9 @@ const doGeneratePasswordToClipboard = async (tab: chrome.tabs.Tab): Promise Promise.resolve(true), - clipboardWriteCallback: (_clipboardValue, _clearMs) => Promise.resolve(), + clipboardWriteCallback: () => Promise.resolve(), win: self, }, - stateMigrationServiceOptions: { - stateFactory: stateFactory, - }, stateServiceOptions: { stateFactory: stateFactory, }, diff --git a/apps/browser/src/listeners/onInstallListener.ts b/apps/browser/src/platform/listeners/on-install-listener.ts similarity index 69% rename from apps/browser/src/listeners/onInstallListener.ts rename to apps/browser/src/platform/listeners/on-install-listener.ts index 92cd2fd6b78..0394941e283 100644 --- a/apps/browser/src/listeners/onInstallListener.ts +++ b/apps/browser/src/platform/listeners/on-install-listener.ts @@ -1,12 +1,12 @@ -import { StateFactory } from "@bitwarden/common/factories/stateFactory"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; +import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; +import { Account } from "../../models/account"; import { - environmentServiceFactory, EnvironmentServiceInitOptions, -} from "../background/service_factories/environment-service.factory"; -import { BrowserApi } from "../browser/browserApi"; -import { Account } from "../models/account"; + environmentServiceFactory, +} from "../background/service-factories/environment-service.factory"; +import { BrowserApi } from "../browser/browser-api"; export async function onInstallListener(details: chrome.runtime.InstalledDetails) { const cache = {}; @@ -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/listeners/update-badge.ts b/apps/browser/src/platform/listeners/update-badge.ts similarity index 85% rename from apps/browser/src/listeners/update-badge.ts rename to apps/browser/src/platform/listeners/update-badge.ts index 14a93c474ff..1b692eb9b97 100644 --- a/apps/browser/src/listeners/update-badge.ts +++ b/apps/browser/src/platform/listeners/update-badge.ts @@ -1,21 +1,21 @@ -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; -import { StateFactory } from "@bitwarden/common/factories/stateFactory"; -import { Utils } from "@bitwarden/common/misc/utils"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; -import { ContainerService } from "@bitwarden/common/services/container.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; +import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { authServiceFactory } from "../auth/background/service-factories/auth-service.factory"; -import { stateServiceFactory } from "../background/service_factories/state-service.factory"; -import { BrowserApi } from "../browser/browserApi"; -import { Account } from "../models/account"; -import { BrowserStateService } from "../services/abstractions/browser-state.service"; -import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service"; -import IconDetails from "../vault/background/models/icon-details"; -import { cipherServiceFactory } from "../vault/background/service_factories/cipher-service.factory"; +import { authServiceFactory } from "../../auth/background/service-factories/auth-service.factory"; +import { Account } from "../../models/account"; +import { stateServiceFactory } from "../../platform/background/service-factories/state-service.factory"; +import { BrowserStateService } from "../../platform/services/abstractions/browser-state.service"; +import IconDetails from "../../vault/background/models/icon-details"; +import { cipherServiceFactory } from "../../vault/background/service_factories/cipher-service.factory"; +import { BrowserApi } from "../browser/browser-api"; +import BrowserPlatformUtilsService from "../services/browser-platform-utils.service"; export type BadgeOptions = { tab?: chrome.tabs.Tab; @@ -42,6 +42,13 @@ export class UpdateBadge { "deletedCipher", ]; + static async windowsOnFocusChangedListener( + windowId: number, + serviceCache: Record + ) { + await new UpdateBadge(self).run({ windowId, existingServices: serviceCache }); + } + static async tabsOnActivatedListener( activeInfo: chrome.tabs.TabActiveInfo, serviceCache: Record @@ -265,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/polyfills/zone-patch-chrome-runtime.ts b/apps/browser/src/platform/polyfills/zone-patch-chrome-runtime.ts new file mode 100644 index 00000000000..fa731840f8b --- /dev/null +++ b/apps/browser/src/platform/polyfills/zone-patch-chrome-runtime.ts @@ -0,0 +1,36 @@ +/** + * Monkey patch `chrome.runtime.onMessage` event listeners to run in the Angular zone. + */ +Zone.__load_patch("ChromeRuntimeOnMessage", (global: any, Zone: ZoneType, api: _ZonePrivate) => { + const onMessage = global.chrome.runtime.onMessage; + if (typeof global?.chrome?.runtime?.onMessage === "undefined") { + return; + } + + // eslint-disable-next-line @typescript-eslint/ban-types + api.patchMethod(onMessage, "addListener", (delegate: Function) => (self: any, args: any[]) => { + const callback = args.length > 0 ? args[0] : null; + if (typeof callback === "function") { + const wrapperedCallback = Zone.current.wrap(callback, "ChromeRuntimeOnMessage"); + callback[api.symbol("chromeRuntimeOnMessageCallback")] = wrapperedCallback; + return delegate.call(self, wrapperedCallback); + } else { + return delegate.apply(self, args); + } + }); + + // eslint-disable-next-line @typescript-eslint/ban-types + api.patchMethod(onMessage, "removeListener", (delegate: Function) => (self: any, args: any[]) => { + const callback = args.length > 0 ? args[0] : null; + if (typeof callback === "function") { + const wrapperedCallback = callback[api.symbol("chromeRuntimeOnMessageCallback")]; + if (wrapperedCallback) { + return delegate.call(self, wrapperedCallback); + } else { + return delegate.apply(self, args); + } + } else { + return delegate.apply(self, args); + } + }); +}); diff --git a/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts b/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts new file mode 100644 index 00000000000..0ded45bea94 --- /dev/null +++ b/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts @@ -0,0 +1,33 @@ +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; + +interface BrowserPopoutWindowService { + openUnlockPrompt(senderWindowId: number): Promise; + closeUnlockPrompt(): Promise; + openPasswordRepromptPrompt( + senderWindowId: number, + promptData: { + action: string; + cipherId: string; + senderTabId: number; + } + ): Promise; + openCipherCreation( + senderWindowId: number, + promptData: { + cipherType?: CipherType; + senderTabId: number; + senderTabURI: string; + } + ): Promise; + openCipherEdit( + senderWindowId: number, + promptData: { + cipherId: string; + senderTabId: number; + senderTabURI: string; + } + ): Promise; + closePasswordRepromptPrompt(): Promise; +} + +export { BrowserPopoutWindowService }; diff --git a/apps/browser/src/platform/popup/browser-popout-window.service.ts b/apps/browser/src/platform/popup/browser-popout-window.service.ts new file mode 100644 index 00000000000..f5ac2f3128e --- /dev/null +++ b/apps/browser/src/platform/popup/browser-popout-window.service.ts @@ -0,0 +1,133 @@ +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; + +import { BrowserApi } from "../browser/browser-api"; + +import { BrowserPopoutWindowService as BrowserPopupWindowServiceInterface } from "./abstractions/browser-popout-window.service"; + +class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface { + private singleActionPopoutTabIds: Record = {}; + private defaultPopoutWindowOptions: chrome.windows.CreateData = { + type: "normal", + focused: true, + width: 500, + height: 800, + }; + + async openUnlockPrompt(senderWindowId: number) { + await this.openSingleActionPopout( + senderWindowId, + "popup/index.html?uilocation=popout", + "unlockPrompt" + ); + } + + async closeUnlockPrompt() { + await this.closeSingleActionPopout("unlockPrompt"); + } + + async openPasswordRepromptPrompt( + senderWindowId: number, + { + cipherId, + senderTabId, + action, + }: { + cipherId: string; + senderTabId: number; + action: string; + } + ) { + const promptWindowPath = + "popup/index.html#/view-cipher" + + "?uilocation=popout" + + `&cipherId=${cipherId}` + + `&senderTabId=${senderTabId}` + + `&action=${action}`; + + await this.openSingleActionPopout(senderWindowId, promptWindowPath, "passwordReprompt"); + } + + async openCipherCreation( + senderWindowId: number, + { + cipherType = CipherType.Login, + senderTabId, + senderTabURI, + }: { + cipherType?: CipherType; + senderTabId: number; + senderTabURI: string; + } + ) { + const promptWindowPath = + "popup/index.html#/edit-cipher" + + "?uilocation=popout" + + `&type=${cipherType}` + + `&senderTabId=${senderTabId}` + + `&uri=${senderTabURI}`; + + await this.openSingleActionPopout(senderWindowId, promptWindowPath, "cipherCreation"); + } + + async openCipherEdit( + senderWindowId: number, + { + cipherId, + senderTabId, + senderTabURI, + }: { + cipherId: string; + senderTabId: number; + senderTabURI: string; + } + ) { + const promptWindowPath = + "popup/index.html#/edit-cipher" + + "?uilocation=popout" + + `&cipherId=${cipherId}` + + `&senderTabId=${senderTabId}` + + `&uri=${senderTabURI}`; + + await this.openSingleActionPopout(senderWindowId, promptWindowPath, "cipherEdit"); + } + + async closePasswordRepromptPrompt() { + await this.closeSingleActionPopout("passwordReprompt"); + } + + private async openSingleActionPopout( + senderWindowId: number, + popupWindowURL: string, + singleActionPopoutKey: string + ) { + const senderWindow = senderWindowId && (await BrowserApi.getWindow(senderWindowId)); + const url = chrome.extension.getURL(popupWindowURL); + const offsetRight = 15; + const offsetTop = 90; + const popupWidth = this.defaultPopoutWindowOptions.width; + const windowOptions = senderWindow + ? { + ...this.defaultPopoutWindowOptions, + url, + left: senderWindow.left + senderWindow.width - popupWidth - offsetRight, + top: senderWindow.top + offsetTop, + } + : { ...this.defaultPopoutWindowOptions, url }; + + const popupWindow = await BrowserApi.createWindow(windowOptions); + + await this.closeSingleActionPopout(singleActionPopoutKey); + this.singleActionPopoutTabIds[singleActionPopoutKey] = popupWindow?.tabs[0].id; + } + + private async closeSingleActionPopout(popoutKey: string) { + const tabId = this.singleActionPopoutTabIds[popoutKey]; + + if (tabId) { + await BrowserApi.removeTab(tabId); + } + this.singleActionPopoutTabIds[popoutKey] = null; + } +} + +export default BrowserPopoutWindowService; diff --git a/apps/browser/src/popup/locales.ts b/apps/browser/src/platform/popup/locales.ts similarity index 100% rename from apps/browser/src/popup/locales.ts rename to apps/browser/src/platform/popup/locales.ts diff --git a/apps/browser/src/services/abstractChromeStorageApi.service.ts b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts similarity index 92% rename from apps/browser/src/services/abstractChromeStorageApi.service.ts rename to apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts index 4d83e369727..5e9c14fd3c4 100644 --- a/apps/browser/src/services/abstractChromeStorageApi.service.ts +++ b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts @@ -1,4 +1,4 @@ -import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; export default abstract class AbstractChromeStorageService implements AbstractStorageService { protected abstract chromeStorageApi: chrome.storage.StorageArea; diff --git a/apps/browser/src/services/abstractions/abstractKeyGeneration.service.ts b/apps/browser/src/platform/services/abstractions/abstract-key-generation.service.ts similarity index 54% rename from apps/browser/src/services/abstractions/abstractKeyGeneration.service.ts rename to apps/browser/src/platform/services/abstractions/abstract-key-generation.service.ts index 6a70718addf..5c2751dceff 100644 --- a/apps/browser/src/services/abstractions/abstractKeyGeneration.service.ts +++ b/apps/browser/src/platform/services/abstractions/abstract-key-generation.service.ts @@ -1,4 +1,4 @@ -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; export interface AbstractKeyGenerationService { makeEphemeralKey(numBytes?: number): Promise; diff --git a/apps/browser/src/services/abstractions/browser-state.service.ts b/apps/browser/src/platform/services/abstractions/browser-state.service.ts similarity index 70% rename from apps/browser/src/services/abstractions/browser-state.service.ts rename to apps/browser/src/platform/services/abstractions/browser-state.service.ts index 0c8a35afbd2..30b1bb98635 100644 --- a/apps/browser/src/services/abstractions/browser-state.service.ts +++ b/apps/browser/src/platform/services/abstractions/browser-state.service.ts @@ -1,10 +1,10 @@ -import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/abstractions/state.service"; -import { StorageOptions } from "@bitwarden/common/models/domain/storage-options"; +import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; +import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; -import { Account } from "../../models/account"; -import { BrowserComponentState } from "../../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; -import { BrowserSendComponentState } from "../../models/browserSendComponentState"; +import { Account } from "../../../models/account"; +import { BrowserComponentState } from "../../../models/browserComponentState"; +import { BrowserGroupingsComponentState } from "../../../models/browserGroupingsComponentState"; +import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; export abstract class BrowserStateService extends BaseStateServiceAbstraction { getBrowserGroupingComponentState: ( diff --git a/apps/browser/src/platform/services/browser-config.service.ts b/apps/browser/src/platform/services/browser-config.service.ts new file mode 100644 index 00000000000..39d1fc565eb --- /dev/null +++ b/apps/browser/src/platform/services/browser-config.service.ts @@ -0,0 +1,28 @@ +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 { LogService } from "@bitwarden/common/platform/abstractions/log.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"; + +@browserSession +export class BrowserConfigService extends ConfigService { + @sessionSync({ initializer: ServerConfig.fromJSON }) + protected _serverConfig: ReplaySubject; + + constructor( + stateService: StateService, + configApiService: ConfigApiServiceAbstraction, + authService: AuthService, + environmentService: EnvironmentService, + logService: LogService, + subscribe = false + ) { + super(stateService, configApiService, authService, environmentService, logService, subscribe); + } +} diff --git a/apps/browser/src/platform/services/browser-crypto.service.ts b/apps/browser/src/platform/services/browser-crypto.service.ts new file mode 100644 index 00000000000..a6dce0f39e9 --- /dev/null +++ b/apps/browser/src/platform/services/browser-crypto.service.ts @@ -0,0 +1,32 @@ +import { KeySuffixOptions } from "@bitwarden/common/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { + SymmetricCryptoKey, + UserKey, +} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CryptoService } from "@bitwarden/common/platform/services/crypto.service"; + +export class BrowserCryptoService extends CryptoService { + override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: string): Promise { + if (keySuffix === KeySuffixOptions.Biometric) { + return await this.stateService.getBiometricUnlock({ userId: userId }); + } + return super.hasUserKeyStored(keySuffix, userId); + } + + /** + * Browser doesn't store biometric keys, so we retrieve them from the desktop and return + * if we successfully saved it into memory as the User Key + */ + protected override async getKeyFromStorage(keySuffix: KeySuffixOptions): Promise { + if (keySuffix === KeySuffixOptions.Biometric) { + await this.platformUtilService.authenticateBiometric(); + const userKey = await this.stateService.getUserKey(); + if (userKey) { + return new SymmetricCryptoKey(Utils.fromB64ToArray(userKey.keyB64)) as UserKey; + } + } + + return await super.getKeyFromStorage(keySuffix); + } +} diff --git a/apps/browser/src/services/browser-environment.service.ts b/apps/browser/src/platform/services/browser-environment.service.ts similarity index 83% rename from apps/browser/src/services/browser-environment.service.ts rename to apps/browser/src/platform/services/browser-environment.service.ts index bad4b4b8321..08a4d811d9d 100644 --- a/apps/browser/src/services/browser-environment.service.ts +++ b/apps/browser/src/platform/services/browser-environment.service.ts @@ -1,8 +1,8 @@ -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { EnvironmentService } from "@bitwarden/common/services/environment.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; -import { GroupPolicyEnvironment } from "../admin-console/types/group-policy-environment"; +import { GroupPolicyEnvironment } from "../../admin-console/types/group-policy-environment"; import { devFlagEnabled, devFlagValue } from "../flags"; export class BrowserEnvironmentService extends EnvironmentService { diff --git a/apps/browser/src/services/browserFileDownloadService.ts b/apps/browser/src/platform/services/browser-file-download.service.ts similarity index 66% rename from apps/browser/src/services/browserFileDownloadService.ts rename to apps/browser/src/platform/services/browser-file-download.service.ts index 89ae6722b59..1ade74367f5 100644 --- a/apps/browser/src/services/browserFileDownloadService.ts +++ b/apps/browser/src/platform/services/browser-file-download.service.ts @@ -1,12 +1,12 @@ import { Injectable } from "@angular/core"; -import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service"; -import { FileDownloadBuilder } from "@bitwarden/common/abstractions/fileDownload/fileDownloadBuilder"; -import { FileDownloadRequest } from "@bitwarden/common/abstractions/fileDownload/fileDownloadRequest"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { FileDownloadBuilder } from "@bitwarden/common/platform/abstractions/file-download/file-download.builder"; +import { FileDownloadRequest } from "@bitwarden/common/platform/abstractions/file-download/file-download.request"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { BrowserApi } from "../browser/browserApi"; -import { SafariApp } from "../browser/safariApp"; +import { SafariApp } from "../../browser/safariApp"; +import { BrowserApi } from "../browser/browser-api"; @Injectable() export class BrowserFileDownloadService implements FileDownloadService { diff --git a/apps/browser/src/services/browser-i18n.service.ts b/apps/browser/src/platform/services/browser-i18n.service.ts similarity index 83% rename from apps/browser/src/services/browser-i18n.service.ts rename to apps/browser/src/platform/services/browser-i18n.service.ts index 3d2f244ab57..03406c5b706 100644 --- a/apps/browser/src/services/browser-i18n.service.ts +++ b/apps/browser/src/platform/services/browser-i18n.service.ts @@ -1,6 +1,6 @@ import { ReplaySubject } from "rxjs"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { browserSession, sessionSync } from "../decorators/session-sync-observable"; diff --git a/apps/browser/src/services/browserLocalStorage.service.ts b/apps/browser/src/platform/services/browser-local-storage.service.ts similarity index 60% rename from apps/browser/src/services/browserLocalStorage.service.ts rename to apps/browser/src/platform/services/browser-local-storage.service.ts index 2c93920df0c..8be8127d545 100644 --- a/apps/browser/src/services/browserLocalStorage.service.ts +++ b/apps/browser/src/platform/services/browser-local-storage.service.ts @@ -1,4 +1,4 @@ -import AbstractChromeStorageService from "./abstractChromeStorageApi.service"; +import AbstractChromeStorageService from "./abstractions/abstract-chrome-storage-api.service"; export default class BrowserLocalStorageService extends AbstractChromeStorageService { protected chromeStorageApi = chrome.storage.local; diff --git a/apps/browser/src/services/browserMemoryStorage.service.ts b/apps/browser/src/platform/services/browser-memory-storage.service.ts similarity index 60% rename from apps/browser/src/services/browserMemoryStorage.service.ts rename to apps/browser/src/platform/services/browser-memory-storage.service.ts index 993ae8a16ef..cdefbe45812 100644 --- a/apps/browser/src/services/browserMemoryStorage.service.ts +++ b/apps/browser/src/platform/services/browser-memory-storage.service.ts @@ -1,4 +1,4 @@ -import AbstractChromeStorageService from "./abstractChromeStorageApi.service"; +import AbstractChromeStorageService from "./abstractions/abstract-chrome-storage-api.service"; export default class BrowserMemoryStorageService extends AbstractChromeStorageService { protected chromeStorageApi = chrome.storage.session; diff --git a/apps/browser/src/services/browserMessagingPrivateModeBackground.service.ts b/apps/browser/src/platform/services/browser-messaging-private-mode-background.service.ts similarity index 74% rename from apps/browser/src/services/browserMessagingPrivateModeBackground.service.ts rename to apps/browser/src/platform/services/browser-messaging-private-mode-background.service.ts index 6d0bcd81afc..c2a6f8c5e1f 100644 --- a/apps/browser/src/services/browserMessagingPrivateModeBackground.service.ts +++ b/apps/browser/src/platform/services/browser-messaging-private-mode-background.service.ts @@ -1,4 +1,4 @@ -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; export default class BrowserMessagingPrivateModeBackgroundService implements MessagingService { send(subscriber: string, arg: any = {}) { diff --git a/apps/browser/src/services/browserMessagingPrivateModePopup.service.ts b/apps/browser/src/platform/services/browser-messaging-private-mode-popup.service.ts similarity index 74% rename from apps/browser/src/services/browserMessagingPrivateModePopup.service.ts rename to apps/browser/src/platform/services/browser-messaging-private-mode-popup.service.ts index a0a3c938acf..5572ba1ba41 100644 --- a/apps/browser/src/services/browserMessagingPrivateModePopup.service.ts +++ b/apps/browser/src/platform/services/browser-messaging-private-mode-popup.service.ts @@ -1,4 +1,4 @@ -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; export default class BrowserMessagingPrivateModePopupService implements MessagingService { send(subscriber: string, arg: any = {}) { diff --git a/apps/browser/src/services/browserMessaging.service.ts b/apps/browser/src/platform/services/browser-messaging.service.ts similarity index 54% rename from apps/browser/src/services/browserMessaging.service.ts rename to apps/browser/src/platform/services/browser-messaging.service.ts index 7832e08b323..5eff957cb50 100644 --- a/apps/browser/src/services/browserMessaging.service.ts +++ b/apps/browser/src/platform/services/browser-messaging.service.ts @@ -1,6 +1,6 @@ -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { BrowserApi } from "../browser/browserApi"; +import { BrowserApi } from "../browser/browser-api"; export default class BrowserMessagingService implements MessagingService { send(subscriber: string, arg: any = {}) { diff --git a/apps/browser/src/services/browserPlatformUtils.service.spec.ts b/apps/browser/src/platform/services/browser-platform-utils.service.spec.ts similarity index 98% rename from apps/browser/src/services/browserPlatformUtils.service.spec.ts rename to apps/browser/src/platform/services/browser-platform-utils.service.spec.ts index c0d62b4d994..964200dfcea 100644 --- a/apps/browser/src/services/browserPlatformUtils.service.spec.ts +++ b/apps/browser/src/platform/services/browser-platform-utils.service.spec.ts @@ -1,6 +1,6 @@ import { DeviceType } from "@bitwarden/common/enums"; -import BrowserPlatformUtilsService from "./browserPlatformUtils.service"; +import BrowserPlatformUtilsService from "./browser-platform-utils.service"; describe("Browser Utils Service", () => { describe("getBrowser", () => { diff --git a/apps/browser/src/services/browserPlatformUtils.service.ts b/apps/browser/src/platform/services/browser-platform-utils.service.ts similarity index 96% rename from apps/browser/src/services/browserPlatformUtils.service.ts rename to apps/browser/src/platform/services/browser-platform-utils.service.ts index 29086db9ec8..018b1c623dc 100644 --- a/apps/browser/src/services/browserPlatformUtils.service.ts +++ b/apps/browser/src/platform/services/browser-platform-utils.service.ts @@ -1,9 +1,9 @@ -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { ClientType, DeviceType } from "@bitwarden/common/enums"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { BrowserApi } from "../browser/browserApi"; -import { SafariApp } from "../browser/safariApp"; +import { SafariApp } from "../../browser/safariApp"; +import { BrowserApi } from "../browser/browser-api"; export default class BrowserPlatformUtilsService implements PlatformUtilsService { private static deviceCache: DeviceType = null; @@ -314,10 +314,6 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService return false; } - if (this.isFirefox()) { - return parseInt((await browser.runtime.getBrowserInfo()).version.split(".")[0], 10) >= 87; - } - return true; } diff --git a/apps/browser/src/services/browser-state.service.spec.ts b/apps/browser/src/platform/services/browser-state.service.spec.ts similarity index 80% rename from apps/browser/src/services/browser-state.service.spec.ts rename to apps/browser/src/platform/services/browser-state.service.spec.ts index 5ef70897b30..0712416172c 100644 --- a/apps/browser/src/services/browser-state.service.spec.ts +++ b/apps/browser/src/platform/services/browser-state.service.spec.ts @@ -1,21 +1,20 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractMemoryStorageService, AbstractStorageService, -} from "@bitwarden/common/abstractions/storage.service"; -import { StateFactory } from "@bitwarden/common/factories/stateFactory"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; -import { State } from "@bitwarden/common/models/domain/state"; -import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service"; +} 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 { State } from "@bitwarden/common/platform/models/domain/state"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; -import { Account } from "../models/account"; -import { BrowserComponentState } from "../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState"; -import { BrowserSendComponentState } from "../models/browserSendComponentState"; +import { Account } from "../../models/account"; +import { BrowserComponentState } from "../../models/browserComponentState"; +import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; +import { BrowserSendComponentState } from "../../models/browserSendComponentState"; import { BrowserStateService } from "./browser-state.service"; @@ -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,9 +37,9 @@ describe("Browser State Service", () => { secureStorageService = mock(); diskStorageService = mock(); logService = mock(); - stateMigrationService = mock(); stateFactory = mock(); - useAccountCache = true; + // turn off account cache for tests + useAccountCache = false; state = new State(new GlobalState()); state.accounts[userId] = new Account({ @@ -63,7 +61,6 @@ describe("Browser State Service", () => { secureStorageService, memoryStorageService, logService, - stateMigrationService, stateFactory, useAccountCache ); diff --git a/apps/browser/src/services/browser-state.service.ts b/apps/browser/src/platform/services/browser-state.service.ts similarity index 63% rename from apps/browser/src/services/browser-state.service.ts rename to apps/browser/src/platform/services/browser-state.service.ts index 3efd6e2edc5..ec6851beb8f 100644 --- a/apps/browser/src/services/browser-state.service.ts +++ b/apps/browser/src/platform/services/browser-state.service.ts @@ -1,14 +1,21 @@ import { BehaviorSubject } from "rxjs"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; -import { StorageOptions } from "@bitwarden/common/models/domain/storage-options"; -import { StateService as BaseStateService } from "@bitwarden/common/services/state.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { + AbstractStorageService, + AbstractMemoryStorageService, +} 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 { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; +import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service"; +import { Account } from "../../models/account"; +import { BrowserComponentState } from "../../models/browserComponentState"; +import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; +import { BrowserSendComponentState } from "../../models/browserSendComponentState"; +import { BrowserApi } from "../browser/browser-api"; import { browserSession, sessionSync } from "../decorators/session-sync-observable"; -import { Account } from "../models/account"; -import { BrowserComponentState } from "../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState"; -import { BrowserSendComponentState } from "../models/browserSendComponentState"; import { BrowserStateService as StateServiceAbstraction } from "./abstractions/browser-state.service"; @@ -26,14 +33,42 @@ export class BrowserStateService protected activeAccountSubject: BehaviorSubject; @sessionSync({ initializer: (b: boolean) => b }) protected activeAccountUnlockedSubject: BehaviorSubject; - @sessionSync({ - initializer: Account.fromJSON as any, // TODO: Remove this any when all any types are removed from Account - initializeAs: "record", - }) - protected accountDiskCache: BehaviorSubject>; protected accountDeserializer = Account.fromJSON; + constructor( + storageService: AbstractStorageService, + secureStorageService: AbstractStorageService, + memoryStorageService: AbstractMemoryStorageService, + logService: LogService, + stateFactory: StateFactory, + useAccountCache = true + ) { + super( + storageService, + secureStorageService, + memoryStorageService, + logService, + stateFactory, + useAccountCache + ); + + // TODO: This is a hack to fix having a disk cache on both the popup and + // the background page that can get out of sync. We need to work out the + // best way to handle caching with multiple instances of the state service. + if (useAccountCache) { + BrowserApi.storageChangeListener((changes, namespace) => { + if (namespace === "local") { + for (const key of Object.keys(changes)) { + if (key !== "accountActivity" && this.accountDiskCache.value[key]) { + this.deleteDiskCache(key); + } + } + } + }); + } + } + async addAccount(account: Account) { // Apply browser overrides to default account values account = new Account(account); @@ -132,4 +167,17 @@ export class BrowserStateService this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); } + + // Overriding the base class to prevent deleting the cache on save. We register a storage listener + // to delete the cache in the constructor above. + protected override async saveAccountToDisk( + account: Account, + options: StorageOptions + ): Promise { + const storageLocation = options.useSecureStorage + ? this.secureStorageService + : this.storageService; + + await storageLocation.save(`${options.userId}`, account, options); + } } diff --git a/apps/browser/src/services/i18n.service.ts b/apps/browser/src/platform/services/i18n.service.ts similarity index 98% rename from apps/browser/src/services/i18n.service.ts rename to apps/browser/src/platform/services/i18n.service.ts index eddd2559a1a..1badfdb7cb2 100644 --- a/apps/browser/src/services/i18n.service.ts +++ b/apps/browser/src/platform/services/i18n.service.ts @@ -1,4 +1,4 @@ -import { I18nService as BaseI18nService } from "@bitwarden/common/services/i18n.service"; +import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service"; export default class I18nService extends BaseI18nService { constructor(systemLanguage: string) { diff --git a/apps/browser/src/services/keyGeneration.service.ts b/apps/browser/src/platform/services/key-generation.service.ts similarity index 70% rename from apps/browser/src/services/keyGeneration.service.ts rename to apps/browser/src/platform/services/key-generation.service.ts index 0dbb1e81225..b2c76e1aee2 100644 --- a/apps/browser/src/services/keyGeneration.service.ts +++ b/apps/browser/src/platform/services/key-generation.service.ts @@ -1,7 +1,7 @@ -import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service"; +import { AbstractKeyGenerationService } from "./abstractions/abstract-key-generation.service"; export class KeyGenerationService implements AbstractKeyGenerationService { constructor(private cryptoFunctionService: CryptoFunctionService) {} diff --git a/apps/browser/src/services/localBackedSessionStorage.service.spec.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts similarity index 93% rename from apps/browser/src/services/localBackedSessionStorage.service.spec.ts rename to apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts index b5f80d4fee1..49465414721 100644 --- a/apps/browser/src/services/localBackedSessionStorage.service.spec.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts @@ -1,15 +1,15 @@ // eslint-disable-next-line no-restricted-imports import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; -import { Utils } from "@bitwarden/common/misc/utils"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; -import { EncryptServiceImplementation } from "@bitwarden/common/services/cryptography/encrypt.service.implementation"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; -import BrowserLocalStorageService from "./browserLocalStorage.service"; -import BrowserMemoryStorageService from "./browserMemoryStorage.service"; -import { KeyGenerationService } from "./keyGeneration.service"; -import { LocalBackedSessionStorageService } from "./localBackedSessionStorage.service"; +import BrowserLocalStorageService from "./browser-local-storage.service"; +import BrowserMemoryStorageService from "./browser-memory-storage.service"; +import { KeyGenerationService } from "./key-generation.service"; +import { LocalBackedSessionStorageService } from "./local-backed-session-storage.service"; describe("Browser Session Storage Service", () => { let encryptService: SubstituteOf; @@ -21,9 +21,7 @@ describe("Browser Session Storage Service", () => { let localStorage: BrowserLocalStorageService; let sessionStorage: BrowserMemoryStorageService; - const key = new SymmetricCryptoKey( - Utils.fromUtf8ToArray("00000000000000000000000000000000").buffer - ); + const key = new SymmetricCryptoKey(Utils.fromUtf8ToArray("00000000000000000000000000000000")); let getSessionKeySpy: jest.SpyInstance; const mockEnc = (input: string) => Promise.resolve(new EncString("ENCRYPTED" + input)); diff --git a/apps/browser/src/services/localBackedSessionStorage.service.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.ts similarity index 88% rename from apps/browser/src/services/localBackedSessionStorage.service.ts rename to apps/browser/src/platform/services/local-backed-session-storage.service.ts index 662a431b797..6c44adabccc 100644 --- a/apps/browser/src/services/localBackedSessionStorage.service.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.ts @@ -1,17 +1,17 @@ import { Jsonify } from "type-fest"; -import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; -import { AbstractMemoryStorageService } from "@bitwarden/common/abstractions/storage.service"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { MemoryStorageOptions } from "@bitwarden/common/models/domain/storage-options"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { MemoryStorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { devFlag } from "../decorators/dev-flag.decorator"; import { devFlagEnabled } from "../flags"; -import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service"; -import BrowserLocalStorageService from "./browserLocalStorage.service"; -import BrowserMemoryStorageService from "./browserMemoryStorage.service"; +import { AbstractKeyGenerationService } from "./abstractions/abstract-key-generation.service"; +import BrowserLocalStorageService from "./browser-local-storage.service"; +import BrowserMemoryStorageService from "./browser-memory-storage.service"; const keys = { encKey: "localEncryptionKey", diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 2fd71557f5b..8ef51913f6d 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -1,14 +1,21 @@ import { Injectable, NgModule } from "@angular/core"; import { ActivatedRouteSnapshot, RouteReuseStrategy, RouterModule, Routes } from "@angular/router"; -import { AuthGuard } from "@bitwarden/angular/auth/guards/auth.guard"; -import { LockGuard } from "@bitwarden/angular/auth/guards/lock.guard"; -import { UnauthGuard } from "@bitwarden/angular/auth/guards/unauth.guard"; +import { + redirectGuard, + AuthGuard, + lockGuard, + tdeDecryptionRequiredGuard, + UnauthGuard, +} from "@bitwarden/angular/auth/guards"; +import { canAccessFeature } from "@bitwarden/angular/guard/feature-flag.guard"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EnvironmentComponent } from "../auth/popup/environment.component"; import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; import { LockComponent } from "../auth/popup/lock.component"; +import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component"; import { LoginWithDeviceComponent } from "../auth/popup/login-with-device.component"; import { LoginComponent } from "../auth/popup/login.component"; import { RegisterComponent } from "../auth/popup/register.component"; @@ -33,11 +40,11 @@ import { ShareComponent } from "../vault/popup/components/vault/share.component" import { VaultFilterComponent } from "../vault/popup/components/vault/vault-filter.component"; import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items.component"; import { ViewComponent } from "../vault/popup/components/vault/view.component"; +import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component"; import { DebounceNavigationService } from "./services/debounceNavigationService"; import { AutofillComponent } from "./settings/autofill.component"; import { ExcludedDomainsComponent } from "./settings/excluded-domains.component"; -import { FolderAddEditComponent } from "./settings/folder-add-edit.component"; import { FoldersComponent } from "./settings/folders.component"; import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component"; import { OptionsComponent } from "./settings/options.component"; @@ -49,8 +56,11 @@ import { TabsComponent } from "./tabs.component"; const routes: Routes = [ { path: "", - redirectTo: "home", pathMatch: "full", + children: [], // Children lets us have an empty component. + canActivate: [ + redirectGuard({ loggedIn: "/tabs/current", loggedOut: "/home", locked: "/lock" }), + ], }, { path: "vault", @@ -72,13 +82,19 @@ const routes: Routes = [ { path: "login-with-device", component: LoginWithDeviceComponent, - canActivate: [UnauthGuard], + canActivate: [], + data: { state: "login-with-device" }, + }, + { + path: "admin-approval-requested", + component: LoginWithDeviceComponent, + canActivate: [], data: { state: "login-with-device" }, }, { path: "lock", component: LockComponent, - canActivate: [LockGuard], + canActivate: [lockGuard()], data: { state: "lock" }, }, { @@ -93,6 +109,14 @@ const routes: Routes = [ canActivate: [UnauthGuard], data: { state: "2fa-options" }, }, + { + path: "login-initiated", + component: LoginDecryptionOptionsComponent, + canActivate: [ + tdeDecryptionRequiredGuard(), + canAccessFeature(FeatureFlag.TrustedDeviceEncryption), + ], + }, { path: "sso", component: SsoComponent, diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index d9dc602a0cc..93f824a7dd1 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -9,20 +9,20 @@ import { import { DomSanitizer } from "@angular/platform-browser"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { IndividualConfig, ToastrService } from "ngx-toastr"; -import { filter, concatMap, Subject, takeUntil } from "rxjs"; -import Swal from "sweetalert2"; +import { filter, concatMap, Subject, takeUntil, firstValueFrom } from "rxjs"; -import { DialogServiceAbstraction, SimpleDialogOptions } from "@bitwarden/angular/services/dialog"; -import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; -import { BrowserApi } from "../browser/browserApi"; -import { BrowserStateService } from "../services/abstractions/browser-state.service"; +import { BrowserApi } from "../platform/browser/browser-api"; +import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; import { routerTransition } from "./app-routing.animations"; +import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-dialog.component"; @Component({ selector: "app-root", @@ -50,7 +50,7 @@ export class AppComponent implements OnInit, OnDestroy { private ngZone: NgZone, private sanitizer: DomSanitizer, private platformUtilsService: PlatformUtilsService, - private dialogService: DialogServiceAbstraction + private dialogService: DialogService ) {} async ngOnInit() { @@ -86,41 +86,33 @@ export class AppComponent implements OnInit, OnDestroy { sendResponse: any ) => { if (msg.command === "doneLoggingOut") { - this.ngZone.run(async () => { - this.authService.logOut(async () => { - if (msg.expired) { - this.showToast({ - type: "warning", - title: this.i18nService.t("loggedOut"), - text: this.i18nService.t("loginExpired"), - }); - } - - if (this.activeUserId === null) { - this.router.navigate(["home"]); - } - }); - this.changeDetectorRef.detectChanges(); + this.authService.logOut(async () => { + if (msg.expired) { + this.showToast({ + type: "warning", + title: this.i18nService.t("loggedOut"), + text: this.i18nService.t("loginExpired"), + }); + } + + if (this.activeUserId === null) { + this.router.navigate(["home"]); + } }); + this.changeDetectorRef.detectChanges(); } else if (msg.command === "authBlocked") { - this.ngZone.run(() => { - this.router.navigate(["home"]); - }); + this.router.navigate(["home"]); } else if (msg.command === "locked") { if (msg.userId == null || msg.userId === (await this.stateService.getUserId())) { - this.ngZone.run(() => { - this.router.navigate(["lock"]); - }); + this.router.navigate(["lock"]); } } else if (msg.command === "showDialog") { - await this.showDialog(msg); + await this.ngZone.run(() => this.showDialog(msg)); } else if (msg.command === "showNativeMessagingFinterprintDialog") { // TODO: Should be refactored to live in another service. - await this.showNativeMessagingFingerprintDialog(msg); + await this.ngZone.run(() => this.showNativeMessagingFingerprintDialog(msg)); } else if (msg.command === "showToast") { - this.ngZone.run(() => { - this.showToast(msg); - }); + this.showToast(msg); } else if (msg.command === "reloadProcess") { const forceWindowReload = this.platformUtilsService.isSafari() || @@ -132,13 +124,9 @@ export class AppComponent implements OnInit, OnDestroy { 2000 ); } else if (msg.command === "reloadPopup") { - this.ngZone.run(() => { - this.router.navigate(["/"]); - }); + this.router.navigate(["/"]); } else if (msg.command === "convertAccountToKeyConnector") { - this.ngZone.run(async () => { - this.router.navigate(["/remove-password"]); - }); + this.router.navigate(["/remove-password"]); } else { msg.webExtSender = sender; this.broadcasterService.send(msg); @@ -242,19 +230,11 @@ export class AppComponent implements OnInit, OnDestroy { } private async showNativeMessagingFingerprintDialog(msg: any) { - await Swal.fire({ - heightAuto: false, - buttonsStyling: false, - icon: "warning", - iconHtml: '', - html: `${this.i18nService.t("desktopIntegrationVerificationText")}

${ - msg.fingerprint - }`, - titleText: this.i18nService.t("desktopSyncVerificationTitle"), - showConfirmButton: true, - confirmButtonText: this.i18nService.t("ok"), - timer: 300000, + const dialogRef = DesktopSyncVerificationDialogComponent.open(this.dialogService, { + fingerprint: msg.fingerprint, }); + + return firstValueFrom(dialogRef.closed); } private async clearComponentStates() { diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 3b8501be447..9dbd87cff57 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -20,6 +20,7 @@ import { EnvironmentComponent } from "../auth/popup/environment.component"; import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; import { LockComponent } from "../auth/popup/lock.component"; +import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component"; import { LoginWithDeviceComponent } from "../auth/popup/login-with-device.component"; import { LoginComponent } from "../auth/popup/login.component"; import { RegisterComponent } from "../auth/popup/register.component"; @@ -32,14 +33,12 @@ 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"; import { ExportComponent } from "../tools/popup/settings/export.component"; import { ActionButtonsComponent } from "../vault/popup/components/action-buttons.component"; import { CipherRowComponent } from "../vault/popup/components/cipher-row.component"; -import { PasswordRepromptComponent } from "../vault/popup/components/password-reprompt.component"; import { AddEditCustomFieldsComponent } from "../vault/popup/components/vault/add-edit-custom-fields.component"; import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component"; import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component"; @@ -52,6 +51,7 @@ import { VaultItemsComponent } from "../vault/popup/components/vault/vault-items import { VaultSelectComponent } from "../vault/popup/components/vault/vault-select.component"; import { ViewCustomFieldsComponent } from "../vault/popup/components/vault/view-custom-fields.component"; import { ViewComponent } from "../vault/popup/components/vault/view.component"; +import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component"; import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; @@ -60,10 +60,8 @@ import { PrivateModeWarningComponent } from "./components/private-mode-warning.c import { SetPinComponent } from "./components/set-pin.component"; import { UserVerificationComponent } from "./components/user-verification.component"; import { ServicesModule } from "./services/services.module"; -import { AboutComponent } from "./settings/about.component"; import { AutofillComponent } from "./settings/autofill.component"; import { ExcludedDomainsComponent } from "./settings/excluded-domains.component"; -import { FolderAddEditComponent } from "./settings/folder-add-edit.component"; import { FoldersComponent } from "./settings/folders.component"; import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component"; import { OptionsComponent } from "./settings/options.component"; @@ -74,7 +72,7 @@ import { VaultTimeoutInputComponent } from "./settings/vault-timeout-input.compo import { TabsComponent } from "./tabs.component"; // Register the locales for the application -import "./locales"; +import "../platform/popup/locales"; @NgModule({ imports: [ @@ -121,17 +119,16 @@ import "./locales"; LockComponent, LoginComponent, LoginWithDeviceComponent, + LoginDecryptionOptionsComponent, OptionsComponent, GeneratorComponent, PasswordGeneratorHistoryComponent, PasswordHistoryComponent, - PasswordRepromptComponent, PopOutComponent, PremiumComponent, PrivateModeWarningComponent, RegisterComponent, SendAddEditComponent, - SendEffluxDatesComponent, SendGroupingsComponent, SendListComponent, SendTypeComponent, @@ -151,7 +148,6 @@ import "./locales"; ViewCustomFieldsComponent, RemovePasswordComponent, VaultSelectComponent, - AboutComponent, HelpAndFeedbackComponent, AutofillComponent, EnvironmentSelectorComponent, diff --git a/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.html b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.html new file mode 100644 index 00000000000..a2a2cd97805 --- /dev/null +++ b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.html @@ -0,0 +1,18 @@ + + + {{ "desktopSyncVerificationTitle" | i18n }} + + +

+ {{ "desktopIntegrationVerificationText" | i18n }} +

+

+ {{ params.fingerprint.join("-") }} +

+
+ + + +
diff --git a/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts new file mode 100644 index 00000000000..c860ef1e342 --- /dev/null +++ b/apps/browser/src/popup/components/desktop-sync-verification-dialog.component.ts @@ -0,0 +1,24 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; + +export type DesktopSyncVerificationDialogParams = { + fingerprint: string[]; +}; + +@Component({ + templateUrl: "desktop-sync-verification-dialog.component.html", + standalone: true, + imports: [JslibModule, ButtonModule, DialogModule], +}) +export class DesktopSyncVerificationDialogComponent { + constructor(@Inject(DIALOG_DATA) protected params: DesktopSyncVerificationDialogParams) {} + + static open(dialogService: DialogService, data: DesktopSyncVerificationDialogParams) { + return dialogService.open(DesktopSyncVerificationDialogComponent, { + data, + }); + } +} diff --git a/apps/browser/src/popup/components/pop-out.component.ts b/apps/browser/src/popup/components/pop-out.component.ts index f3cf66c13f0..b8675ec4d4c 100644 --- a/apps/browser/src/popup/components/pop-out.component.ts +++ b/apps/browser/src/popup/components/pop-out.component.ts @@ -1,6 +1,6 @@ import { Component, Input, OnInit } from "@angular/core"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PopupUtilsService } from "../services/popup-utils.service"; diff --git a/apps/browser/src/popup/components/user-verification.component.html b/apps/browser/src/popup/components/user-verification.component.html index 8d7f1ed8706..25bd81cf394 100644 --- a/apps/browser/src/popup/components/user-verification.component.html +++ b/apps/browser/src/popup/components/user-verification.component.html @@ -1,4 +1,4 @@ - +
- +
-
-
+ +

+ {{ "serverVersion" | i18n }} ({{ "selfHostedServer" | i18n }}): + {{ this.serverConfig?.version }} + + ({{ "lastSeenOn" | i18n : (serverConfig.utcDate | date : "mediumDate") }}) + +

+ + +

+
+
-
+ diff --git a/apps/browser/src/popup/settings/about.component.ts b/apps/browser/src/popup/settings/about.component.ts index 338a00e2685..c0c9012f341 100644 --- a/apps/browser/src/popup/settings/about.component.ts +++ b/apps/browser/src/popup/settings/about.component.ts @@ -1,25 +1,29 @@ +import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; import { Observable } from "rxjs"; -import { ConfigServiceAbstraction } from "@bitwarden/common/abstractions/config/config.service.abstraction"; -import { ServerConfig } from "@bitwarden/common/abstractions/config/server-config"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { ButtonModule, DialogModule } from "@bitwarden/components"; -import { BrowserApi } from "../../browser/browserApi"; +import { BrowserApi } from "../../platform/browser/browser-api"; @Component({ - selector: "app-about", templateUrl: "about.component.html", + standalone: true, + imports: [CommonModule, JslibModule, DialogModule, ButtonModule], }) export class AboutComponent { - serverConfig$: Observable; + protected serverConfig$: Observable = this.configService.serverConfig$; - year = new Date().getFullYear(); - version = BrowserApi.getApplicationVersion(); - isCloud: boolean; + protected year = new Date().getFullYear(); + protected version = BrowserApi.getApplicationVersion(); + protected isCloud = this.environmentService.isCloud(); - constructor(configService: ConfigServiceAbstraction, environmentService: EnvironmentService) { - this.serverConfig$ = configService.serverConfig$; - this.isCloud = environmentService.isCloud(); - } + constructor( + private configService: ConfigServiceAbstraction, + private environmentService: EnvironmentService + ) {} } diff --git a/apps/browser/src/popup/settings/autofill.component.ts b/apps/browser/src/popup/settings/autofill.component.ts index 05975e60920..477c0592664 100644 --- a/apps/browser/src/popup/settings/autofill.component.ts +++ b/apps/browser/src/popup/settings/autofill.component.ts @@ -1,11 +1,11 @@ import { Component, OnInit } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { UriMatchType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { BrowserApi } from "../../browser/browserApi"; +import { BrowserApi } from "../../platform/browser/browser-api"; @Component({ selector: "app-autofill", diff --git a/apps/browser/src/popup/settings/await-desktop-dialog.component.html b/apps/browser/src/popup/settings/await-desktop-dialog.component.html new file mode 100644 index 00000000000..688071a15d6 --- /dev/null +++ b/apps/browser/src/popup/settings/await-desktop-dialog.component.html @@ -0,0 +1,11 @@ + + {{ "awaitDesktop" | i18n }} + + {{ "awaitDesktopDesc" | i18n }} + + + + + diff --git a/apps/browser/src/popup/settings/await-desktop-dialog.component.ts b/apps/browser/src/popup/settings/await-desktop-dialog.component.ts new file mode 100644 index 00000000000..9ed6efe036f --- /dev/null +++ b/apps/browser/src/popup/settings/await-desktop-dialog.component.ts @@ -0,0 +1,17 @@ +import { Component } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; + +@Component({ + templateUrl: "await-desktop-dialog.component.html", + standalone: true, + imports: [JslibModule, ButtonModule, DialogModule], +}) +export class AwaitDesktopDialogComponent { + static open(dialogService: DialogService) { + return dialogService.open(AwaitDesktopDialogComponent, { + disableClose: true, + }); + } +} diff --git a/apps/browser/src/popup/settings/excluded-domains.component.ts b/apps/browser/src/popup/settings/excluded-domains.component.ts index 74c336ad85b..7adedd72240 100644 --- a/apps/browser/src/popup/settings/excluded-domains.component.ts +++ b/apps/browser/src/popup/settings/excluded-domains.component.ts @@ -1,13 +1,13 @@ import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { BrowserApi } from "../../browser/browserApi"; +import { BrowserApi } from "../../platform/browser/browser-api"; interface ExcludedDomain { uri: string; diff --git a/apps/browser/src/popup/settings/help-and-feedback.component.ts b/apps/browser/src/popup/settings/help-and-feedback.component.ts index 006f76f48bc..0ff9887353c 100644 --- a/apps/browser/src/popup/settings/help-and-feedback.component.ts +++ b/apps/browser/src/popup/settings/help-and-feedback.component.ts @@ -1,6 +1,6 @@ import { Component } from "@angular/core"; -import { BrowserApi } from "../../browser/browserApi"; +import { BrowserApi } from "../../platform/browser/browser-api"; @Component({ selector: "app-help-and-feedback", diff --git a/apps/browser/src/popup/settings/options.component.html b/apps/browser/src/popup/settings/options.component.html index 3f60d4e68f6..a356be5f735 100644 --- a/apps/browser/src/popup/settings/options.component.html +++ b/apps/browser/src/popup/settings/options.component.html @@ -21,7 +21,7 @@

> - General + {{ "general" | i18n }}

@@ -122,7 +122,7 @@

> - Display + {{ "display" | i18n }}

diff --git a/apps/browser/src/popup/settings/options.component.ts b/apps/browser/src/popup/settings/options.component.ts index e5d7f4b80ed..b020816158c 100644 --- a/apps/browser/src/popup/settings/options.component.ts +++ b/apps/browser/src/popup/settings/options.component.ts @@ -1,12 +1,12 @@ import { Component, OnInit } from "@angular/core"; import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { TotpService } from "@bitwarden/common/abstractions/totp.service"; import { ThemeType, UriMatchType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @Component({ selector: "app-options", 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/popup/settings/premium.component.ts b/apps/browser/src/popup/settings/premium.component.ts index 63d45d79141..e57d53f3c40 100644 --- a/apps/browser/src/popup/settings/premium.component.ts +++ b/apps/browser/src/popup/settings/premium.component.ts @@ -1,13 +1,14 @@ import { CurrencyPipe, Location } from "@angular/common"; import { Component } from "@angular/core"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; import { PremiumComponent as BasePremiumComponent } from "@bitwarden/angular/vault/components/premium.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { DialogService } from "@bitwarden/components"; @Component({ selector: "app-premium", @@ -24,13 +25,26 @@ export class PremiumComponent extends BasePremiumComponent { logService: LogService, private location: Location, private currencyPipe: CurrencyPipe, - dialogService: DialogServiceAbstraction + dialogService: DialogService, + environmentService: EnvironmentService ) { - super(i18nService, platformUtilsService, apiService, logService, stateService, dialogService); + super( + i18nService, + platformUtilsService, + apiService, + logService, + stateService, + dialogService, + environmentService + ); // Support old price string. Can be removed in future once all translations are properly updated. const thePrice = this.currencyPipe.transform(this.price, "$"); - this.priceString = i18nService.t("premiumPrice", thePrice); + // Safari extension crashes due to $1 appearing in the price string ($10.00). Escape the $ to fix. + const formattedPrice = this.platformUtilsService.isSafari() + ? thePrice.replace("$", "$$$") + : thePrice; + this.priceString = i18nService.t("premiumPrice", formattedPrice); if (this.priceString.indexOf("%price%") > -1) { this.priceString = this.priceString.replace("%price%", thePrice); } diff --git a/apps/browser/src/popup/settings/settings.component.html b/apps/browser/src/popup/settings/settings.component.html index 987127d3d84..6591d11cc19 100644 --- a/apps/browser/src/popup/settings/settings.component.html +++ b/apps/browser/src/popup/settings/settings.component.html @@ -71,28 +71,29 @@

    {{ "security" | i18n }}

    +
    - +
    - +
    {{ "security" | i18n }}
  • />
    -
    +
    -
    +
    - + (blur)="saveUsernameOptions()" />
    +
    + + +
    @@ -405,6 +422,28 @@

    />

    + +
    + + +
    +
    + + +
    +
    diff --git a/apps/browser/src/tools/popup/generator/generator.component.ts b/apps/browser/src/tools/popup/generator/generator.component.ts index e869f6d7794..03d7442e6eb 100644 --- a/apps/browser/src/tools/popup/generator/generator.component.ts +++ b/apps/browser/src/tools/popup/generator/generator.component.ts @@ -3,10 +3,10 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { GeneratorComponent as BaseGeneratorComponent } from "@bitwarden/angular/tools/generator/components/generator.component"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; diff --git a/apps/browser/src/tools/popup/generator/password-generator-history.component.html b/apps/browser/src/tools/popup/generator/password-generator-history.component.html index f57bbcbb1ec..22e07810d07 100644 --- a/apps/browser/src/tools/popup/generator/password-generator-history.component.html +++ b/apps/browser/src/tools/popup/generator/password-generator-history.component.html @@ -22,7 +22,7 @@

    {{ h.date | date : "medium" }} diff --git a/apps/browser/src/tools/popup/generator/password-generator-history.component.ts b/apps/browser/src/tools/popup/generator/password-generator-history.component.ts index 18110fc5d96..fbe1ba10d33 100644 --- a/apps/browser/src/tools/popup/generator/password-generator-history.component.ts +++ b/apps/browser/src/tools/popup/generator/password-generator-history.component.ts @@ -2,8 +2,8 @@ import { Location } from "@angular/common"; import { Component } from "@angular/core"; import { PasswordGeneratorHistoryComponent as BasePasswordGeneratorHistoryComponent } from "@bitwarden/angular/tools/generator/components/password-generator-history.component"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; @Component({ 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 0578cce2dda..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/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.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..11dca222fca 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 }}

    - @@ -27,7 +27,7 @@

    icon="bwi-external-link bwi-rotate-270 bwi-fw" [clickable]="true" title="{{ 'sendFileCalloutHeader' | i18n }}" - *ngIf="showFilePopoutMessage && send.type === sendType.File && !disableSend" + *ngIf="showFilePopoutMessage && type === sendType.File && !disableSend" (click)="popOutWindow()" >
    {{ "sendLinuxChromiumFileWarning" | i18n }}
    @@ -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 8a45d39dcc8..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,21 +1,22 @@ 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"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { DialogService } from "@bitwarden/components"; +import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service"; import { PopupUtilsService } from "../../../popup/services/popup-utils.service"; -import { BrowserStateService } from "../../../services/abstractions/browser-state.service"; @Component({ selector: "app-send-add-edit", @@ -47,7 +48,8 @@ export class SendAddEditComponent extends BaseAddEditComponent { private popupUtilsService: PopupUtilsService, logService: LogService, sendApiService: SendApiService, - dialogService: DialogServiceAbstraction + 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/tools/popup/send/send-groupings.component.ts b/apps/browser/src/tools/popup/send/send-groupings.component.ts index 8c84df39768..e93064fe80b 100644 --- a/apps/browser/src/tools/popup/send/send-groupings.component.ts +++ b/apps/browser/src/tools/popup/send/send-groupings.component.ts @@ -1,24 +1,24 @@ import { ChangeDetectorRef, Component, NgZone } from "@angular/core"; import { Router } from "@angular/router"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component"; -import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { DialogService } from "@bitwarden/components"; import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; +import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service"; import { PopupUtilsService } from "../../../popup/services/popup-utils.service"; -import { BrowserStateService } from "../../../services/abstractions/browser-state.service"; const ComponentId = "SendComponent"; @@ -51,7 +51,7 @@ export class SendGroupingsComponent extends BaseSendComponent { private broadcasterService: BroadcasterService, logService: LogService, sendApiService: SendApiService, - dialogService: DialogServiceAbstraction + dialogService: DialogService ) { super( sendService, diff --git a/apps/browser/src/tools/popup/send/send-type.component.ts b/apps/browser/src/tools/popup/send/send-type.component.ts index f7637070413..4a2794fdda5 100644 --- a/apps/browser/src/tools/popup/send/send-type.component.ts +++ b/apps/browser/src/tools/popup/send/send-type.component.ts @@ -3,23 +3,23 @@ import { ChangeDetectorRef, Component, NgZone } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { first } from "rxjs/operators"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component"; -import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { DialogService } from "@bitwarden/components"; import { BrowserComponentState } from "../../../models/browserComponentState"; +import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service"; import { PopupUtilsService } from "../../../popup/services/popup-utils.service"; -import { BrowserStateService } from "../../../services/abstractions/browser-state.service"; const ComponentId = "SendTypeComponent"; @@ -51,7 +51,7 @@ export class SendTypeComponent extends BaseSendComponent { private router: Router, logService: LogService, sendApiService: SendApiService, - dialogService: DialogServiceAbstraction + dialogService: DialogService ) { super( sendService, diff --git a/apps/browser/src/tools/popup/settings/export.component.ts b/apps/browser/src/tools/popup/settings/export.component.ts index b90dda05232..d34b86a9e46 100644 --- a/apps/browser/src/tools/popup/settings/export.component.ts +++ b/apps/browser/src/tools/popup/settings/export.component.ts @@ -2,16 +2,16 @@ import { Component } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; import { Router } from "@angular/router"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; import { ExportComponent as BaseExportComponent } from "@bitwarden/angular/tools/export/components/export.component"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; import { VaultExportServiceAbstraction } from "@bitwarden/exporter/vault-export"; @Component({ @@ -31,7 +31,7 @@ export class ExportComponent extends BaseExportComponent { userVerificationService: UserVerificationService, formBuilder: UntypedFormBuilder, fileDownloadService: FileDownloadService, - dialogService: DialogServiceAbstraction + dialogService: DialogService ) { super( cryptoService, diff --git a/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts b/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts index 24cc6f2f89a..46062ebc9cc 100644 --- a/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts +++ b/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts @@ -1,43 +1,47 @@ import { CipherService as AbstractCipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; +import { + CipherFileUploadServiceInitOptions, + cipherFileUploadServiceFactory, +} from "../../../background/service-factories/cipher-file-upload-service.factory"; +import { + searchServiceFactory, + SearchServiceInitOptions, +} from "../../../background/service-factories/search-service.factory"; +import { + SettingsServiceInitOptions, + settingsServiceFactory, +} from "../../../background/service-factories/settings-service.factory"; import { apiServiceFactory, ApiServiceInitOptions, -} from "../../../background/service_factories/api-service.factory"; +} from "../../../platform/background/service-factories/api-service.factory"; import { - CipherFileUploadServiceInitOptions, - cipherFileUploadServiceFactory, -} from "../../../background/service_factories/cipher-file-upload-service.factory"; + configServiceFactory, + ConfigServiceInitOptions, +} from "../../../platform/background/service-factories/config-service.factory"; import { cryptoServiceFactory, CryptoServiceInitOptions, -} from "../../../background/service_factories/crypto-service.factory"; +} from "../../../platform/background/service-factories/crypto-service.factory"; import { - encryptServiceFactory, EncryptServiceInitOptions, -} from "../../../background/service_factories/encrypt-service.factory"; + encryptServiceFactory, +} from "../../../platform/background/service-factories/encrypt-service.factory"; import { CachedServices, factory, FactoryOptions, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; import { i18nServiceFactory, I18nServiceInitOptions, -} from "../../../background/service_factories/i18n-service.factory"; -import { - searchServiceFactory, - SearchServiceInitOptions, -} from "../../../background/service_factories/search-service.factory"; -import { - SettingsServiceInitOptions, - settingsServiceFactory, -} from "../../../background/service_factories/settings-service.factory"; +} from "../../../platform/background/service-factories/i18n-service.factory"; import { stateServiceFactory, StateServiceInitOptions, -} from "../../../background/service_factories/state-service.factory"; +} from "../../../platform/background/service-factories/state-service.factory"; type CipherServiceFactoryOptions = FactoryOptions; @@ -49,7 +53,8 @@ export type CipherServiceInitOptions = CipherServiceFactoryOptions & I18nServiceInitOptions & SearchServiceInitOptions & StateServiceInitOptions & - EncryptServiceInitOptions; + EncryptServiceInitOptions & + ConfigServiceInitOptions; export function cipherServiceFactory( cache: { cipherService?: AbstractCipherService } & CachedServices, @@ -68,7 +73,8 @@ export function cipherServiceFactory( await searchServiceFactory(cache, opts), await stateServiceFactory(cache, opts), await encryptServiceFactory(cache, opts), - await cipherFileUploadServiceFactory(cache, opts) + await cipherFileUploadServiceFactory(cache, opts), + await configServiceFactory(cache, opts) ) ); } diff --git a/apps/browser/src/admin-console/background/service-factories/collection-service.factory.ts b/apps/browser/src/vault/background/service_factories/collection-service.factory.ts similarity index 68% rename from apps/browser/src/admin-console/background/service-factories/collection-service.factory.ts rename to apps/browser/src/vault/background/service_factories/collection-service.factory.ts index 0035eee8c42..323eebe27d1 100644 --- a/apps/browser/src/admin-console/background/service-factories/collection-service.factory.ts +++ b/apps/browser/src/vault/background/service_factories/collection-service.factory.ts @@ -1,23 +1,23 @@ -import { CollectionService as AbstractCollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service"; -import { CollectionService } from "@bitwarden/common/admin-console/services/collection.service"; +import { CollectionService as AbstractCollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { CollectionService } from "@bitwarden/common/vault/services/collection.service"; import { cryptoServiceFactory, CryptoServiceInitOptions, -} from "../../../background/service_factories/crypto-service.factory"; +} from "../../../platform/background/service-factories/crypto-service.factory"; import { CachedServices, factory, FactoryOptions, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; import { i18nServiceFactory, I18nServiceInitOptions, -} from "../../../background/service_factories/i18n-service.factory"; +} from "../../../platform/background/service-factories/i18n-service.factory"; import { stateServiceFactory as stateServiceFactory, StateServiceInitOptions, -} from "../../../background/service_factories/state-service.factory"; +} from "../../../platform/background/service-factories/state-service.factory"; type CollectionServiceFactoryOptions = FactoryOptions; diff --git a/apps/browser/src/vault/background/service_factories/folder-service.factory.ts b/apps/browser/src/vault/background/service_factories/folder-service.factory.ts index ba6cad613fc..b33c79a012b 100644 --- a/apps/browser/src/vault/background/service_factories/folder-service.factory.ts +++ b/apps/browser/src/vault/background/service_factories/folder-service.factory.ts @@ -1,22 +1,22 @@ import { FolderService as AbstractFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { - cryptoServiceFactory, CryptoServiceInitOptions, -} from "../../../background/service_factories/crypto-service.factory"; + cryptoServiceFactory, +} from "../../../platform/background/service-factories/crypto-service.factory"; import { CachedServices, factory, FactoryOptions, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; import { i18nServiceFactory, I18nServiceInitOptions, -} from "../../../background/service_factories/i18n-service.factory"; +} from "../../../platform/background/service-factories/i18n-service.factory"; import { stateServiceFactory as stateServiceFactory, StateServiceInitOptions, -} from "../../../background/service_factories/state-service.factory"; +} from "../../../platform/background/service-factories/state-service.factory"; import { BrowserFolderService } from "../../services/browser-folder.service"; import { cipherServiceFactory, CipherServiceInitOptions } from "./cipher-service.factory"; diff --git a/apps/browser/src/vault/background/service_factories/sync-notifier-service.factory.ts b/apps/browser/src/vault/background/service_factories/sync-notifier-service.factory.ts index 78ebb0c7730..9e976b3bf75 100644 --- a/apps/browser/src/vault/background/service_factories/sync-notifier-service.factory.ts +++ b/apps/browser/src/vault/background/service_factories/sync-notifier-service.factory.ts @@ -5,7 +5,7 @@ import { FactoryOptions, CachedServices, factory, -} from "../../../background/service_factories/factory-options"; +} from "../../../platform/background/service-factories/factory-options"; type SyncNotifierServiceFactoryOptions = FactoryOptions; diff --git a/apps/browser/src/vault/popup/components/action-buttons.component.ts b/apps/browser/src/vault/popup/components/action-buttons.component.ts index fb6f7d78110..ff8b7cab0d1 100644 --- a/apps/browser/src/vault/popup/components/action-buttons.component.ts +++ b/apps/browser/src/vault/popup/components/action-buttons.component.ts @@ -1,15 +1,15 @@ import { Component, EventEmitter, Input, Output } from "@angular/core"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { TotpService } from "@bitwarden/common/abstractions/totp.service"; import { EventType } from "@bitwarden/common/enums"; -import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { PasswordRepromptService } from "@bitwarden/vault"; @Component({ selector: "app-action-buttons", diff --git a/apps/browser/src/vault/popup/components/password-reprompt.component.html b/apps/browser/src/vault/popup/components/password-reprompt.component.html deleted file mode 100644 index 730e96fab96..00000000000 --- a/apps/browser/src/vault/popup/components/password-reprompt.component.html +++ /dev/null @@ -1,55 +0,0 @@ - diff --git a/apps/browser/src/vault/popup/components/password-reprompt.component.ts b/apps/browser/src/vault/popup/components/password-reprompt.component.ts deleted file mode 100644 index f63da5ed48e..00000000000 --- a/apps/browser/src/vault/popup/components/password-reprompt.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from "@angular/core"; - -import { PasswordRepromptComponent as BasePasswordRepromptComponent } from "@bitwarden/angular/vault/components/password-reprompt.component"; - -@Component({ - templateUrl: "password-reprompt.component.html", -}) -export class PasswordRepromptComponent extends BasePasswordRepromptComponent {} diff --git a/apps/browser/src/vault/popup/components/vault/add-edit-custom-fields.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit-custom-fields.component.ts index 517e5016c6f..6992455a8a6 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit-custom-fields.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit-custom-fields.component.ts @@ -2,7 +2,7 @@ import { Component } from "@angular/core"; import { AddEditCustomFieldsComponent as BaseAddEditCustomFieldsComponent } from "@bitwarden/angular/vault/components/add-edit-custom-fields.component"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @Component({ selector: "app-vault-add-edit-custom-fields", diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.html b/apps/browser/src/vault/popup/components/vault/add-edit.component.html index 9deda95d365..dda71cb0d6e 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.html +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.html @@ -163,6 +163,7 @@

    class="monospaced" type="{{ showCardNumber ? 'text' : 'password' }}" name="Card.Number" + (input)="onCardNumberChange()" [(ngModel)]="cipher.card.number" appInputVerbatim [readonly]="!cipher.edit && editMode" @@ -563,6 +564,7 @@

    +
    diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index 1c4dc5dadef..fd75b2e8c58 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -3,26 +3,26 @@ import { Component } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { first } from "rxjs/operators"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { DialogService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; -import { BrowserApi } from "../../../../browser/browserApi"; +import { BrowserApi } from "../../../../platform/browser/browser-api"; import { PopupUtilsService } from "../../../../popup/services/popup-utils.service"; @Component({ @@ -35,6 +35,9 @@ export class AddEditComponent extends BaseAddEditComponent { showAttachments = true; openAttachmentsInPopup: boolean; showAutoFillOnPageLoadOptions: boolean; + senderTabId?: number; + uilocation?: "popout" | "popup" | "sidebar" | "tab"; + inPopout = false; constructor( cipherService: CipherService, @@ -55,7 +58,7 @@ export class AddEditComponent extends BaseAddEditComponent { passwordRepromptService: PasswordRepromptService, logService: LogService, sendApiService: SendApiService, - dialogService: DialogServiceAbstraction + dialogService: DialogService ) { super( cipherService, @@ -81,6 +84,9 @@ export class AddEditComponent extends BaseAddEditComponent { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.queryParams.pipe(first()).subscribe(async (params) => { + this.senderTabId = parseInt(params?.senderTabId, 10) || undefined; + this.uilocation = params?.uilocation; + if (params.cipherId) { this.cipherId = params.cipherId; } @@ -128,6 +134,8 @@ export class AddEditComponent extends BaseAddEditComponent { this.openAttachmentsInPopup = this.popupUtilsService.inPopup(window); }); + this.inPopout = this.uilocation === "popout" || this.popupUtilsService.inPopout(window); + if (!this.editMode) { const tabs = await BrowserApi.tabsQuery({ windowType: "normal" }); this.currentUris = @@ -162,6 +170,11 @@ export class AddEditComponent extends BaseAddEditComponent { return true; } + if (this.senderTabId && this.inPopout) { + setTimeout(() => this.close(), 1000); + return true; + } + if (this.cloneMode) { this.router.navigate(["/tabs/vault"]); } else { @@ -194,6 +207,11 @@ export class AddEditComponent extends BaseAddEditComponent { cancel() { super.cancel(); + if (this.senderTabId && this.inPopout) { + this.close(); + return; + } + if (this.popupUtilsService.inTab(window)) { this.messagingService.send("closeTab"); return; @@ -202,6 +220,14 @@ export class AddEditComponent extends BaseAddEditComponent { this.location.back(); } + // Used for closing single-action views + close() { + BrowserApi.focusTab(this.senderTabId); + window.close(); + + return; + } + async generateUsername(): Promise { const confirmed = await super.generateUsername(); if (confirmed) { @@ -264,4 +290,27 @@ export class AddEditComponent extends BaseAddEditComponent { } }, 200); } + + repromptChanged() { + super.repromptChanged(); + + if (!this.showAutoFillOnPageLoadOptions) { + return; + } + + if (this.reprompt) { + this.platformUtilsService.showToast( + "info", + null, + this.i18nService.t("passwordRepromptDisabledAutofillOnPageLoad") + ); + return; + } + + this.platformUtilsService.showToast( + "info", + null, + this.i18nService.t("autofillOnPageLoadSetToDefault") + ); + } } diff --git a/apps/browser/src/vault/popup/components/vault/attachments.component.ts b/apps/browser/src/vault/popup/components/vault/attachments.component.ts index 68f59def546..5047c9bfd1e 100644 --- a/apps/browser/src/vault/popup/components/vault/attachments.component.ts +++ b/apps/browser/src/vault/popup/components/vault/attachments.component.ts @@ -3,16 +3,16 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { first } from "rxjs/operators"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; import { AttachmentsComponent as BaseAttachmentsComponent } from "@bitwarden/angular/vault/components/attachments.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { DialogService } from "@bitwarden/components"; @Component({ selector: "app-vault-attachments", @@ -33,7 +33,7 @@ export class AttachmentsComponent extends BaseAttachmentsComponent { stateService: StateService, logService: LogService, fileDownloadService: FileDownloadService, - dialogService: DialogServiceAbstraction + dialogService: DialogService ) { super( cipherService, diff --git a/apps/browser/src/vault/popup/components/vault/collections.component.ts b/apps/browser/src/vault/popup/components/vault/collections.component.ts index 3f61c1f23ff..d4615e165b2 100644 --- a/apps/browser/src/vault/popup/components/vault/collections.component.ts +++ b/apps/browser/src/vault/popup/components/vault/collections.component.ts @@ -4,11 +4,11 @@ import { ActivatedRoute } from "@angular/router"; import { first } from "rxjs/operators"; import { CollectionsComponent as BaseCollectionsComponent } from "@bitwarden/angular/admin-console/components/collections.component"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; @Component({ selector: "app-vault-collections", diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.html b/apps/browser/src/vault/popup/components/vault/current-tab.component.html index bec06919433..4ff7adf4678 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.html +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.html @@ -55,7 +55,7 @@

    {{ "typeLogins" | i18n }} {{ loginCiphers.length }}

    -
    +
    = this._selectedVault.asObservable(); enforcePersonalOwnership = false; - overlayPostition: ConnectedPosition[] = [ + overlayPosition: ConnectedPosition[] = [ { originX: "start", originY: "bottom", @@ -149,7 +149,7 @@ export class VaultSelectComponent implements OnInit, OnDestroy { .withPush(true) .withViewportMargin(10) .withGrowAfterOpen(true) - .withPositions(this.overlayPostition); + .withPositions(this.overlayPosition); this.overlayRef = this.overlay.create({ hasBackdrop: true, diff --git a/apps/browser/src/vault/popup/components/vault/view.component.html b/apps/browser/src/vault/popup/components/vault/view.component.html index 27eebd5d4ab..e8dceaff090 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.html +++ b/apps/browser/src/vault/popup/components/vault/view.component.html @@ -72,7 +72,7 @@

    class="box-content-row" appStopClick (click)="fillCipher()" - *ngIf="cipher.type !== cipherType.SecureNote && !cipher.isDeleted && !inPopout" + *ngIf=" + cipher.type !== cipherType.SecureNote && + !cipher.isDeleted && + (!this.inPopout || this.loadAction) + " >
    - @@ -209,7 +198,6 @@

    {{ "people" | i18n }}

    - diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts index 69f965901bc..6afa4ac9ff4 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/people.component.ts @@ -4,16 +4,9 @@ import { first } from "rxjs/operators"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderUserStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { ProviderUserBulkRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-bulk.request"; @@ -21,7 +14,14 @@ import { ProviderUserConfirmRequest } from "@bitwarden/common/admin-console/mode import { ProviderUserBulkResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user-bulk.response"; import { ProviderUserUserDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-user.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { EntityEventsComponent } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { DialogService } from "@bitwarden/components"; +import { openEntityEventsDialog } from "@bitwarden/web-vault/app/admin-console/organizations/manage/entity-events.component"; import { BulkStatusComponent } from "@bitwarden/web-vault/app/admin-console/organizations/members/components/bulk/bulk-status.component"; import { BasePeopleComponent } from "@bitwarden/web-vault/app/common/base.people.component"; @@ -41,8 +41,6 @@ export class PeopleComponent @ViewChild("addEdit", { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef; @ViewChild("groupsTemplate", { read: ViewContainerRef, static: true }) groupsModalRef: ViewContainerRef; - @ViewChild("eventsTemplate", { read: ViewContainerRef, static: true }) - eventsModalRef: ViewContainerRef; @ViewChild("bulkStatusTemplate", { read: ViewContainerRef, static: true }) bulkStatusModalRef: ViewContainerRef; @ViewChild("bulkConfirmTemplate", { read: ViewContainerRef, static: true }) @@ -52,6 +50,7 @@ export class PeopleComponent userType = ProviderUserType; userStatusType = ProviderUserStatusType; + status: ProviderUserStatusType = null; providerId: string; accessEvents = false; @@ -70,7 +69,7 @@ export class PeopleComponent userNamePipe: UserNamePipe, stateService: StateService, private providerService: ProviderService, - dialogService: DialogServiceAbstraction + dialogService: DialogService ) { super( apiService, @@ -140,7 +139,7 @@ export class PeopleComponent async confirmUser(user: ProviderUserUserDetailsResponse, publicKey: Uint8Array): Promise { const providerKey = await this.cryptoService.getProviderKey(this.providerId); - const key = await this.cryptoService.rsaEncrypt(providerKey.key, publicKey.buffer); + const key = await this.cryptoService.rsaEncrypt(providerKey.key, publicKey); const request = new ProviderUserConfirmRequest(); request.key = key.encryptedString; await this.apiService.postProviderUserConfirm(this.providerId, user.id, request); @@ -167,12 +166,14 @@ export class PeopleComponent } async events(user: ProviderUserUserDetailsResponse) { - await this.modalService.openViewRef(EntityEventsComponent, this.eventsModalRef, (comp) => { - comp.name = this.userNamePipe.transform(user); - comp.providerId = this.providerId; - comp.entityId = user.id; - comp.showUser = false; - comp.entity = "user"; + await openEntityEventsDialog(this.dialogService, { + data: { + name: this.userNamePipe.transform(user), + providerId: this.providerId, + entityId: user.id, + showUser: false, + entity: "user", + }, }); } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/user-add-edit.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/user-add-edit.component.ts index 7a83cab7493..328ca9500e3 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/manage/user-add-edit.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/manage/user-add-edit.component.ts @@ -1,14 +1,14 @@ import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { DialogServiceAbstraction, SimpleDialogType } from "@bitwarden/angular/services/dialog"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; import { ProviderUserInviteRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-invite.request"; import { ProviderUserUpdateRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-user-update.request"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; @Component({ selector: "provider-user-add-edit", @@ -38,7 +38,7 @@ export class UserAddEditComponent implements OnInit { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private logService: LogService, - private dialogService: DialogServiceAbstraction + private dialogService: DialogService ) {} async ngOnInit() { @@ -96,7 +96,7 @@ export class UserAddEditComponent implements OnInit { const confirmed = await this.dialogService.openSimpleDialog({ title: this.name, content: { key: "removeUserConfirmation" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!confirmed) { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts index 3be8ea67859..5bc2764038f 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts @@ -1,7 +1,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; -import { AuthGuard } from "@bitwarden/angular/auth/guards/auth.guard"; +import { AuthGuard } from "@bitwarden/angular/auth/guards"; import { Provider } from "@bitwarden/common/models/domain/provider"; import { ProvidersComponent } from "@bitwarden/web-vault/app/admin-console/providers/providers.component"; import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component"; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index cda2a108f7f..b7f3bf9f382 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -4,6 +4,8 @@ import { FormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ModalService } from "@bitwarden/angular/services/modal.service"; +import { SearchModule } from "@bitwarden/components"; +import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing"; import { OssModule } from "@bitwarden/web-vault/app/oss.module"; import { AddOrganizationComponent } from "./clients/add-organization.component"; @@ -26,7 +28,15 @@ import { SetupProviderComponent } from "./setup/setup-provider.component"; import { SetupComponent } from "./setup/setup.component"; @NgModule({ - imports: [CommonModule, FormsModule, OssModule, JslibModule, ProvidersRoutingModule], + imports: [ + CommonModule, + FormsModule, + OssModule, + JslibModule, + ProvidersRoutingModule, + OrganizationPlansComponent, + SearchModule, + ], declarations: [ AcceptProviderComponent, AccountComponent, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts index dc6678903fe..177a580029b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/services/web-provider.service.ts @@ -1,8 +1,8 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { ProviderAddOrganizationRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-add-organization.request"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; @Injectable() diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts index 939623d8ef6..138beb2cc0b 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts @@ -2,11 +2,11 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { ProviderUpdateRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-update.request"; import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; @Component({ diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts index 46e4151ff32..1bc2365b9c9 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts @@ -3,11 +3,12 @@ import { ActivatedRoute, Router } from "@angular/router"; import { first } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { ProviderKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; @Component({ @@ -78,8 +79,8 @@ export class SetupComponent implements OnInit { async doSubmit() { try { - const shareKey = await this.cryptoService.makeShareKey(); - const key = shareKey[0].encryptedString; + const providerKey = await this.cryptoService.makeOrgKey(); + const key = providerKey[0].encryptedString; const request = new ProviderSetupRequest(); request.name = this.name; diff --git a/bitwarden_license/bit-web/src/app/app.module.ts b/bitwarden_license/bit-web/src/app/app.module.ts index c5de6f6d5fa..77075a20a44 100644 --- a/bitwarden_license/bit-web/src/app/app.module.ts +++ b/bitwarden_license/bit-web/src/app/app.module.ts @@ -19,6 +19,12 @@ import { MaximumVaultTimeoutPolicyComponent } from "./admin-console/policies/max import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; +/** + * This is the AppModule for the commercial version of Bitwarden. + * `apps/web/app.module.ts` contains the OSS version. + * + * You probably do not want to modify this file. Consider editing `oss.module.ts` instead. + */ @NgModule({ imports: [ OverlayModule, diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html index 0f68788acd5..fa589b2438a 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html @@ -14,7 +14,7 @@

    {{ "singleSignOn" | i18n }}

    {{ "ssoPolicyHelpStart" | i18n }} - {{ "ssoPolicyHelpLink" | i18n }} + {{ "ssoPolicyHelpAnchor" | i18n }} {{ "ssoPolicyHelpEnd" | i18n }}

    @@ -78,12 +78,18 @@

    {{ "singleSignOn" | i18n }}

    *ngIf="showTdeOptions" > - {{ "trustedDeviceEncryption" | i18n }} + {{ "trustedDevices" | i18n }} - {{ "memberDecryptionTdeDescStart" | i18n }} - {{ "memberDecryptionTdeDescLink" | i18n }} - {{ "memberDecryptionTdeDescEnd" | i18n }} + {{ "memberDecryptionOptionTdeDescriptionPartOne" | i18n }} + {{ "memberDecryptionOptionTdeDescriptionLinkOne" | i18n }} + {{ "memberDecryptionOptionTdeDescriptionPartTwo" | i18n }} + {{ "memberDecryptionOptionTdeDescriptionLinkTwo" | i18n }} + {{ "memberDecryptionOptionTdeDescriptionPartThree" | i18n }} + {{ + "memberDecryptionOptionTdeDescriptionLinkThree" | i18n + }} + {{ "memberDecryptionOptionTdeDescriptionPartFour" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts index f6646bd2f97..21cda4bbb05 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts @@ -9,12 +9,8 @@ import { import { ActivatedRoute } from "@angular/router"; import { concatMap, Subject, takeUntil } from "rxjs"; -import { SelectOptions } from "@bitwarden/angular/interfaces/selectOptions"; import { ControlsOf } from "@bitwarden/angular/types/controls-of"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { ConfigServiceAbstraction } from "@bitwarden/common/abstractions/config/config.service.abstraction"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -31,10 +27,19 @@ import { OrganizationSsoRequest } from "@bitwarden/common/auth/models/request/or import { OrganizationSsoResponse } from "@bitwarden/common/auth/models/response/organization-sso.response"; import { SsoConfigView } from "@bitwarden/common/auth/models/view/sso-config.view"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ssoTypeValidator } from "./sso-type.validator"; +interface SelectOptions { + name: string; + value: any; + disabled?: boolean; +} + const defaultSigningAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; @Component({ @@ -230,11 +235,11 @@ export class SsoComponent implements OnInit, OnDestroy { ) .subscribe(); - const tdeFeatureFlag = await this.configService.getFeatureFlagBool( + const tdeFeatureFlag = await this.configService.getFeatureFlag( FeatureFlag.TrustedDeviceEncryption ); - this.showTdeOptions = tdeFeatureFlag && !this.platformUtilsService.isSelfHost(); + this.showTdeOptions = tdeFeatureFlag; // If the tde flag is not enabled, continue showing the key connector options to keep the UI the same // Once the flag is removed, we can rely on the platformUtilsService.isSelfHost() check alone this.showKeyConnectorOptions = !tdeFeatureFlag || this.platformUtilsService.isSelfHost(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.html index 9cd74d5ef2d..462c15311a0 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.component.html @@ -1,10 +1,4 @@ -
    - -
    - -
    -
    + + + + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.module.ts index fe4ff31b504..750c947d745 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.module.ts @@ -1,6 +1,6 @@ import { NgModule } from "@angular/core"; -import { NavigationModule } from "@bitwarden/components"; +import { LayoutComponent as BitLayoutComponent, NavigationModule } from "@bitwarden/components"; import { SharedModule } from "@bitwarden/web-vault/app/shared/shared.module"; import { LayoutComponent } from "./layout.component"; @@ -8,7 +8,7 @@ import { NavigationComponent } from "./navigation.component"; import { OrgSwitcherComponent } from "./org-switcher.component"; @NgModule({ - imports: [SharedModule, NavigationModule], + imports: [SharedModule, NavigationModule, BitLayoutComponent], declarations: [LayoutComponent, NavigationComponent, OrgSwitcherComponent], }) export class LayoutModule {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.stories.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.stories.ts deleted file mode 100644 index 533f86a6e62..00000000000 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/layout.stories.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Component, importProvidersFrom } from "@angular/core"; -import { RouterModule } from "@angular/router"; -import { Meta, Story, applicationConfig, moduleMetadata } from "@storybook/angular"; -import { BehaviorSubject } from "rxjs"; - -import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { IconModule } from "@bitwarden/components"; -import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/app/tests/preloaded-english-i18n.module"; - -import { LayoutComponent } from "./layout.component"; -import { LayoutModule } from "./layout.module"; -import { NavigationComponent } from "./navigation.component"; - -class MockOrganizationService implements Partial { - private static _orgs = new BehaviorSubject([]); - organizations$ = MockOrganizationService._orgs; // eslint-disable-line rxjs/no-exposed-subjects -} - -@Component({ - selector: "story-content", - template: `

    Content

    `, -}) -class StoryContentComponent {} - -export default { - title: "Web/Layout", - component: LayoutComponent, - decorators: [ - moduleMetadata({ - imports: [RouterModule, LayoutModule, IconModule], - declarations: [StoryContentComponent], - providers: [{ provide: OrganizationService, useClass: MockOrganizationService }], - }), - applicationConfig({ - providers: [ - importProvidersFrom( - RouterModule.forRoot( - [ - { - path: "", - component: LayoutComponent, - children: [ - { - path: "", - redirectTo: "secrets", - pathMatch: "full", - }, - { - path: "secrets", - component: StoryContentComponent, - data: { - title: "secrets", - searchTitle: "searchSecrets", - }, - }, - { - outlet: "sidebar", - path: "", - component: NavigationComponent, - }, - ], - }, - ], - { useHash: true } - ) - ), - importProvidersFrom(PreloadedEnglishI18nModule), - ], - }), - ], -} as Meta; - -const Template: Story = (args) => ({ - props: args, - template: ` - - `, -}); - -export const Default = Template.bind({}); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html index 3b058077b2a..97e96bed37c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html @@ -1,22 +1,24 @@ - - - + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts index 4b0449ef331..6c28398f89a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.ts @@ -4,8 +4,7 @@ import { map } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; - -import { SecretsManagerLogo } from "./secrets-manager-logo"; +import { SecretsManagerLogo } from "@bitwarden/web-vault/app/layouts/secrets-manager-logo"; @Component({ selector: "sm-navigation", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/service-account.view.ts b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/service-account.view.ts index 6f77be4f448..a0ce182a02d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/models/view/service-account.view.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/models/view/service-account.view.ts @@ -5,3 +5,7 @@ export class ServiceAccountView { creationDate: string; revisionDate: string; } + +export class ServiceAccountSecretsDetailsView extends ServiceAccountView { + accessToSecrets: number; +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.stories.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.stories.ts index a25e3863071..77436e3ec04 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.stories.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/onboarding.stories.ts @@ -5,7 +5,7 @@ import { delay, of, startWith } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { LinkModule, IconModule, ProgressModule } from "@bitwarden/components"; -import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/app/tests/preloaded-english-i18n.module"; +import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/app/core/tests"; import { OnboardingTaskComponent } from "./onboarding-task.component"; import { OnboardingComponent } from "./onboarding.component"; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html index bc7f15f4b88..fd738d59b8d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.html @@ -63,6 +63,7 @@

    {{ "secrets" | i1 (editSecretEvent)="openEditSecret($event)" (copySecretNameEvent)="copySecretName($event)" (copySecretValueEvent)="copySecretValue($event)" + (copySecretUuidEvent)="copySecretUuid($event)" [secrets]="view.latestSecrets" >
    diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts index 8b345402565..86fab25608a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/overview.component.ts @@ -13,11 +13,11 @@ import { share, } from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { DialogService } from "@bitwarden/components"; import { ProjectListView } from "../models/view/project-list.view"; import { SecretListView } from "../models/view/secret-list.view"; @@ -84,7 +84,7 @@ export class OverviewComponent implements OnInit, OnDestroy { private projectService: ProjectService, private secretService: SecretService, private serviceAccountService: ServiceAccountService, - private dialogService: DialogServiceAbstraction, + private dialogService: DialogService, private organizationService: OrganizationService, private stateService: StateService, private platformUtilsService: PlatformUtilsService, @@ -130,7 +130,7 @@ export class OverviewComponent implements OnInit, OnDestroy { orgId$, this.serviceAccountService.serviceAccount$.pipe(startWith(null)), ]).pipe( - switchMap(([orgId]) => this.serviceAccountService.getServiceAccounts(orgId)), + switchMap(([orgId]) => this.serviceAccountService.getServiceAccounts(orgId, false)), share() ); @@ -290,6 +290,10 @@ export class OverviewComponent implements OnInit, OnDestroy { ); } + copySecretUuid(id: string) { + SecretsListComponent.copySecretUuid(id, this.platformUtilsService, this.i18nService); + } + protected hideOnboarding() { this.showOnboarding = false; this.saveCompletedTasks(this.organizationId, { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.html index 0a9239a10a0..a8b3866dc17 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.html @@ -1,5 +1,5 @@ - + {{ title | i18n }} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts index 5a6d9084e15..c37cf3d6642 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-delete-dialog.component.ts @@ -8,9 +8,9 @@ import { AbstractControl, } from "@angular/forms"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; import { ProjectListView } from "../../models/view/project-list.view"; import { @@ -38,7 +38,7 @@ export class ProjectDeleteDialogComponent implements OnInit { private projectService: ProjectService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, - private dialogService: DialogServiceAbstraction + private dialogService: DialogService ) {} ngOnInit(): void { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.html index e21db2fc900..912fa8802b1 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.html @@ -1,5 +1,5 @@ - + {{ title | i18n }}
    @@ -7,7 +7,7 @@
    {{ "projectName" | i18n }} - +
    diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts index 02c4ae74fce..a6a3c958d09 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/dialog/project-dialog.component.ts @@ -3,8 +3,9 @@ import { Component, Inject, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { Router } from "@angular/router"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { BitValidators } from "@bitwarden/components"; import { ProjectView } from "../../models/view/project.view"; import { ProjectService } from "../../projects/project.service"; @@ -25,7 +26,10 @@ export interface ProjectOperation { }) export class ProjectDialogComponent implements OnInit { protected formGroup = new FormGroup({ - name: new FormControl("", [Validators.required]), + name: new FormControl("", { + validators: [Validators.required, Validators.maxLength(500), BitValidators.trimValidator], + updateOn: "submit", + }), }); protected loading = false; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/requests/project.request.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/requests/project.request.ts index 994c12e927f..8e9f2c72c10 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/requests/project.request.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/models/requests/project.request.ts @@ -1,4 +1,4 @@ -import { EncString } from "@bitwarden/common/models/domain/enc-string"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; export class ProjectRequest { name: EncString; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project.service.ts index 513601c5ba0..d3051916ccf 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project.service.ts @@ -2,11 +2,11 @@ import { Injectable } from "@angular/core"; import { Subject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { ProjectListView } from "../models/view/project-list.view"; import { ProjectView } from "../models/view/project.view"; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.html index b461c10a3b1..0485c5df1fa 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.html @@ -7,7 +7,7 @@ granteeType="people" [label]="'people' | i18n" [hint]="'projectPeopleSelectHint' | i18n" - [columnTitle]="'groupSlashUser' | i18n" + [columnTitle]="'name' | i18n" [emptyMessage]="'projectEmptyPeopleAccessPolicies' | i18n" (onCreateAccessPolicies)="handleCreateAccessPolicies($event)" (onDeleteAccessPolicy)="handleDeleteAccessPolicy($event)" diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts index d5129815bf3..03b2625f001 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-people.component.ts @@ -2,9 +2,8 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { map, Observable, share, startWith, Subject, switchMap, takeUntil } from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; -import { SelectItemView } from "@bitwarden/components"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { DialogService, SelectItemView } from "@bitwarden/components"; import { GroupProjectAccessPolicyView, @@ -144,7 +143,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy { constructor( private route: ActivatedRoute, - private dialogService: DialogServiceAbstraction, + private dialogService: DialogService, private validationService: ValidationService, private accessPolicyService: AccessPolicyService ) {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.html index f5edf2de0e2..980f38ca157 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.html @@ -16,6 +16,7 @@ (editSecretEvent)="openEditSecret($event)" (copySecretNameEvent)="copySecretName($event)" (copySecretValueEvent)="copySecretValue($event)" + (copySecretUuidEvent)="copySecretUuid($event)" [secrets]="projectSecrets.secrets" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts index 905b68cf037..2d1690ef0ec 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-secrets.component.ts @@ -2,9 +2,9 @@ import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { combineLatest, combineLatestWith, filter, Observable, startWith, switchMap } from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; import { ProjectView } from "../../models/view/project.view"; import { SecretListView } from "../../models/view/secret-list.view"; @@ -36,7 +36,7 @@ export class ProjectSecretsComponent { private route: ActivatedRoute, private projectService: ProjectService, private secretService: SecretService, - private dialogService: DialogServiceAbstraction, + private dialogService: DialogService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService ) {} @@ -109,4 +109,8 @@ export class ProjectSecretsComponent { this.secretService ); } + + copySecretUuid(id: string) { + SecretsListComponent.copySecretUuid(id, this.platformUtilsService, this.i18nService); + } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html index fb6eab471bb..2755377d2a0 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.html @@ -11,6 +11,7 @@ [emptyMessage]="'projectEmptyServiceAccountAccessPolicies' | i18n" (onCreateAccessPolicies)="handleCreateAccessPolicies($event)" (onDeleteAccessPolicy)="handleDeleteAccessPolicy($event)" + (onUpdateAccessPolicy)="handleUpdateAccessPolicy($event)" >
    diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts index 19105c6cf88..2cd2134dd4b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project-service-accounts.component.ts @@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { map, Observable, startWith, Subject, switchMap, takeUntil } from "rxjs"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { SelectItemView } from "@bitwarden/components"; import { @@ -39,11 +39,21 @@ export class ProjectServiceAccountsComponent implements OnInit, OnDestroy { read: policy.read, write: policy.write, icon: AccessSelectorComponent.serviceAccountIcon, - static: true, + static: false, })) ) ); + protected async handleUpdateAccessPolicy(policy: AccessSelectorRowView) { + try { + return await this.accessPolicyService.updateAccessPolicy( + AccessSelectorComponent.getBaseAccessPolicyView(policy) + ); + } catch (e) { + this.validationService.showError(e); + } + } + protected handleCreateAccessPolicies(selected: SelectItemView[]) { const projectAccessPoliciesView = new ProjectAccessPoliciesView(); projectAccessPoliciesView.serviceAccountAccessPolicies = selected diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts index 7c4e7357af0..c87d238d6a8 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/project/project.component.ts @@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from "@angular/router"; import { catchError, combineLatest, + EMPTY, filter, Observable, startWith, @@ -11,7 +12,9 @@ import { takeUntil, } from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; import { ProjectView } from "../../models/view/project.view"; import { @@ -37,7 +40,9 @@ export class ProjectComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private projectService: ProjectService, private router: Router, - private dialogService: DialogServiceAbstraction + private dialogService: DialogService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService ) {} ngOnInit(): void { @@ -48,10 +53,17 @@ export class ProjectComponent implements OnInit, OnDestroy { ); this.project$ = combineLatest([this.route.params, currentProjectEdited]).pipe( - switchMap(([params, _]) => { - return this.projectService.getByProjectId(params.projectId); - }), - catchError(async () => this.handleError()) + switchMap(([params, _]) => this.projectService.getByProjectId(params.projectId)), + catchError(() => { + this.router.navigate(["/sm", this.organizationId, "projects"]).then(() => { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("notFound", this.i18nService.t("project")) + ); + }); + return EMPTY; + }) ); this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params) => { @@ -60,12 +72,6 @@ export class ProjectComponent implements OnInit, OnDestroy { }); } - handleError = () => { - const projectsListUrl = `/sm/${this.organizationId}/projects/`; - this.router.navigate([projectsListUrl]); - return new ProjectView(); - }; - ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts index fe202b31229..7128e26a3d8 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/projects/projects/projects.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { combineLatest, lastValueFrom, Observable, startWith, switchMap } from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; +import { DialogService } from "@bitwarden/components"; import { ProjectListView } from "../../models/view/project-list.view"; import { AccessPolicyService } from "../../shared/access-policies/access-policy.service"; @@ -37,7 +37,7 @@ export class ProjectsComponent implements OnInit { private route: ActivatedRoute, private projectService: ProjectService, private accessPolicyService: AccessPolicyService, - private dialogService: DialogServiceAbstraction + private dialogService: DialogService ) {} ngOnInit() { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts index de06e6c926a..4158746131a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-delete.component.ts @@ -1,9 +1,9 @@ import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; import { SecretListView } from "../../models/view/secret-list.view"; import { @@ -27,7 +27,7 @@ export class SecretDeleteDialogComponent { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, @Inject(DIALOG_DATA) private data: SecretDeleteOperation, - private dialogService: DialogServiceAbstraction + private dialogService: DialogService ) {} showSoftDeleteSecretWarning = this.data.secrets.length === 1; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html index 65cef3e9a2e..62692511e23 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.html @@ -11,7 +11,7 @@
    {{ "name" | i18n }} - + {{ "value" | i18n }} @@ -41,7 +41,7 @@ {{ "projectName" | i18n }} - +
    diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts index 64b75511ff4..426542823f9 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/dialog/secret-dialog.component.ts @@ -3,11 +3,11 @@ import { Component, Inject, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { lastValueFrom, Subject, takeUntil } from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DialogService, BitValidators } from "@bitwarden/components"; import { ProjectListView } from "../../models/view/project-list.view"; import { ProjectView } from "../../models/view/project.view"; @@ -36,11 +36,20 @@ export interface SecretOperation { }) export class SecretDialogComponent implements OnInit { protected formGroup = new FormGroup({ - name: new FormControl("", [Validators.required]), - value: new FormControl("", [Validators.required]), - notes: new FormControl(""), + name: new FormControl("", { + validators: [Validators.required, Validators.maxLength(500), BitValidators.trimValidator], + updateOn: "submit", + }), + value: new FormControl("", [Validators.required, Validators.maxLength(25000)]), + notes: new FormControl("", { + validators: [Validators.maxLength(7000), BitValidators.trimValidator], + updateOn: "submit", + }), project: new FormControl("", [Validators.required]), - newProjectName: new FormControl(""), + newProjectName: new FormControl("", { + validators: [Validators.maxLength(500), BitValidators.trimValidator], + updateOn: "submit", + }), }); private destroy$ = new Subject(); @@ -56,7 +65,7 @@ export class SecretDialogComponent implements OnInit { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private projectService: ProjectService, - private dialogService: DialogServiceAbstraction, + private dialogService: DialogService, private organizationService: OrganizationService ) {} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.ts index 0acc292b9c1..faa80f061da 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.ts @@ -2,10 +2,10 @@ import { Injectable } from "@angular/core"; import { Subject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SecretListView } from "../models/view/secret-list.view"; import { SecretProjectView } from "../models/view/secret-project.view"; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.html index cb7a882f2f5..be30d381f1b 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.html @@ -12,6 +12,7 @@ (editSecretEvent)="openEditSecret($event)" (copySecretNameEvent)="copySecretName($event)" (copySecretValueEvent)="copySecretValue($event)" + (copySecretUuidEvent)="copySecretUuid($event)" [secrets]="secrets$ | async" [search]="search" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts index 940823ea62c..7c05f169a3d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secrets.component.ts @@ -2,9 +2,9 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { combineLatestWith, Observable, startWith, switchMap } from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; import { SecretListView } from "../models/view/secret-list.view"; import { SecretsListComponent } from "../shared/secrets-list.component"; @@ -33,7 +33,7 @@ export class SecretsComponent implements OnInit { constructor( private route: ActivatedRoute, private secretService: SecretService, - private dialogService: DialogServiceAbstraction, + private dialogService: DialogService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService ) {} @@ -96,4 +96,8 @@ export class SecretsComponent implements OnInit { this.secretService ); } + + copySecretUuid(id: string) { + SecretsListComponent.copySecretUuid(id, this.platformUtilsService, this.i18nService); + } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html index 632e10c8c7f..3d7e1b1073e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.html @@ -32,7 +32,6 @@ {{ "name" | i18n }} - {{ "permissions" | i18n }} {{ "expires" | i18n }} {{ "lastEdited" | i18n }} @@ -57,7 +56,6 @@ /> {{ token.name }} - {{ permission(token) | i18n }} {{ token.expireAt === null ? ("never" | i18n) : (token.expireAt | date : "medium") }} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts index ce7ad6e86da..e24b0488b2c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-list.component.ts @@ -39,8 +39,4 @@ export class AccessListComponent { const selected = this.tokens.filter((s) => this.selection.selected.includes(s.id)); this.revokeAccessTokensEvent.emit(selected); } - - protected permission(token: AccessTokenView) { - return "canRead"; - } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts index e6eadd9e927..ea264e8aa4a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access-tokens.component.ts @@ -1,14 +1,23 @@ -import { Component, OnInit } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { combineLatestWith, Observable, startWith, switchMap } from "rxjs"; - -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { UserVerificationPromptComponent } from "@bitwarden/web-vault/app/shared/components/user-verification"; - +import { + combineLatestWith, + firstValueFrom, + Observable, + startWith, + Subject, + switchMap, + takeUntil, +} from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; +import { openUserVerificationPrompt } from "@bitwarden/web-vault/app/auth/shared/components/user-verification"; + +import { ServiceAccountView } from "../../models/view/service-account.view"; import { AccessTokenView } from "../models/view/access-token.view"; +import { ServiceAccountService } from "../service-account.service"; import { AccessService } from "./access.service"; import { AccessTokenCreateDialogComponent } from "./dialogs/access-token-create-dialog.component"; @@ -17,31 +26,50 @@ import { AccessTokenCreateDialogComponent } from "./dialogs/access-token-create- selector: "sm-access-tokens", templateUrl: "./access-tokens.component.html", }) -export class AccessTokenComponent implements OnInit { +export class AccessTokenComponent implements OnInit, OnDestroy { accessTokens$: Observable; - private serviceAccountId: string; - private organizationId: string; + private destroy$ = new Subject(); + private serviceAccountView: ServiceAccountView; constructor( private route: ActivatedRoute, private accessService: AccessService, - private dialogService: DialogServiceAbstraction, - private modalService: ModalService, + private dialogService: DialogService, private platformUtilsService: PlatformUtilsService, - private i18nService: I18nService + private i18nService: I18nService, + private serviceAccountService: ServiceAccountService ) {} ngOnInit() { this.accessTokens$ = this.accessService.accessToken$.pipe( startWith(null), combineLatestWith(this.route.params), - switchMap(async ([_, params]) => { - this.organizationId = params.organizationId; - this.serviceAccountId = params.serviceAccountId; - return await this.getAccessTokens(); - }) + switchMap(async ([_, params]) => + this.accessService.getAccessTokens(params.organizationId, params.serviceAccountId) + ) ); + + this.serviceAccountService.serviceAccount$ + .pipe( + startWith(null), + combineLatestWith(this.route.params), + switchMap(([_, params]) => + this.serviceAccountService.getByServiceAccountId( + params.serviceAccountId, + params.organizationId + ) + ), + takeUntil(this.destroy$) + ) + .subscribe((serviceAccountView) => { + this.serviceAccountView = serviceAccountView; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } protected async revoke(tokens: AccessTokenView[]) { @@ -59,7 +87,7 @@ export class AccessTokenComponent implements OnInit { } await this.accessService.revokeAccessTokens( - this.serviceAccountId, + this.serviceAccountView.id, tokens.map((t) => t.id) ); @@ -69,14 +97,12 @@ export class AccessTokenComponent implements OnInit { protected openNewAccessTokenDialog() { AccessTokenCreateDialogComponent.openNewAccessTokenDialog( this.dialogService, - this.serviceAccountId, - this.organizationId + this.serviceAccountView ); } private verifyUser() { - const ref = this.modalService.open(UserVerificationPromptComponent, { - allowMultipleModals: true, + const ref = openUserVerificationPrompt(this.dialogService, { data: { confirmDescription: "revokeAccessTokenDesc", confirmButtonText: "revokeAccessToken", @@ -88,10 +114,6 @@ export class AccessTokenComponent implements OnInit { return; } - return ref.onClosedPromise(); - } - - private async getAccessTokens(): Promise { - return await this.accessService.getAccessTokens(this.organizationId, this.serviceAccountId); + return firstValueFrom(ref.closed); } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access.service.ts index 92b248999f7..d087fb9ef6c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/access.service.ts @@ -2,13 +2,13 @@ import { Injectable } from "@angular/core"; import { Subject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; -import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; -import { Utils } from "@bitwarden/common/misc/utils"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +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"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { AccessTokenRequest } from "../models/requests/access-token.request"; import { RevokeAccessTokensRequest } from "../models/requests/revoke-access-tokens.request"; @@ -53,7 +53,7 @@ export class AccessService { serviceAccountId: string, accessTokenView: AccessTokenView ): Promise { - const keyMaterial = await this.cryptoFunctionService.randomBytes(16); + const keyMaterial = await this.cryptoFunctionService.aesGenerateKey(128); const key = await this.cryptoFunctionService.hkdf( keyMaterial, "bitwarden-accesstoken", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.html index 7e020a51060..d843887a392 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-create-dialog.component.html @@ -10,21 +10,8 @@
    {{ "name" | i18n }} - + -
    - - {{ "permissions" | i18n }} - - - - {{ "accessTokenPermissionsBetaNotification" | i18n }} - -
    (AccessTokenCreateDialogComponent, { data: { - organizationId: organizationId, - serviceAccountView: serviceAccountView, + serviceAccountView, }, }); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts index 505f48c88f3..4244c4aa50a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/access-token-dialog.component.ts @@ -1,8 +1,8 @@ import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { Component, Inject, OnInit } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; export interface AccessTokenDetails { subTitle: string; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts index 132494ef47e..7eb7fd894b8 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/access/dialogs/expiration-options.component.ts @@ -9,10 +9,13 @@ import { NG_VALUE_ACCESSOR, ValidationErrors, Validator, + ValidatorFn, Validators, } from "@angular/forms"; import { Subject, takeUntil } from "rxjs"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + @Component({ selector: "sm-expiration-options", templateUrl: "./expiration-options.component.html", @@ -46,10 +49,10 @@ export class ExpirationOptionsComponent protected form = new FormGroup({ expires: new FormControl("never", [Validators.required]), - expireDateTime: new FormControl("", [Validators.required]), + expireDateTime: new FormControl("", [Validators.required, this.expiresInFutureValidator()]), }); - constructor(private datePipe: DatePipe) {} + constructor(private datePipe: DatePipe, private i18nService: I18nService) {} async ngOnInit() { this.form.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => { @@ -74,7 +77,7 @@ export class ExpirationOptionsComponent validate(control: AbstractControl): ValidationErrors { if ( - (this.form.value.expires == "custom" && this.form.value.expireDateTime) || + (this.form.value.expires == "custom" && !this.form.invalid) || this.form.value.expires !== "custom" ) { return null; @@ -111,4 +114,20 @@ export class ExpirationOptionsComponent currentDate.setDate(currentDate.getDate() + Number(this.form.value.expires)); return currentDate; } + + expiresInFutureValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const enteredDate = new Date(control.value); + + if (enteredDate > new Date()) { + return null; + } else { + return { + ValidationError: { + message: this.i18nService.t("expirationDateError"), + }, + }; + } + }; + } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.html index 048248531d1..9af34837037 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.html @@ -1,5 +1,5 @@ - + {{ title }} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts index da75b61c7aa..ccc99bd6b8a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-delete-dialog.component.ts @@ -8,9 +8,9 @@ import { AbstractControl, } from "@angular/forms"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; import { ServiceAccountView } from "../../models/view/service-account.view"; import { @@ -38,7 +38,7 @@ export class ServiceAccountDeleteDialogComponent { private serviceAccountService: ServiceAccountService, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, - private dialogService: DialogServiceAbstraction + private dialogService: DialogService ) {} get title() { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.html index f837fce6b2d..55f6ff4da14 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.html @@ -1,5 +1,5 @@ - + {{ title | i18n }}
    @@ -8,7 +8,7 @@
    {{ "serviceAccountName" | i18n }} - +
    diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts index 9ecf4ce1a10..1f42537f956 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/dialog/service-account-dialog.component.ts @@ -2,8 +2,9 @@ import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { BitValidators } from "@bitwarden/components"; import { ServiceAccountView } from "../../models/view/service-account.view"; import { ServiceAccountService } from "../service-account.service"; @@ -23,9 +24,15 @@ export interface ServiceAccountOperation { templateUrl: "./service-account-dialog.component.html", }) export class ServiceAccountDialogComponent { - protected formGroup = new FormGroup({ - name: new FormControl("", [Validators.required]), - }); + protected formGroup = new FormGroup( + { + name: new FormControl("", { + validators: [Validators.required, Validators.maxLength(500), BitValidators.trimValidator], + updateOn: "submit", + }), + }, + {} + ); protected loading = false; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/access-token.request.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/access-token.request.ts index c570d3d2051..3801e7398fe 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/access-token.request.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/access-token.request.ts @@ -1,4 +1,4 @@ -import { EncString } from "@bitwarden/common/models/domain/enc-string"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; export class AccessTokenRequest { name: EncString; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/service-account.request.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/service-account.request.ts index 8c62324a783..47221b2b82a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/service-account.request.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/requests/service-account.request.ts @@ -1,4 +1,4 @@ -import { EncString } from "@bitwarden/common/models/domain/enc-string"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; export class ServiceAccountRequest { name: EncString; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/service-account.response.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/service-account.response.ts index 7ece18f7d87..8339b4f2cd5 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/service-account.response.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/models/responses/service-account.response.ts @@ -16,3 +16,12 @@ export class ServiceAccountResponse extends BaseResponse { this.revisionDate = this.getResponseProperty("RevisionDate"); } } + +export class ServiceAccountSecretsDetailsResponse extends ServiceAccountResponse { + accessToSecrets: number; + + constructor(response: any) { + super(response); + this.accessToSecrets = this.getResponseProperty("AccessToSecrets"); + } +} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html index 3d896e62253..2a490d7914a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.html @@ -7,7 +7,7 @@ granteeType="people" [label]="'people' | i18n" [hint]="'projectPeopleSelectHint' | i18n" - [columnTitle]="'groupSlashUser' | i18n" + [columnTitle]="'name' | i18n" [emptyMessage]="'projectEmptyPeopleAccessPolicies' | i18n" (onCreateAccessPolicies)="handleCreateAccessPolicies($event)" (onDeleteAccessPolicy)="handleDeleteAccessPolicy($event)" diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts index e346d25871e..296ef1c1325 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/people/service-account-people.component.ts @@ -11,13 +11,9 @@ import { takeUntil, } from "rxjs"; -import { - SimpleDialogType, - DialogServiceAbstraction, - SimpleDialogOptions, -} from "@bitwarden/angular/services/dialog"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view"; import { @@ -134,7 +130,7 @@ export class ServiceAccountPeopleComponent { const simpleDialogOpts: SimpleDialogOptions = { title: this.i18nService.t("saPeopleWarningTitle"), content: this.i18nService.t("saPeopleWarningMessage"), - type: SimpleDialogType.WARNING, + type: "warning", acceptButtonText: { key: "close" }, cancelButtonText: null, }; @@ -146,7 +142,7 @@ export class ServiceAccountPeopleComponent { constructor( private route: ActivatedRoute, - private dialogService: DialogServiceAbstraction, + private dialogService: DialogService, private i18nService: I18nService, private validationService: ValidationService, private accessPolicyService: AccessPolicyService diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html index 8ffc465e1bc..368a62a9331 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.html @@ -11,6 +11,7 @@ [emptyMessage]="'serviceAccountEmptyProjectAccesspolicies' | i18n" (onCreateAccessPolicies)="handleCreateAccessPolicies($event)" (onDeleteAccessPolicy)="handleDeleteAccessPolicy($event)" + (onUpdateAccessPolicy)="handleUpdateAccessPolicy($event)" >
    diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts index 87e88082799..aed98a42351 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/projects/service-account-projects.component.ts @@ -1,8 +1,8 @@ -import { Component } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { combineLatestWith, map, Observable, startWith, Subject, switchMap, takeUntil } from "rxjs"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view"; import { ServiceAccountProjectAccessPolicyView } from "../../models/view/access-policy.view"; @@ -16,7 +16,7 @@ import { selector: "sm-service-account-projects", templateUrl: "./service-account-projects.component.html", }) -export class ServiceAccountProjectsComponent { +export class ServiceAccountProjectsComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); private serviceAccountId: string; private organizationId: string; @@ -38,7 +38,7 @@ export class ServiceAccountProjectsComponent { read: policy.read, write: policy.write, icon: AccessSelectorComponent.projectIcon, - static: true, + static: false, } as AccessSelectorRowView; }); }) @@ -63,6 +63,16 @@ export class ServiceAccountProjectsComponent { ); } + protected async handleUpdateAccessPolicy(policy: AccessSelectorRowView) { + try { + return await this.accessPolicyService.updateAccessPolicy( + AccessSelectorComponent.getBaseAccessPolicyView(policy) + ); + } catch (e) { + this.validationService.showError(e); + } + } + protected async handleDeleteAccessPolicy(policy: AccessSelectorRowView) { try { await this.accessPolicyService.deleteAccessPolicy(policy.accessPolicyId); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts index 6d5dee2a9fa..06b91f71f75 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.component.ts @@ -1,8 +1,21 @@ -import { Component } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; -import { switchMap } from "rxjs"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + EMPTY, + Subject, + catchError, + combineLatest, + filter, + startWith, + switchMap, + takeUntil, +} from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; + +import { ServiceAccountView } from "../models/view/service-account.view"; import { AccessTokenCreateDialogComponent } from "./access/dialogs/access-token-create-dialog.component"; import { ServiceAccountService } from "./service-account.service"; @@ -11,35 +24,60 @@ import { ServiceAccountService } from "./service-account.service"; selector: "sm-service-account", templateUrl: "./service-account.component.html", }) -export class ServiceAccountComponent { +export class ServiceAccountComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); private organizationId: string; private serviceAccountId: string; - /** - * TODO: remove when a server method is available that fetches a service account by ID - */ - protected serviceAccount$ = this.route.params.pipe( - switchMap((params) => { - this.serviceAccountId = params.serviceAccountId; - this.organizationId = params.organizationId; - - return this.serviceAccountService - .getServiceAccounts(params.organizationId) - .then((saList) => saList.find((sa) => sa.id === params.serviceAccountId)); + private onChange$ = this.serviceAccountService.serviceAccount$.pipe( + filter((sa) => sa?.id === this.serviceAccountId), + startWith(null) + ); + + private serviceAccountView: ServiceAccountView; + protected serviceAccount$ = combineLatest([this.route.params, this.onChange$]).pipe( + switchMap(([params, _]) => + this.serviceAccountService.getByServiceAccountId( + params.serviceAccountId, + params.organizationId + ) + ), + catchError(() => { + this.router.navigate(["/sm", this.organizationId, "service-accounts"]).then(() => { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("notFound", this.i18nService.t("serviceAccount")) + ); + }); + return EMPTY; }) ); constructor( private route: ActivatedRoute, private serviceAccountService: ServiceAccountService, - private dialogService: DialogServiceAbstraction + private dialogService: DialogService, + private router: Router, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService ) {} + ngOnInit(): void { + this.serviceAccount$.pipe(takeUntil(this.destroy$)).subscribe((serviceAccountView) => { + this.serviceAccountView = serviceAccountView; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + protected openNewAccessTokenDialog() { AccessTokenCreateDialogComponent.openNewAccessTokenDialog( this.dialogService, - this.serviceAccountId, - this.organizationId + this.serviceAccountView ); } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.service.ts index ba6c71beb1c..c3354c70b2d 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-account.service.ts @@ -2,17 +2,23 @@ import { Injectable } from "@angular/core"; import { Subject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; - -import { ServiceAccountView } from "../models/view/service-account.view"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; + +import { + ServiceAccountSecretsDetailsView, + ServiceAccountView, +} from "../models/view/service-account.view"; import { BulkOperationStatus } from "../shared/dialogs/bulk-status-dialog.component"; import { ServiceAccountRequest } from "./models/requests/service-account.request"; -import { ServiceAccountResponse } from "./models/responses/service-account.response"; +import { + ServiceAccountResponse, + ServiceAccountSecretsDetailsResponse, +} from "./models/responses/service-account.response"; @Injectable({ providedIn: "root", @@ -28,16 +34,24 @@ export class ServiceAccountService { private encryptService: EncryptService ) {} - async getServiceAccounts(organizationId: string): Promise { + async getServiceAccounts( + organizationId: string, + includeAccessToSecrets?: boolean + ): Promise { + const params = new URLSearchParams(); + if (includeAccessToSecrets) { + params.set("includeAccessToSecrets", "true"); + } + const r = await this.apiService.send( "GET", - "/organizations/" + organizationId + "/service-accounts", + "/organizations/" + organizationId + "/service-accounts?" + params.toString(), null, true, true ); - const results = new ListResponse(r, ServiceAccountResponse); - return await this.createServiceAccountViews(organizationId, results.data); + const results = new ListResponse(r, ServiceAccountSecretsDetailsResponse); + return await this.createServiceAccountSecretsDetailsViews(organizationId, results.data); } async getByServiceAccountId( @@ -127,21 +141,39 @@ export class ServiceAccountService { serviceAccountView.organizationId = serviceAccountResponse.organizationId; serviceAccountView.creationDate = serviceAccountResponse.creationDate; serviceAccountView.revisionDate = serviceAccountResponse.revisionDate; - serviceAccountView.name = await this.encryptService.decryptToUtf8( - new EncString(serviceAccountResponse.name), - organizationKey - ); + serviceAccountView.name = serviceAccountResponse.name + ? await this.encryptService.decryptToUtf8( + new EncString(serviceAccountResponse.name), + organizationKey + ) + : null; return serviceAccountView; } - private async createServiceAccountViews( + private async createServiceAccountSecretsDetailsView( + organizationKey: SymmetricCryptoKey, + response: ServiceAccountSecretsDetailsResponse + ): Promise { + const view = new ServiceAccountSecretsDetailsView(); + view.id = response.id; + view.organizationId = response.organizationId; + view.creationDate = response.creationDate; + view.revisionDate = response.revisionDate; + view.accessToSecrets = response.accessToSecrets; + view.name = response.name + ? await this.encryptService.decryptToUtf8(new EncString(response.name), organizationKey) + : null; + return view; + } + + private async createServiceAccountSecretsDetailsViews( organizationId: string, - serviceAccountResponses: ServiceAccountResponse[] - ): Promise { + serviceAccountResponses: ServiceAccountSecretsDetailsResponse[] + ): Promise { const orgKey = await this.getOrganizationKey(organizationId); return await Promise.all( - serviceAccountResponses.map(async (s: ServiceAccountResponse) => { - return await this.createServiceAccountView(orgKey, s); + serviceAccountResponses.map(async (s: ServiceAccountSecretsDetailsResponse) => { + return await this.createServiceAccountSecretsDetailsView(orgKey, s); }) ); } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html index e3e71ed85e0..bff5cbf4d31 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.html @@ -64,8 +64,7 @@ - - 0 + {{ serviceAccount.accessToSecrets }} {{ serviceAccount.revisionDate | date : "medium" }} diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts index 5bc0b1e7f97..34e38e4603e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts-list.component.ts @@ -2,32 +2,36 @@ import { SelectionModel } from "@angular/cdk/collections"; import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core"; import { Subject, takeUntil } from "rxjs"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TableDataSource } from "@bitwarden/components"; -import { ServiceAccountView } from "../models/view/service-account.view"; +import { + ServiceAccountSecretsDetailsView, + ServiceAccountView, +} from "../models/view/service-account.view"; @Component({ selector: "sm-service-accounts-list", templateUrl: "./service-accounts-list.component.html", }) export class ServiceAccountsListComponent implements OnDestroy { - protected dataSource = new TableDataSource(); + protected dataSource = new TableDataSource(); @Input() - get serviceAccounts(): ServiceAccountView[] { + get serviceAccounts(): ServiceAccountSecretsDetailsView[] { return this._serviceAccounts; } - set serviceAccounts(serviceAccounts: ServiceAccountView[]) { + set serviceAccounts(serviceAccounts: ServiceAccountSecretsDetailsView[]) { this.selection.clear(); this._serviceAccounts = serviceAccounts; this.dataSource.data = serviceAccounts; } - private _serviceAccounts: ServiceAccountView[]; + private _serviceAccounts: ServiceAccountSecretsDetailsView[]; @Input() set search(search: string) { + this.selection.clear(); this.dataSource.filter = search; } @@ -55,19 +59,24 @@ export class ServiceAccountsListComponent implements OnDestroy { } isAllSelected() { - const numSelected = this.selection.selected.length; - const numRows = this.serviceAccounts.length; - return numSelected === numRows; + if (this.selection.selected?.length > 0) { + const numSelected = this.selection.selected.length; + const numRows = this.dataSource.filteredData.length; + return numSelected === numRows; + } + return false; } toggleAll() { - this.isAllSelected() - ? this.selection.clear() - : this.selection.select(...this.serviceAccounts.map((s) => s.id)); + if (this.isAllSelected()) { + this.selection.clear(); + } else { + this.selection.select(...this.dataSource.filteredData.map((s) => s.id)); + } } - delete(serviceAccount: ServiceAccountView) { - this.deleteServiceAccountsEvent.emit([serviceAccount]); + delete(serviceAccount: ServiceAccountSecretsDetailsView) { + this.deleteServiceAccountsEvent.emit([serviceAccount as ServiceAccountView]); } bulkDeleteServiceAccounts() { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts index 9800a14fe5a..808073ba810 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/service-accounts/service-accounts.component.ts @@ -2,9 +2,12 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { combineLatest, Observable, startWith, switchMap } from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; +import { DialogService } from "@bitwarden/components"; -import { ServiceAccountView } from "../models/view/service-account.view"; +import { + ServiceAccountSecretsDetailsView, + ServiceAccountView, +} from "../models/view/service-account.view"; import { AccessPolicyService } from "../shared/access-policies/access-policy.service"; import { @@ -23,14 +26,14 @@ import { ServiceAccountService } from "./service-account.service"; templateUrl: "./service-accounts.component.html", }) export class ServiceAccountsComponent implements OnInit { - protected serviceAccounts$: Observable; + protected serviceAccounts$: Observable; protected search: string; private organizationId: string; constructor( private route: ActivatedRoute, - private dialogService: DialogServiceAbstraction, + private dialogService: DialogService, private accessPolicyService: AccessPolicyService, private serviceAccountService: ServiceAccountService ) {} @@ -78,7 +81,7 @@ export class ServiceAccountsComponent implements OnInit { ); } - private async getServiceAccounts(): Promise { - return await this.serviceAccountService.getServiceAccounts(this.organizationId); + private async getServiceAccounts(): Promise { + return await this.serviceAccountService.getServiceAccounts(this.organizationId, true); } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts index 59133c614b8..81aad270f6c 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/dialog/sm-import-error-dialog.component.ts @@ -1,7 +1,7 @@ import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SecretsManagerImportError } from "../models/error/sm-import-error"; import { SecretsManagerImportErrorLine } from "../models/error/sm-import-error-line"; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/models/requests/sm-imported-project.request.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/models/requests/sm-imported-project.request.ts index ff509e084ff..bee91972bb8 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/models/requests/sm-imported-project.request.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/models/requests/sm-imported-project.request.ts @@ -1,4 +1,4 @@ -import { EncString } from "@bitwarden/common/models/domain/enc-string"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; export class SecretsManagerImportedProjectRequest { id: string; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/models/requests/sm-imported-secret.request.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/models/requests/sm-imported-secret.request.ts index e6e73b7fe24..55c5382b3ad 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/models/requests/sm-imported-secret.request.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/models/requests/sm-imported-secret.request.ts @@ -1,4 +1,4 @@ -import { EncString } from "@bitwarden/common/models/domain/enc-string"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; export class SecretsManagerImportedSecretRequest { id: string; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts index ee90b4fcb55..201abb84ab2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts @@ -1,15 +1,15 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { Subject, switchMap, takeUntil } from "rxjs"; +import { firstValueFrom, Subject, switchMap, takeUntil } from "rxjs"; -import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { UserVerificationPromptComponent } from "@bitwarden/web-vault/app/shared/components/user-verification"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; +import { openUserVerificationPrompt } from "@bitwarden/web-vault/app/auth/shared/components/user-verification"; import { SecretsManagerPortingApiService } from "../services/sm-porting-api.service"; import { SecretsManagerPortingService } from "../services/sm-porting.service"; @@ -42,7 +42,7 @@ export class SecretsManagerExportComponent implements OnInit, OnDestroy { private smPortingService: SecretsManagerPortingService, private fileDownloadService: FileDownloadService, private logService: LogService, - private modalService: ModalService, + private dialogService: DialogService, private secretsManagerApiService: SecretsManagerPortingApiService ) {} @@ -82,7 +82,7 @@ export class SecretsManagerExportComponent implements OnInit, OnDestroy { private async doExport() { const fileExtension = this.exportFormats[this.formGroup.get("format").value].fileExtension; - const exportData = await this.secretsManagerApiService.export(this.orgId, fileExtension); + const exportData = await this.secretsManagerApiService.export(this.orgId); await this.downloadFile(exportData, fileExtension); this.platformUtilsService.showToast("success", null, this.i18nService.t("dataExportSuccess")); @@ -98,12 +98,11 @@ export class SecretsManagerExportComponent implements OnInit, OnDestroy { } private verifyUser() { - const ref = this.modalService.open(UserVerificationPromptComponent, { - allowMultipleModals: true, + const ref = openUserVerificationPrompt(this.dialogService, { data: { - confirmDescription: "exportWarningDesc", - confirmButtonText: "exportVault", - modalTitle: "confirmVaultExport", + confirmDescription: "exportSecretsWarningDesc", + confirmButtonText: "exportSecrets", + modalTitle: "confirmSecretsExport", }, }); @@ -111,6 +110,6 @@ export class SecretsManagerExportComponent implements OnInit, OnDestroy { return; } - return ref.onClosedPromise(); + return firstValueFrom(ref.closed); } } diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts index 0a9279b3301..3819b7f1dbe 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.ts @@ -3,12 +3,13 @@ import { FormControl, FormGroup } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; import { Subject, takeUntil } from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; -import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { DialogService } from "@bitwarden/components"; import { SecretsManagerImportErrorDialogComponent, @@ -37,7 +38,7 @@ export class SecretsManagerImportComponent implements OnInit, OnDestroy { protected fileDownloadService: FileDownloadService, private logService: LogService, private secretsManagerPortingApiService: SecretsManagerPortingApiService, - private dialogService: DialogServiceAbstraction + private dialogService: DialogService ) {} async ngOnInit() { @@ -73,6 +74,13 @@ export class SecretsManagerImportComponent implements OnInit, OnDestroy { if (error?.lines?.length > 0) { this.openImportErrorDialog(error); return; + } else if (!Utils.isNullOrWhitespace(error?.message)) { + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccurred"), + error.message + ); + return; } else if (error != null) { this.platformUtilsService.showToast( "error", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.ts index 6432796430c..41d75e8c639 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.ts @@ -1,11 +1,11 @@ import { Injectable } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SecretsManagerImportError } from "../models/error/sm-import-error"; import { SecretsManagerImportRequest } from "../models/requests/sm-import.request"; @@ -29,10 +29,10 @@ export class SecretsManagerPortingApiService { private i18nService: I18nService ) {} - async export(organizationId: string, exportFormat = "json"): Promise { + async export(organizationId: string): Promise { const response = await this.apiService.send( "GET", - "/sm/" + organizationId + "/export?format=" + exportFormat, + "/sm/" + organizationId + "/export", null, true, true diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting.service.ts index eb92a4a58be..8f29e38a9e5 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting.service.ts @@ -2,7 +2,7 @@ import { formatDate } from "@angular/common"; import { Injectable } from "@angular/core"; import { firstValueFrom } from "rxjs"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @Injectable({ providedIn: "root", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.ts index 1157f4878fc..c9b51b382ad 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.ts @@ -2,12 +2,12 @@ import { Injectable } from "@angular/core"; import { Subject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { BaseAccessPolicyView, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-selector.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-selector.component.ts index 96915dc419c..a5b4f4b05d9 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-selector.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-selector.component.ts @@ -12,7 +12,7 @@ import { tap, } from "rxjs"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view"; import { BaseAccessPolicyView } from "../../models/view/access-policy.view"; @@ -57,7 +57,16 @@ export class AccessSelectorComponent implements OnInit { protected rows$ = new Subject(); @Input() private set rows(value: AccessSelectorRowView[]) { - this.rows$.next(value); + const sorted = value.sort((a, b) => { + if (a.icon == b.icon) { + return a.name.localeCompare(b.name); + } + if (a.icon == AccessSelectorComponent.userIcon) { + return -1; + } + return 1; + }); + this.rows$.next(sorted); } private maxLength = 15; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.html index 2951cec9bf7..263281ed77a 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.html @@ -68,7 +68,7 @@ - diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.ts index 8c9110a3a65..0b7625a3902 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.component.ts @@ -2,9 +2,11 @@ import { Component, Input } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { combineLatest, map, Observable } from "rxjs"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { AccountProfile } from "@bitwarden/common/models/domain/account"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { AccountProfile } from "@bitwarden/common/platform/models/domain/account"; @Component({ selector: "sm-header", @@ -23,10 +25,12 @@ export class HeaderComponent { protected routeData$: Observable<{ titleId: string }>; protected account$: Observable; + protected canLock$: Observable; constructor( private route: ActivatedRoute, private stateService: StateService, + private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private messagingService: MessagingService ) { this.routeData$ = this.route.data.pipe( @@ -45,6 +49,9 @@ export class HeaderComponent { return accounts[activeAccount]?.profile; }) ); + this.canLock$ = this.vaultTimeoutSettingsService + .availableVaultTimeoutActions$() + .pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock))); } protected lock() { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.stories.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.stories.ts index 508573d4669..8e480ae50a1 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.stories.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/header.stories.ts @@ -4,14 +4,16 @@ import { Meta, Story, moduleMetadata, - componentWrapperDecorator, applicationConfig, + componentWrapperDecorator, } from "@storybook/angular"; import { BehaviorSubject, combineLatest, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { AvatarModule, BreadcrumbsModule, @@ -24,7 +26,7 @@ import { TypographyModule, InputModule, } from "@bitwarden/components"; -import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/app/tests/preloaded-english-i18n.module"; +import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/app/core/tests"; import { HeaderComponent } from "./header.component"; @@ -42,6 +44,12 @@ class MockMessagingService implements MessagingService { } } +class MockVaultTimeoutService { + availableVaultTimeoutActions$() { + return new BehaviorSubject([VaultTimeoutAction.Lock]).asObservable(); + } +} + @Component({ selector: "product-switcher", template: ``, @@ -89,6 +97,7 @@ export default { declarations: [HeaderComponent, MockProductSwitcher, MockDynamicAvatar], providers: [ { provide: StateService, useClass: MockStateService }, + { provide: VaultTimeoutSettingsService, useClass: MockVaultTimeoutService }, { provide: MessagingService, useFactory: () => { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts index ac0345c40e1..7ecc2f917a4 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/new-menu.component.ts @@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { Subject, takeUntil } from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; +import { DialogService } from "@bitwarden/components"; import { ProjectDialogComponent, @@ -26,7 +26,7 @@ export class NewMenuComponent implements OnInit, OnDestroy { private organizationId: string; private destroy$: Subject = new Subject(); - constructor(private route: ActivatedRoute, private dialogService: DialogServiceAbstraction) {} + constructor(private route: ActivatedRoute, private dialogService: DialogService) {} ngOnInit() { this.route.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html index 4b65d0a0c97..fe639343de2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/projects-list.component.html @@ -78,6 +78,10 @@ > + + + {{ "viewProject" | i18n }} +
    - - - {{ "viewProject" | i18n }} - -
    {{ secret.name }}
    +
    +
    + +
    +
    {{ secret.name }}
    +
    + {{ secret.id }} + +
    +
    diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts index ec0be650d6e..3f653434d6e 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/secrets-list.component.ts @@ -2,8 +2,8 @@ import { SelectionModel } from "@angular/cdk/collections"; import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core"; import { Subject, takeUntil } from "rxjs"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TableDataSource } from "@bitwarden/components"; import { SecretListView } from "../models/view/secret-list.view"; @@ -29,6 +29,7 @@ export class SecretsListComponent implements OnDestroy { @Input() set search(search: string) { + this.selection.clear(); this.dataSource.filter = search; } @@ -37,6 +38,7 @@ export class SecretsListComponent implements OnDestroy { @Output() editSecretEvent = new EventEmitter(); @Output() copySecretNameEvent = new EventEmitter(); @Output() copySecretValueEvent = new EventEmitter(); + @Output() copySecretUuidEvent = new EventEmitter(); @Output() onSecretCheckedEvent = new EventEmitter(); @Output() deleteSecretsEvent = new EventEmitter(); @Output() newSecretEvent = new EventEmitter(); @@ -61,15 +63,20 @@ export class SecretsListComponent implements OnDestroy { } isAllSelected() { - const numSelected = this.selection.selected.length; - const numRows = this.secrets.length; - return numSelected === numRows; + if (this.selection.selected?.length > 0) { + const numSelected = this.selection.selected.length; + const numRows = this.dataSource.filteredData.length; + return numSelected === numRows; + } + return false; } toggleAll() { - this.isAllSelected() - ? this.selection.clear() - : this.selection.select(...this.secrets.map((s) => s.id)); + if (this.isAllSelected()) { + this.selection.clear(); + } else { + this.selection.select(...this.dataSource.filteredData.map((s) => s.id)); + } } bulkDeleteSecrets() { @@ -143,6 +150,19 @@ export class SecretsListComponent implements OnDestroy { }); } + static copySecretUuid( + id: string, + platformUtilsService: PlatformUtilsService, + i18nService: I18nService + ) { + platformUtilsService.copyToClipboard(id); + platformUtilsService.showToast( + "success", + null, + i18nService.t("valueCopied", i18nService.t("uuid")) + ); + } + /** * TODO: Remove in favor of updating `PlatformUtilsService.copyToClipboard` */ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts index f279314b43e..5c18bab4e42 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts @@ -1,7 +1,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; -import { AuthGuard } from "@bitwarden/angular/auth/guards/auth.guard"; +import { AuthGuard } from "@bitwarden/angular/auth/guards"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard"; import { buildFlaggedRoute } from "@bitwarden/web-vault/app/oss-routing.module"; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/sm.guard.ts b/bitwarden_license/bit-web/src/app/secrets-manager/sm.guard.ts index 340f0484f7b..ee653b8f4ae 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/sm.guard.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/sm.guard.ts @@ -6,7 +6,7 @@ import { RouterStateSnapshot, } from "@angular/router"; -import { AuthGuard } from "@bitwarden/angular/auth/guards/auth.guard"; +import { AuthGuard } from "@bitwarden/angular/auth/guards"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts index 6787a81c9c8..bdc0b94f745 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-hard-delete.component.ts @@ -1,8 +1,8 @@ import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SecretService } from "../../secrets/secret.service"; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts index 99dce75f575..06fc20ca827 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/dialog/secret-restore.component.ts @@ -1,8 +1,8 @@ import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SecretService } from "../../secrets/secret.service"; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.html index e4990f0111a..03dc17b98d5 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.html @@ -9,4 +9,5 @@ (restoreSecretsEvent)="openRestoreSecret($event)" [secrets]="secrets$ | async" [trash]="true" + (copySecretUuidEvent)="copySecretUuid($event)" > diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts index 8835049f82d..e92a01ed279 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/trash/trash.component.ts @@ -2,10 +2,13 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { combineLatestWith, Observable, startWith, switchMap } from "rxjs"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; import { SecretListView } from "../models/view/secret-list.view"; import { SecretService } from "../secrets/secret.service"; +import { SecretsListComponent } from "../shared/secrets-list.component"; import { SecretHardDeleteDialogComponent, @@ -28,7 +31,9 @@ export class TrashComponent implements OnInit { constructor( private route: ActivatedRoute, private secretService: SecretService, - private dialogService: DialogServiceAbstraction + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private dialogService: DialogService ) {} ngOnInit() { @@ -65,4 +70,8 @@ export class TrashComponent implements OnInit { }, }); } + + copySecretUuid(id: string) { + SecretsListComponent.copySecretUuid(id, this.platformUtilsService, this.i18nService); + } } diff --git a/bitwarden_license/bit-web/tsconfig.json b/bitwarden_license/bit-web/tsconfig.json index 9f3a8224889..0612053e4e3 100644 --- a/bitwarden_license/bit-web/tsconfig.json +++ b/bitwarden_license/bit-web/tsconfig.json @@ -2,11 +2,13 @@ "extends": "../../apps/web/tsconfig", "compilerOptions": { "paths": { - "@bitwarden/web-vault/*": ["../../apps/web/src/*"], - "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/angular/*": ["../../libs/angular/src/*"], + "@bitwarden/auth": ["../../libs/auth/src"], + "@bitwarden/common/*": ["../../libs/common/src/*"], "@bitwarden/components": ["../../libs/components/src"], - "@bitwarden/exporter/*": ["../../libs/exporter/src/*"] + "@bitwarden/exporter/*": ["../../libs/exporter/src/*"], + "@bitwarden/vault": ["../../libs/vault/src"], + "@bitwarden/web-vault/*": ["../../apps/web/src/*"] } }, "include": ["src/**/*.stories.ts"] diff --git a/jest.config.js b/jest.config.js index 8b54a826583..035d90bda62 100644 --- a/jest.config.js +++ b/jest.config.js @@ -21,11 +21,13 @@ module.exports = { "/bitwarden_license/bit-web/jest.config.js", "/libs/angular/jest.config.js", + "/libs/auth/jest.config.js", "/libs/common/jest.config.js", "/libs/components/jest.config.js", - "/libs/importer/jest.config.js", "/libs/exporter/jest.config.js", + "/libs/importer/jest.config.js", "/libs/node/jest.config.js", + "/libs/vault/jest.config.js", ], // Workaround for a memory leak that crashes tests in CI: diff --git a/libs/angular/src/admin-console/components/collections.component.ts b/libs/angular/src/admin-console/components/collections.component.ts index c26d3b930ac..945a79d1d78 100644 --- a/libs/angular/src/admin-console/components/collections.component.ts +++ b/libs/angular/src/admin-console/components/collections.component.ts @@ -1,13 +1,13 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service"; -import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; @Directive() export class CollectionsComponent implements OnInit { @@ -37,7 +37,9 @@ export class CollectionsComponent implements OnInit { async load() { this.cipherDomain = await this.loadCipher(); this.collectionIds = this.loadCipherCollections(); - this.cipher = await this.cipherDomain.decrypt(); + this.cipher = await this.cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain) + ); this.collections = await this.loadCollections(); this.collections.forEach((c) => ((c as any).checked = false)); diff --git a/libs/angular/src/auth/components/base-login-decryption-options.component.ts b/libs/angular/src/auth/components/base-login-decryption-options.component.ts new file mode 100644 index 00000000000..3350c3999c6 --- /dev/null +++ b/libs/angular/src/auth/components/base-login-decryption-options.component.ts @@ -0,0 +1,291 @@ +import { Directive, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder, FormControl } from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + firstValueFrom, + switchMap, + Subject, + catchError, + from, + of, + finalize, + takeUntil, + defer, + throwError, +} from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { DevicesServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices.service.abstraction"; +import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; +import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account"; + +enum State { + NewUser, + ExistingUserUntrustedDevice, +} + +type NewUserData = { + readonly state: State.NewUser; + readonly organizationId: string; + readonly userEmail: string; +}; + +type ExistingUserUntrustedDeviceData = { + readonly state: State.ExistingUserUntrustedDevice; + readonly showApproveFromOtherDeviceBtn: boolean; + readonly showReqAdminApprovalBtn: boolean; + readonly showApproveWithMasterPasswordBtn: boolean; + readonly userEmail: string; +}; + +type Data = NewUserData | ExistingUserUntrustedDeviceData; + +@Directive() +export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + protected State = State; + + protected data?: Data; + protected loading = true; + + // Remember device means for the user to trust the device + rememberDeviceForm = this.formBuilder.group({ + rememberDevice: [true], + }); + + get rememberDevice(): FormControl { + return this.rememberDeviceForm?.controls.rememberDevice; + } + + constructor( + protected formBuilder: FormBuilder, + protected devicesService: DevicesServiceAbstraction, + protected stateService: StateService, + protected router: Router, + protected activatedRoute: ActivatedRoute, + protected messagingService: MessagingService, + protected tokenService: TokenService, + protected loginService: LoginService, + protected organizationApiService: OrganizationApiServiceAbstraction, + protected cryptoService: CryptoService, + protected organizationUserService: OrganizationUserService, + protected apiService: ApiService, + protected i18nService: I18nService, + protected validationService: ValidationService, + protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + protected platformUtilsService: PlatformUtilsService, + protected passwordResetEnrollmentService: PasswordResetEnrollmentServiceAbstraction + ) {} + + async ngOnInit() { + this.loading = true; + + this.setupRememberDeviceValueChanges(); + + // Persist user choice from state if it exists + await this.setRememberDeviceDefaultValue(); + + try { + const accountDecryptionOptions: AccountDecryptionOptions = + await this.stateService.getAccountDecryptionOptions(); + + // see sso-login.strategy - to determine if a user is new or not it just checks if there is a key on the token response.. + // can we check if they have a user key or master key in crypto service? Would that be sufficient? + if ( + !accountDecryptionOptions?.trustedDeviceOption?.hasAdminApproval && + !accountDecryptionOptions?.hasMasterPassword + ) { + // We are dealing with a new account if: + // - User does not have admin approval (i.e. has not enrolled into admin reset) + // - AND does not have a master password + + this.loadNewUserData(); + } else { + this.loadUntrustedDeviceData(accountDecryptionOptions); + } + + // Note: this is probably not a comprehensive write up of all scenarios: + + // If the TDE feature flag is enabled and TDE is configured for the org that the user is a member of, + // then new and existing users can be redirected here after completing the SSO flow (and 2FA if enabled). + + // First we must determine user type (new or existing): + + // New User + // - present user with option to remember the device or not (trust the device) + // - present a continue button to proceed to the vault + // - loadNewUserData() --> will need to load enrollment status and user email address. + + // Existing User + // - Determine if user is an admin with access to account recovery in admin console + // - Determine if user has a MP or not, if not, they must be redirected to set one (see PM-1035) + // - Determine if device is trusted or not via device crypto service (method not yet written) + // - If not trusted, present user with login decryption options (approve from other device, approve with master password, request admin approval) + // - loadUntrustedDeviceData() + } catch (err) { + this.validationService.showError(err); + } + } + + private async setRememberDeviceDefaultValue() { + const rememberDeviceFromState = await this.deviceTrustCryptoService.getShouldTrustDevice(); + + const rememberDevice = rememberDeviceFromState ?? true; + + this.rememberDevice.setValue(rememberDevice); + } + + private setupRememberDeviceValueChanges() { + this.rememberDevice.valueChanges + .pipe( + switchMap((value) => + defer(() => this.deviceTrustCryptoService.setShouldTrustDevice(value)) + ), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + async loadNewUserData() { + const autoEnrollStatus$ = defer(() => + this.stateService.getUserSsoOrganizationIdentifier() + ).pipe( + switchMap((organizationIdentifier) => { + if (organizationIdentifier == undefined) { + return throwError(() => new Error(this.i18nService.t("ssoIdentifierRequired"))); + } + + return from(this.organizationApiService.getAutoEnrollStatus(organizationIdentifier)); + }), + catchError((err: unknown) => { + this.validationService.showError(err); + return of(undefined); + }) + ); + + const email$ = from(this.stateService.getEmail()).pipe( + catchError((err: unknown) => { + this.validationService.showError(err); + return of(undefined); + }), + takeUntil(this.destroy$) + ); + + const autoEnrollStatus = await firstValueFrom(autoEnrollStatus$); + const email = await firstValueFrom(email$); + + this.data = { state: State.NewUser, organizationId: autoEnrollStatus.id, userEmail: email }; + this.loading = false; + } + + loadUntrustedDeviceData(accountDecryptionOptions: AccountDecryptionOptions) { + this.loading = true; + + const email$ = from(this.stateService.getEmail()).pipe( + catchError((err: unknown) => { + this.validationService.showError(err); + return of(undefined); + }), + takeUntil(this.destroy$) + ); + + email$ + .pipe( + takeUntil(this.destroy$), + finalize(() => { + this.loading = false; + }) + ) + .subscribe((email) => { + const showApproveFromOtherDeviceBtn = + accountDecryptionOptions?.trustedDeviceOption?.hasLoginApprovingDevice || false; + + const showReqAdminApprovalBtn = + !!accountDecryptionOptions?.trustedDeviceOption?.hasAdminApproval || false; + + const showApproveWithMasterPasswordBtn = + accountDecryptionOptions?.hasMasterPassword || false; + + const userEmail = email; + + this.data = { + state: State.ExistingUserUntrustedDevice, + showApproveFromOtherDeviceBtn, + showReqAdminApprovalBtn, + showApproveWithMasterPasswordBtn, + userEmail, + }; + }); + } + + async approveFromOtherDevice() { + if (this.data.state !== State.ExistingUserUntrustedDevice) { + return; + } + + this.loginService.setEmail(this.data.userEmail); + this.router.navigate(["/login-with-device"]); + } + + async requestAdminApproval() { + this.loginService.setEmail(this.data.userEmail); + this.router.navigate(["/admin-approval-requested"]); + } + + async approveWithMasterPassword() { + this.router.navigate(["/lock"], { queryParams: { from: "login-initiated" } }); + } + + async createUser() { + if (this.data.state !== State.NewUser) { + return; + } + + // this.loading to support clients without async-actions-support + this.loading = true; + try { + const { publicKey, privateKey } = await this.cryptoService.initAccount(); + const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString); + await this.apiService.postAccountKeys(keysRequest); + + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("accountSuccessfullyCreated") + ); + + await this.passwordResetEnrollmentService.enroll(this.data.organizationId); + + if (this.rememberDeviceForm.value.rememberDevice) { + await this.deviceTrustCryptoService.trustDevice(); + } + } catch (error) { + this.validationService.showError(error); + } finally { + this.loading = false; + } + } + + logOut() { + this.loading = true; // to avoid an awkward delay in browser extension + this.messagingService.send("logout"); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/libs/angular/src/auth/components/captcha-protected.component.ts b/libs/angular/src/auth/components/captcha-protected.component.ts index 0783b2605b7..621c6e9c7b5 100644 --- a/libs/angular/src/auth/components/captcha-protected.component.ts +++ b/libs/angular/src/auth/components/captcha-protected.component.ts @@ -1,10 +1,10 @@ import { Directive, Input } from "@angular/core"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { CaptchaIFrame } from "@bitwarden/common/auth/captcha-iframe"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; @Directive() export abstract class CaptchaProtectedComponent { diff --git a/libs/angular/src/auth/components/change-password.component.ts b/libs/angular/src/auth/components/change-password.component.ts index 18e98242b21..b9c2d1be4f5 100644 --- a/libs/angular/src/auth/components/change-password.component.ts +++ b/libs/angular/src/auth/components/change-password.component.ts @@ -1,21 +1,21 @@ import { Directive, OnDestroy, OnInit } from "@angular/core"; import { Subject, takeUntil } from "rxjs"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config"; import { KdfType } from "@bitwarden/common/enums"; -import { Utils } from "@bitwarden/common/misc/utils"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { MasterKey, UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { DialogService } from "@bitwarden/components"; -import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog"; import { PasswordColorText } from "../../shared/components/password-strength/password-strength.component"; @Directive() @@ -44,7 +44,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { protected platformUtilsService: PlatformUtilsService, protected policyService: PolicyService, protected stateService: StateService, - protected dialogService: DialogServiceAbstraction + protected dialogService: DialogService ) {} async ngOnInit() { @@ -79,23 +79,28 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { if (this.kdfConfig == null) { this.kdfConfig = await this.stateService.getKdfConfig(); } - const key = await this.cryptoService.makeKey( + + // Create new master key + const newMasterKey = await this.cryptoService.makeMasterKey( this.masterPassword, email.trim().toLowerCase(), this.kdf, this.kdfConfig ); - const masterPasswordHash = await this.cryptoService.hashPassword(this.masterPassword, key); + const newMasterKeyHash = await this.cryptoService.hashMasterKey( + this.masterPassword, + newMasterKey + ); - let encKey: [SymmetricCryptoKey, EncString] = null; - const existingEncKey = await this.cryptoService.getEncKey(); - if (existingEncKey == null) { - encKey = await this.cryptoService.makeEncKey(key); + let newProtectedUserKey: [UserKey, EncString] = null; + const userKey = await this.cryptoService.getUserKey(); + if (userKey == null) { + newProtectedUserKey = await this.cryptoService.makeUserKey(newMasterKey); } else { - encKey = await this.cryptoService.remakeEncKey(key); + newProtectedUserKey = await this.cryptoService.encryptUserKeyWithMasterKey(newMasterKey); } - await this.performSubmitActions(masterPasswordHash, key, encKey); + await this.performSubmitActions(newMasterKeyHash, newMasterKey, newProtectedUserKey); } async setupSubmitActions(): Promise { @@ -105,9 +110,9 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { } async performSubmitActions( - masterPasswordHash: string, - key: SymmetricCryptoKey, - encKey: [SymmetricCryptoKey, EncString] + newMasterKeyHash: string, + newMasterKey: MasterKey, + newUserKey: [UserKey, EncString] ) { // Override in sub-class } @@ -162,7 +167,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { const result = await this.dialogService.openSimpleDialog({ title: { key: "weakAndExposedMasterPassword" }, content: { key: "weakAndBreachedMasterPasswordDesc" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!result) { @@ -173,7 +178,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { const result = await this.dialogService.openSimpleDialog({ title: { key: "weakMasterPassword" }, content: { key: "weakMasterPasswordDesc" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!result) { @@ -184,7 +189,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { const result = await this.dialogService.openSimpleDialog({ title: { key: "exposedMasterPassword" }, content: { key: "exposedMasterPasswordDesc" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!result) { @@ -201,7 +206,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy { title: { key: "logOut" }, content: { key: "logOutConfirmation" }, acceptButtonText: { key: "logOut" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (confirmed) { diff --git a/libs/angular/src/auth/components/environment-selector.component.html b/libs/angular/src/auth/components/environment-selector.component.html index e64d09da066..2765787c317 100644 --- a/libs/angular/src/auth/components/environment-selector.component.html +++ b/libs/angular/src/auth/components/environment-selector.component.html @@ -1,36 +1,51 @@ -

    - diff --git a/libs/angular/src/auth/components/environment-selector.component.ts b/libs/angular/src/auth/components/environment-selector.component.ts index d35347c1479..19dc95dbcf6 100644 --- a/libs/angular/src/auth/components/environment-selector.component.ts +++ b/libs/angular/src/auth/components/environment-selector.component.ts @@ -4,9 +4,12 @@ import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/cor import { Router } from "@angular/router"; import { Subject, takeUntil } from "rxjs"; -import { ConfigServiceAbstraction } from "@bitwarden/common/abstractions/config/config.service.abstraction"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { + EnvironmentService as EnvironmentServiceAbstraction, + Region, +} from "@bitwarden/common/platform/abstractions/environment.service"; @Component({ selector: "environment-selector", @@ -37,9 +40,9 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy { euServerFlagEnabled: boolean; isOpen = false; showingModal = false; - selectedEnvironment: ServerEnvironment; - ServerEnvironmentType = ServerEnvironment; - overlayPostition: ConnectedPosition[] = [ + selectedEnvironment: Region; + ServerEnvironmentType = Region; + overlayPosition: ConnectedPosition[] = [ { originX: "start", originY: "bottom", @@ -50,7 +53,7 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy { protected componentDestroyed$: Subject = new Subject(); constructor( - protected environmentService: EnvironmentService, + protected environmentService: EnvironmentServiceAbstraction, protected configService: ConfigServiceAbstraction, protected router: Router ) {} @@ -67,33 +70,28 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy { this.componentDestroyed$.complete(); } - async toggle(option: ServerEnvironment) { + async toggle(option: Region) { this.isOpen = !this.isOpen; if (option === null) { return; } - if (option === ServerEnvironment.EU) { - await this.environmentService.setUrls({ base: "https://vault.bitwarden.eu" }); - } else if (option === ServerEnvironment.US) { - await this.environmentService.setUrls({ base: "https://vault.bitwarden.com" }); - } else if (option === ServerEnvironment.SelfHosted) { + + this.updateEnvironmentInfo(); + + if (option === Region.SelfHosted) { this.onOpenSelfHostedSettings.emit(); + return; } + + await this.environmentService.setRegion(option); this.updateEnvironmentInfo(); } async updateEnvironmentInfo() { - this.euServerFlagEnabled = await this.configService.getFeatureFlagBool( + this.selectedEnvironment = this.environmentService.selectedRegion; + this.euServerFlagEnabled = await this.configService.getFeatureFlag( FeatureFlag.DisplayEuEnvironmentFlag ); - const webvaultUrl = this.environmentService.getWebVaultUrl(); - if (this.environmentService.isSelfHosted()) { - this.selectedEnvironment = ServerEnvironment.SelfHosted; - } else if (webvaultUrl != null && webvaultUrl.includes("bitwarden.eu")) { - this.selectedEnvironment = ServerEnvironment.EU; - } else { - this.selectedEnvironment = ServerEnvironment.US; - } } close() { @@ -101,9 +99,3 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy { this.updateEnvironmentInfo(); } } - -enum ServerEnvironment { - US = "US", - EU = "EU", - SelfHosted = "Self-hosted", -} diff --git a/libs/angular/src/auth/components/hint.component.ts b/libs/angular/src/auth/components/hint.component.ts index bfa5d3b92ef..92875a345ff 100644 --- a/libs/angular/src/auth/components/hint.component.ts +++ b/libs/angular/src/auth/components/hint.component.ts @@ -2,11 +2,11 @@ import { Directive, OnInit } from "@angular/core"; import { Router } from "@angular/router"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { PasswordHintRequest } from "@bitwarden/common/auth/models/request/password-hint.request"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @Directive() export class HintComponent implements OnInit { diff --git a/libs/angular/src/auth/components/lock.component.ts b/libs/angular/src/auth/components/lock.component.ts index 77957ff4fea..46927923787 100644 --- a/libs/angular/src/auth/components/lock.component.ts +++ b/libs/angular/src/auth/components/lock.component.ts @@ -4,29 +4,31 @@ import { firstValueFrom, Subject } from "rxjs"; import { concatMap, take, takeUntil } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; -import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/enums"; -import { Utils } from "@bitwarden/common/misc/utils"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; - -import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { DialogService } from "@bitwarden/components"; @Directive() export class LockComponent implements OnInit, OnDestroy { @@ -34,20 +36,20 @@ export class LockComponent implements OnInit, OnDestroy { pin = ""; showPassword = false; email: string; - pinLock = false; + pinEnabled = false; + masterPasswordEnabled = false; webVaultHostname = ""; formPromise: Promise; supportsBiometric: boolean; biometricLock: boolean; biometricText: string; - hideInput: boolean; protected successRoute = "vault"; protected forcePasswordResetRoute = "update-temp-password"; protected onSuccessfulSubmit: () => Promise; private invalidPinAttempts = 0; - private pinSet: [boolean, boolean]; + private pinStatus: PinLockType; private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions = undefined; @@ -65,12 +67,13 @@ export class LockComponent implements OnInit, OnDestroy { protected stateService: StateService, protected apiService: ApiService, protected logService: LogService, - private keyConnectorService: KeyConnectorService, protected ngZone: NgZone, protected policyApiService: PolicyApiServiceAbstraction, protected policyService: InternalPolicyService, - protected passwordGenerationService: PasswordGenerationServiceAbstraction, - protected dialogService: DialogServiceAbstraction + protected passwordStrengthService: PasswordStrengthServiceAbstraction, + protected dialogService: DialogService, + protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + protected userVerificationService: UserVerificationService ) {} async ngOnInit() { @@ -90,7 +93,7 @@ export class LockComponent implements OnInit, OnDestroy { } async submit() { - if (this.pinLock) { + if (this.pinEnabled) { return await this.handlePinRequiredUnlock(); } @@ -102,7 +105,7 @@ export class LockComponent implements OnInit, OnDestroy { title: { key: "logOut" }, content: { key: "logOutConfirmation" }, acceptButtonText: { key: "logOut" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (confirmed) { @@ -115,18 +118,18 @@ export class LockComponent implements OnInit, OnDestroy { return; } - const success = (await this.cryptoService.getKey(KeySuffixOptions.Biometric)) != null; + const userKey = await this.cryptoService.getUserKeyFromStorage(KeySuffixOptions.Biometric); - if (success) { - await this.doContinue(false); + if (userKey) { + await this.setUserKeyAndContinue(userKey, false); } - return success; + return !!userKey; } togglePassword() { this.showPassword = !this.showPassword; - const input = document.getElementById(this.pinLock ? "pin" : "masterPassword"); + const input = document.getElementById(this.pinEnabled ? "pin" : "masterPassword"); if (this.ngZone.isStable) { input.focus(); } else { @@ -152,25 +155,58 @@ export class LockComponent implements OnInit, OnDestroy { try { const kdf = await this.stateService.getKdfType(); const kdfConfig = await this.stateService.getKdfConfig(); - if (this.pinSet[0]) { - const key = await this.cryptoService.makeKeyFromPin( + let userKeyPin: EncString; + let oldPinKey: EncString; + switch (this.pinStatus) { + case "PERSISTANT": { + userKeyPin = await this.stateService.getPinKeyEncryptedUserKey(); + const oldEncryptedPinKey = await this.stateService.getEncryptedPinProtected(); + oldPinKey = oldEncryptedPinKey ? new EncString(oldEncryptedPinKey) : undefined; + break; + } + case "TRANSIENT": { + userKeyPin = await this.stateService.getPinKeyEncryptedUserKeyEphemeral(); + oldPinKey = await this.stateService.getDecryptedPinProtected(); + break; + } + case "DISABLED": { + throw new Error("Pin is disabled"); + } + default: { + const _exhaustiveCheck: never = this.pinStatus; + return _exhaustiveCheck; + } + } + + let userKey: UserKey; + if (oldPinKey) { + userKey = await this.cryptoService.decryptAndMigrateOldPinKey( + this.pinStatus === "TRANSIENT", this.pin, this.email, kdf, kdfConfig, - await this.stateService.getDecryptedPinProtected() + oldPinKey ); - const encKey = await this.cryptoService.getEncKey(key); - const protectedPin = await this.stateService.getProtectedPin(); - const decPin = await this.cryptoService.decryptToUtf8(new EncString(protectedPin), encKey); - failed = decPin !== this.pin; - if (!failed) { - await this.setKeyAndContinue(key); - } } else { - const key = await this.cryptoService.makeKeyFromPin(this.pin, this.email, kdf, kdfConfig); - failed = false; - await this.setKeyAndContinue(key); + userKey = await this.cryptoService.decryptUserKeyWithPin( + this.pin, + this.email, + kdf, + kdfConfig, + userKeyPin + ); + } + + const protectedPin = await this.stateService.getProtectedPin(); + const decryptedPin = await this.cryptoService.decryptToUtf8( + new EncString(protectedPin), + userKey + ); + failed = decryptedPin !== this.pin; + + if (!failed) { + await this.setUserKeyAndContinue(userKey); } } catch { failed = true; @@ -206,18 +242,28 @@ export class LockComponent implements OnInit, OnDestroy { const kdf = await this.stateService.getKdfType(); const kdfConfig = await this.stateService.getKdfConfig(); - const key = await this.cryptoService.makeKey(this.masterPassword, this.email, kdf, kdfConfig); - const storedKeyHash = await this.cryptoService.getKeyHash(); + const masterKey = await this.cryptoService.makeMasterKey( + this.masterPassword, + this.email, + kdf, + kdfConfig + ); + const storedPasswordHash = await this.cryptoService.getMasterKeyHash(); let passwordValid = false; - if (storedKeyHash != null) { - passwordValid = await this.cryptoService.compareAndUpdateKeyHash(this.masterPassword, key); + if (storedPasswordHash != null) { + // Offline unlock possible + passwordValid = await this.cryptoService.compareAndUpdateKeyHash( + this.masterPassword, + masterKey + ); } else { + // Online only const request = new SecretVerificationRequest(); - const serverKeyHash = await this.cryptoService.hashPassword( + const serverKeyHash = await this.cryptoService.hashMasterKey( this.masterPassword, - key, + masterKey, HashPurpose.ServerAuthorization ); request.masterPasswordHash = serverKeyHash; @@ -226,14 +272,16 @@ export class LockComponent implements OnInit, OnDestroy { const response = await this.formPromise; this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse(response); passwordValid = true; - const localKeyHash = await this.cryptoService.hashPassword( + const localKeyHash = await this.cryptoService.hashMasterKey( this.masterPassword, - key, + masterKey, HashPurpose.LocalAuthorization ); - await this.cryptoService.setKeyHash(localKeyHash); + await this.cryptoService.setMasterKeyHash(localKeyHash); } catch (e) { this.logService.error(e); + } finally { + this.formPromise = null; } } @@ -246,19 +294,18 @@ export class LockComponent implements OnInit, OnDestroy { return; } - if (this.pinSet[0]) { - const protectedPin = await this.stateService.getProtectedPin(); - const encKey = await this.cryptoService.getEncKey(key); - const decPin = await this.cryptoService.decryptToUtf8(new EncString(protectedPin), encKey); - const pinKey = await this.cryptoService.makePinKey(decPin, this.email, kdf, kdfConfig); - await this.stateService.setDecryptedPinProtected( - await this.cryptoService.encrypt(key.key, pinKey) - ); - } - await this.setKeyAndContinue(key, true); + const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); + await this.cryptoService.setMasterKey(masterKey); + await this.setUserKeyAndContinue(userKey, true); } - private async setKeyAndContinue(key: SymmetricCryptoKey, evaluatePasswordAfterUnlock = false) { - await this.cryptoService.setKey(key); + + private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) { + await this.cryptoService.setUserKey(key); + + // Now that we have a decrypted user key in memory, we can check if we + // need to establish trust on the current device + await this.deviceTrustCryptoService.trustDeviceIfRequired(); + await this.doContinue(evaluatePasswordAfterUnlock); } @@ -296,24 +343,39 @@ export class LockComponent implements OnInit, OnDestroy { } private async load() { - this.pinSet = await this.vaultTimeoutSettingsService.isPinLockSet(); - this.pinLock = - (this.pinSet[0] && (await this.stateService.getDecryptedPinProtected()) != null) || - this.pinSet[1]; + // TODO: Investigate PM-3515 + + // The loading of the lock component works as follows: + // 1. First, is locking a valid timeout action? If not, we will log the user out. + // 2. If locking IS a valid timeout action, we proceed to show the user the lock screen. + // The user will be able to unlock as follows: + // - If they have a PIN set, they will be presented with the PIN input + // - If they have a master password and no PIN, they will be presented with the master password input + // - If they have biometrics enabled, they will be presented with the biometric prompt + + const availableVaultTimeoutActions = await firstValueFrom( + this.vaultTimeoutSettingsService.availableVaultTimeoutActions$() + ); + const supportsLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock); + if (!supportsLock) { + return await this.vaultTimeoutService.logOut(); + } + + this.pinStatus = await this.vaultTimeoutSettingsService.isPinLockSet(); + + let ephemeralPinSet = await this.stateService.getPinKeyEncryptedUserKeyEphemeral(); + ephemeralPinSet ||= await this.stateService.getDecryptedPinProtected(); + this.pinEnabled = + (this.pinStatus === "TRANSIENT" && !!ephemeralPinSet) || this.pinStatus === "PERSISTANT"; + this.masterPasswordEnabled = await this.userVerificationService.hasMasterPassword(); + this.supportsBiometric = await this.platformUtilsService.supportsBiometric(); this.biometricLock = (await this.vaultTimeoutSettingsService.isBiometricLockSet()) && - ((await this.cryptoService.hasKeyStored(KeySuffixOptions.Biometric)) || + ((await this.cryptoService.hasUserKeyStored(KeySuffixOptions.Biometric)) || !this.platformUtilsService.supportsSecureStorage()); this.biometricText = await this.stateService.getBiometricText(); this.email = await this.stateService.getEmail(); - const usesKeyConnector = await this.keyConnectorService.getUsesKeyConnector(); - this.hideInput = usesKeyConnector && !this.pinLock; - - // Users with key connector and without biometric or pin has no MP to unlock using - if (usesKeyConnector && !(this.biometricLock || this.pinLock)) { - await this.vaultTimeoutService.logOut(); - } const webVaultUrl = this.environmentService.getWebVaultUrl(); const vaultUrl = @@ -333,7 +395,7 @@ export class LockComponent implements OnInit, OnDestroy { return false; } - const passwordStrength = this.passwordGenerationService.passwordStrength( + const passwordStrength = this.passwordStrengthService.getPasswordStrength( this.masterPassword, this.email )?.score; diff --git a/libs/angular/src/auth/components/login-with-device.component.ts b/libs/angular/src/auth/components/login-with-device.component.ts index d2ed9f02a45..84d904ec7b6 100644 --- a/libs/angular/src/auth/components/login-with-device.component.ts +++ b/libs/angular/src/auth/components/login-with-device.component.ts @@ -1,38 +1,51 @@ import { Directive, OnDestroy, OnInit } from "@angular/core"; -import { Router } from "@angular/router"; +import { IsActiveMatchOptions, Router } from "@angular/router"; import { Subject, takeUntil } from "rxjs"; import { AnonymousHubService } from "@bitwarden/common/abstractions/anonymousHub.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable"; +import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason"; import { PasswordlessLogInCredentials } from "@bitwarden/common/auth/models/domain/log-in-credentials"; import { PasswordlessCreateAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-create-auth.request"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; -import { Utils } from "@bitwarden/common/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { HttpStatusCode } from "@bitwarden/common/enums/http-status-code.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { CaptchaProtectedComponent } from "./captcha-protected.component"; +// TODO: consider renaming this component something like LoginViaAuthReqComponent + +enum State { + StandardAuthRequest, + AdminAuthRequest, +} + @Directive() export class LoginWithDeviceComponent extends CaptchaProtectedComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); + userAuthNStatus: AuthenticationStatus; email: string; showResendNotification = false; passwordlessRequest: PasswordlessCreateAuthRequest; @@ -42,12 +55,19 @@ export class LoginWithDeviceComponent onSuccessfulLoginNavigate: () => Promise; onSuccessfulLoginForceResetNavigate: () => Promise; + protected adminApprovalRoute = "admin-approval-requested"; + + protected StateEnum = State; + protected state = State.StandardAuthRequest; + protected twoFactorRoute = "2fa"; protected successRoute = "vault"; protected forcePasswordResetRoute = "update-temp-password"; private resendTimeout = 12000; - private authRequestKeyPair: [publicKey: ArrayBuffer, privateKey: ArrayBuffer]; + private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array }; + + // TODO: in future, go to child components and remove child constructors and let deps fall through to the super class constructor( protected router: Router, private cryptoService: CryptoService, @@ -63,10 +83,14 @@ export class LoginWithDeviceComponent private anonymousHubService: AnonymousHubService, private validationService: ValidationService, private stateService: StateService, - private loginService: LoginService + private loginService: LoginService, + private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + private authReqCryptoService: AuthRequestCryptoServiceAbstraction ) { super(environmentService, i18nService, platformUtilsService); + // TODO: I don't know why this is necessary. + // Why would the existence of the email depend on the navigation? const navigation = this.router.getCurrentNavigation(); if (navigation) { this.email = this.loginService.getEmail(); @@ -77,25 +101,167 @@ export class LoginWithDeviceComponent .getPushNotificationObs$() .pipe(takeUntil(this.destroy$)) .subscribe((id) => { - this.confirmResponse(id); + // Only fires on approval currently + this.verifyAndHandleApprovedAuthReq(id); }); } async ngOnInit() { - if (!this.email) { - this.router.navigate(["/login"]); - return; + this.userAuthNStatus = await this.authService.getAuthStatus(); + + const matchOptions: IsActiveMatchOptions = { + paths: "exact", + queryParams: "ignored", + fragment: "ignored", + matrixParams: "ignored", + }; + + if (this.router.isActive(this.adminApprovalRoute, matchOptions)) { + this.state = State.AdminAuthRequest; + } + + if (this.state === State.AdminAuthRequest) { + // Pull email from state for admin auth reqs b/c it is available + // This also prevents it from being lost on refresh as the + // login service email does not persist. + this.email = await this.stateService.getEmail(); + + if (!this.email) { + this.platformUtilsService.showToast("error", null, this.i18nService.t("userEmailMissing")); + this.router.navigate(["/login-initiated"]); + return; + } + + // We only allow a single admin approval request to be active at a time + // so must check state to see if we have an existing one or not + const adminAuthReqStorable = await this.stateService.getAdminAuthRequest(); + + if (adminAuthReqStorable) { + await this.handleExistingAdminAuthRequest(adminAuthReqStorable); + } else { + // No existing admin auth request; so we need to create one + await this.startPasswordlessLogin(); + } + } else { + // Standard auth request + // TODO: evaluate if we can remove the setting of this.email in the constructor + this.email = this.loginService.getEmail(); + + if (!this.email) { + this.platformUtilsService.showToast("error", null, this.i18nService.t("userEmailMissing")); + this.router.navigate(["/login"]); + return; + } + + await this.startPasswordlessLogin(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.anonymousHubService.stopHubConnection(); + } + + private async handleExistingAdminAuthRequest(adminAuthReqStorable: AdminAuthRequestStorable) { + // Note: on login, the SSOLoginStrategy will also call to see an existing admin auth req + // has been approved and handle it if so. + + // Regardless, we always retrieve the auth request from the server verify and handle status changes here as well + let adminAuthReqResponse: AuthRequestResponse; + try { + adminAuthReqResponse = await this.apiService.getAuthRequest(adminAuthReqStorable.id); + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) { + return await this.handleExistingAdminAuthReqDeletedOrDenied(); + } + } + + // Request doesn't exist anymore + if (!adminAuthReqResponse) { + return await this.handleExistingAdminAuthReqDeletedOrDenied(); } + // Re-derive the user's fingerprint phrase + // It is important to not use the server's public key here as it could have been compromised via MITM + const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey( + adminAuthReqStorable.privateKey + ); + this.fingerprintPhrase = ( + await this.cryptoService.getFingerprint(this.email, derivedPublicKeyArrayBuffer) + ).join("-"); + + // Request denied + if (adminAuthReqResponse.isAnswered && !adminAuthReqResponse.requestApproved) { + return await this.handleExistingAdminAuthReqDeletedOrDenied(); + } + + // Request approved + if (adminAuthReqResponse.requestApproved) { + return await this.handleApprovedAdminAuthRequest( + adminAuthReqResponse, + adminAuthReqStorable.privateKey + ); + } + + // Request still pending response from admin + // So, create hub connection so that any approvals will be received via push notification + this.anonymousHubService.createHubConnection(adminAuthReqStorable.id); + } + + private async handleExistingAdminAuthReqDeletedOrDenied() { + // clear the admin auth request from state + await this.stateService.setAdminAuthRequest(null); + + // start new auth request this.startPasswordlessLogin(); } + private async buildAuthRequest(authRequestType: AuthRequestType) { + const authRequestKeyPairArray = await this.cryptoFunctionService.rsaGenerateKeyPair(2048); + + this.authRequestKeyPair = { + publicKey: authRequestKeyPairArray[0], + privateKey: authRequestKeyPairArray[1], + }; + + const deviceIdentifier = await this.appIdService.getAppId(); + const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey); + const accessCode = await this.passwordGenerationService.generatePassword({ length: 25 }); + + this.fingerprintPhrase = ( + await this.cryptoService.getFingerprint(this.email, this.authRequestKeyPair.publicKey) + ).join("-"); + + this.passwordlessRequest = new PasswordlessCreateAuthRequest( + this.email, + deviceIdentifier, + publicKey, + authRequestType, + accessCode + ); + } + async startPasswordlessLogin() { this.showResendNotification = false; try { - await this.buildAuthRequest(); - const reqResponse = await this.apiService.postAuthRequest(this.passwordlessRequest); + let reqResponse: AuthRequestResponse; + + if (this.state === State.AdminAuthRequest) { + await this.buildAuthRequest(AuthRequestType.AdminApproval); + reqResponse = await this.apiService.postAdminAuthRequest(this.passwordlessRequest); + + const adminAuthReqStorable = new AdminAuthRequestStorable({ + id: reqResponse.id, + privateKey: this.authRequestKeyPair.privateKey, + }); + + await this.stateService.setAdminAuthRequest(adminAuthReqStorable); + } else { + await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock); + reqResponse = await this.apiService.postAuthRequest(this.passwordlessRequest); + } if (reqResponse.id) { this.anonymousHubService.createHubConnection(reqResponse.id); @@ -109,52 +275,69 @@ export class LoginWithDeviceComponent }, this.resendTimeout); } - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - this.anonymousHubService.stopHubConnection(); - } - - private async confirmResponse(requestId: string) { + private async verifyAndHandleApprovedAuthReq(requestId: string) { try { - const response = await this.apiService.getAuthResponse( - requestId, - this.passwordlessRequest.accessCode - ); + // Retrieve the auth request from server and verify it's approved + let authReqResponse: AuthRequestResponse; + + switch (this.state) { + case State.StandardAuthRequest: + // Unauthed - access code required for user verification + authReqResponse = await this.apiService.getAuthResponse( + requestId, + this.passwordlessRequest.accessCode + ); + break; - if (!response.requestApproved) { + case State.AdminAuthRequest: + // Authed - no access code required + authReqResponse = await this.apiService.getAuthRequest(requestId); + break; + + default: + break; + } + + if (!authReqResponse.requestApproved) { return; } - const credentials = await this.buildLoginCredentials(requestId, response); - const loginResponse = await this.authService.logIn(credentials); + // Approved so proceed: - if (loginResponse.requiresTwoFactor) { - if (this.onSuccessfulLoginTwoFactorNavigate != null) { - this.onSuccessfulLoginTwoFactorNavigate(); - } else { - this.router.navigate([this.twoFactorRoute]); - } - } else if (loginResponse.forcePasswordReset != ForceResetPasswordReason.None) { - if (this.onSuccessfulLoginForceResetNavigate != null) { - this.onSuccessfulLoginForceResetNavigate(); - } else { - this.router.navigate([this.forcePasswordResetRoute]); - } - } else { - await this.setRememberEmailValues(); - if (this.onSuccessfulLogin != null) { - this.onSuccessfulLogin(); - } - if (this.onSuccessfulLoginNavigate != null) { - this.onSuccessfulLoginNavigate(); - } else { - this.router.navigate([this.successRoute]); - } + // 4 Scenarios to handle for approved auth requests: + // Existing flow 1: + // - Anon Login with Device > User is not AuthN > receives approval from device with pubKey(masterKey) + // > decrypt masterKey > must authenticate > gets masterKey(userKey) > decrypt userKey and proceed to vault + + // 3 new flows from TDE: + // Flow 2: + // - Post SSO > User is AuthN > SSO login strategy success sets masterKey(userKey) > receives approval from device with pubKey(masterKey) + // > decrypt masterKey > decrypt userKey > establish trust if required > proceed to vault + // Flow 3: + // - Post SSO > User is AuthN > Receives approval from device with pubKey(userKey) > decrypt userKey > establish trust if required > proceed to vault + // Flow 4: + // - Anon Login with Device > User is not AuthN > receives approval from device with pubKey(userKey) + // > decrypt userKey > must authenticate > set userKey > proceed to vault + + // if user has authenticated via SSO + if (this.userAuthNStatus === AuthenticationStatus.Locked) { + return await this.handleApprovedAdminAuthRequest( + authReqResponse, + this.authRequestKeyPair.privateKey + ); } + + // Flow 1 and 4: + const loginAuthResult = await this.loginViaPasswordlessStrategy(requestId, authReqResponse); + await this.handlePostLoginNavigation(loginAuthResult); } catch (error) { if (error instanceof ErrorResponse) { - this.router.navigate(["/login"]); + let errorRoute = "/login"; + if (this.state === State.AdminAuthRequest) { + errorRoute = "/login-initiated"; + } + + this.router.navigate([errorRoute]); this.validationService.showError(error); return; } @@ -163,50 +346,132 @@ export class LoginWithDeviceComponent } } - async setRememberEmailValues() { - const rememberEmail = this.loginService.getRememberEmail(); - const rememberedEmail = this.loginService.getEmail(); - await this.stateService.setRememberedEmail(rememberEmail ? rememberedEmail : null); - this.loginService.clearValues(); - } + async handleApprovedAdminAuthRequest( + adminAuthReqResponse: AuthRequestResponse, + privateKey: ArrayBuffer + ) { + // See verifyAndHandleApprovedAuthReq(...) for flow details + // it's flow 2 or 3 based on presence of masterPasswordHash + if (adminAuthReqResponse.masterPasswordHash) { + // Flow 2: masterPasswordHash is not null + // key is authRequestPublicKey(masterKey) + we have authRequestPublicKey(masterPasswordHash) + await this.authReqCryptoService.setKeysAfterDecryptingSharedMasterKeyAndHash( + adminAuthReqResponse, + privateKey + ); + } else { + // Flow 3: masterPasswordHash is null + // we can assume key is authRequestPublicKey(userKey) and we can just decrypt with userKey and proceed to vault + await this.authReqCryptoService.setUserKeyAfterDecryptingSharedUserKey( + adminAuthReqResponse, + privateKey + ); + } - private async buildAuthRequest() { - this.authRequestKeyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048); - const deviceIdentifier = await this.appIdService.getAppId(); - const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair[0]); - const accessCode = await this.passwordGenerationService.generatePassword({ length: 25 }); + // clear the admin auth request from state so it cannot be used again (it's a one time use) + // TODO: this should eventually be enforced via deleting this on the server once it is used + await this.stateService.setAdminAuthRequest(null); - this.fingerprintPhrase = ( - await this.cryptoService.getFingerprint(this.email, this.authRequestKeyPair[0]) - ).join("-"); + this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved")); - this.passwordlessRequest = new PasswordlessCreateAuthRequest( - this.email, - deviceIdentifier, - publicKey, - AuthRequestType.AuthenticateAndUnlock, - accessCode - ); + // Now that we have a decrypted user key in memory, we can check if we + // need to establish trust on the current device + await this.deviceTrustCryptoService.trustDeviceIfRequired(); + + // TODO: don't forget to use auto enrollment service everywhere we trust device + + await this.handleSuccessfulLoginNavigation(); } - private async buildLoginCredentials( + // Authentication helper + private async buildPasswordlessLoginCredentials( requestId: string, response: AuthRequestResponse ): Promise { - const decKey = await this.cryptoService.rsaDecrypt(response.key, this.authRequestKeyPair[1]); - const decMasterPasswordHash = await this.cryptoService.rsaDecrypt( - response.masterPasswordHash, - this.authRequestKeyPair[1] - ); - const key = new SymmetricCryptoKey(decKey); - const localHashedPassword = Utils.fromBufferToUtf8(decMasterPasswordHash); + // if masterPasswordHash has a value, we will always receive key as authRequestPublicKey(masterKey) + authRequestPublicKey(masterPasswordHash) + // if masterPasswordHash is null, we will always receive key as authRequestPublicKey(userKey) + if (response.masterPasswordHash) { + const { masterKey, masterKeyHash } = + await this.authReqCryptoService.decryptPubKeyEncryptedMasterKeyAndHash( + response.key, + response.masterPasswordHash, + this.authRequestKeyPair.privateKey + ); - return new PasswordlessLogInCredentials( - this.email, - this.passwordlessRequest.accessCode, - requestId, - key, - localHashedPassword - ); + return new PasswordlessLogInCredentials( + this.email, + this.passwordlessRequest.accessCode, + requestId, + null, // no userKey + masterKey, + masterKeyHash + ); + } else { + const userKey = await this.authReqCryptoService.decryptPubKeyEncryptedUserKey( + response.key, + this.authRequestKeyPair.privateKey + ); + return new PasswordlessLogInCredentials( + this.email, + this.passwordlessRequest.accessCode, + requestId, + userKey, + null, // no masterKey + null // no masterKeyHash + ); + } + } + + private async loginViaPasswordlessStrategy( + requestId: string, + authReqResponse: AuthRequestResponse + ): Promise { + // Note: credentials change based on if the authReqResponse.key is a encryptedMasterKey or UserKey + const credentials = await this.buildPasswordlessLoginCredentials(requestId, authReqResponse); + + // Note: keys are set by PasswordlessLogInStrategy success handling + return await this.authService.logIn(credentials); + } + + // Routing logic + private async handlePostLoginNavigation(loginResponse: AuthResult) { + if (loginResponse.requiresTwoFactor) { + if (this.onSuccessfulLoginTwoFactorNavigate != null) { + this.onSuccessfulLoginTwoFactorNavigate(); + } else { + this.router.navigate([this.twoFactorRoute]); + } + } else if (loginResponse.forcePasswordReset != ForceResetPasswordReason.None) { + if (this.onSuccessfulLoginForceResetNavigate != null) { + this.onSuccessfulLoginForceResetNavigate(); + } else { + this.router.navigate([this.forcePasswordResetRoute]); + } + } else { + await this.handleSuccessfulLoginNavigation(); + } + } + + async setRememberEmailValues() { + const rememberEmail = this.loginService.getRememberEmail(); + const rememberedEmail = this.loginService.getEmail(); + await this.stateService.setRememberedEmail(rememberEmail ? rememberedEmail : null); + this.loginService.clearValues(); + } + + private async handleSuccessfulLoginNavigation() { + if (this.state === State.StandardAuthRequest) { + // Only need to set remembered email on standard login with auth req flow + await this.setRememberEmailValues(); + } + + if (this.onSuccessfulLogin != null) { + this.onSuccessfulLogin(); + } + if (this.onSuccessfulLoginNavigate != null) { + this.onSuccessfulLoginNavigate(); + } else { + this.router.navigate([this.successRoute]); + } } } diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index e882d938946..02ba54e6f13 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -3,26 +3,27 @@ import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { take } from "rxjs/operators"; -import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; -import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; -import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { - AllValidationErrors, - FormValidationErrorsService, -} from "@bitwarden/common/abstractions/formValidationErrors.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason"; import { PasswordLogInCredentials } from "@bitwarden/common/auth/models/domain/log-in-credentials"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { + AllValidationErrors, + FormValidationErrorsService, +} from "../../platform/abstractions/form-validation-errors.service"; + import { CaptchaProtectedComponent } from "./captcha-protected.component"; @Directive() @@ -35,14 +36,16 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit onSuccessfulLoginNavigate: () => Promise; onSuccessfulLoginTwoFactorNavigate: () => Promise; onSuccessfulLoginForceResetNavigate: () => Promise; - selfHosted = false; showLoginWithDevice: boolean; validatedEmail = false; paramEmailSet = false; formGroup = this.formBuilder.group({ email: ["", [Validators.required, Validators.email]], - masterPassword: ["", [Validators.required, Validators.minLength(8)]], + masterPassword: [ + "", + [Validators.required, Validators.minLength(Utils.originalMinimumPasswordLength)], + ], rememberEmail: [false], }); @@ -73,7 +76,6 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit protected loginService: LoginService ) { super(environmentService, i18nService, platformUtilsService); - this.selfHosted = platformUtilsService.isSelfHost(); } get selfHostedDomain() { @@ -139,6 +141,8 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit await this.loginService.saveEmailSettings(); if (this.handleCaptchaRequired(response)) { return; + } else if (this.handleMigrateEncryptionKey(response)) { + return; } else if (response.requiresTwoFactor) { if (this.onSuccessfulLoginTwoFactorNavigate != null) { this.onSuccessfulLoginTwoFactorNavigate(); @@ -270,6 +274,21 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit await this.loginService.saveEmailSettings(); } + // Legacy accounts used the master key to encrypt data. Migration is required + // but only performed on web + protected handleMigrateEncryptionKey(result: AuthResult): boolean { + if (!result.requiresEncryptionKeyMigration) { + return false; + } + + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccured"), + this.i18nService.t("encryptionKeyMigrationRequired") + ); + return true; + } + private getErrorToastMessage() { const error: AllValidationErrors = this.formValidationErrorService .getFormValidationErrors(this.formGroup.controls) @@ -279,6 +298,8 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit switch (error.errorName) { case "email": return this.i18nService.t("invalidEmail"); + case "minlength": + return this.i18nService.t("masterPasswordMinlength", Utils.originalMinimumPasswordLength); default: return this.i18nService.t(this.errorTag(error)); } @@ -295,9 +316,10 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit async getLoginWithDevice(email: string) { try { const deviceIdentifier = await this.appIdService.getAppId(); - const res = await this.devicesApiService.getKnownDevice(email, deviceIdentifier); - //ensure the application is not self-hosted - this.showLoginWithDevice = res && !this.selfHosted; + this.showLoginWithDevice = await this.devicesApiService.getKnownDevice( + email, + deviceIdentifier + ); } catch (e) { this.showLoginWithDevice = false; } diff --git a/libs/angular/src/auth/components/remove-password.component.ts b/libs/angular/src/auth/components/remove-password.component.ts index c53196feaff..8fc14eb373b 100644 --- a/libs/angular/src/auth/components/remove-password.component.ts +++ b/libs/angular/src/auth/components/remove-password.component.ts @@ -1,15 +1,14 @@ import { Directive, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; - -import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog"; +import { DialogService } from "@bitwarden/components"; @Directive() export class RemovePasswordComponent implements OnInit { @@ -29,7 +28,7 @@ export class RemovePasswordComponent implements OnInit { private i18nService: I18nService, private keyConnectorService: KeyConnectorService, private organizationApiService: OrganizationApiServiceAbstraction, - private dialogService: DialogServiceAbstraction + private dialogService: DialogService ) {} async ngOnInit() { @@ -61,7 +60,7 @@ export class RemovePasswordComponent implements OnInit { const confirmed = await this.dialogService.openSimpleDialog({ title: this.organization.name, content: { key: "leaveOrganizationConfirmation" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!confirmed) { diff --git a/libs/angular/src/auth/components/sso.component.spec.ts b/libs/angular/src/auth/components/sso.component.spec.ts new file mode 100644 index 00000000000..6d81b3d61ec --- /dev/null +++ b/libs/angular/src/auth/components/sso.component.spec.ts @@ -0,0 +1,567 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute, Router } from "@angular/router"; +import { MockProxy, mock } from "jest-mock-extended"; +import { Observable, of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; +import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; +import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason"; +import { KeyConnectorUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/key-connector-user-decryption-option"; +import { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; + +import { SsoComponent } from "./sso.component"; +// test component that extends the SsoComponent +@Component({}) +class TestSsoComponent extends SsoComponent {} + +interface SsoComponentProtected { + twoFactorRoute: string; + successRoute: string; + trustedDeviceEncRoute: string; + changePasswordRoute: string; + forcePasswordResetRoute: string; + logIn(code: string, codeVerifier: string, orgIdFromState: string): Promise; + handleLoginError(e: any): Promise; +} + +// The ideal scenario would be to not have to test the protected / private methods of the SsoComponent +// but that will require a refactor of the SsoComponent class which is out of scope for now. +// This test suite allows us to be sure that the new Trusted Device encryption flows + mild refactors +// of the SsoComponent don't break the existing post login flows. +describe("SsoComponent", () => { + let component: TestSsoComponent; + let _component: SsoComponentProtected; + let fixture: ComponentFixture; + + // Mock Services + let mockAuthService: MockProxy; + let mockRouter: MockProxy; + let mockI18nService: MockProxy; + + let mockQueryParams: Observable; + let mockActivatedRoute: ActivatedRoute; + + let mockStateService: MockProxy; + let mockPlatformUtilsService: MockProxy; + let mockApiService: MockProxy; + let mockCryptoFunctionService: MockProxy; + let mockEnvironmentService: MockProxy; + let mockPasswordGenerationService: MockProxy; + let mockLogService: MockProxy; + let mockConfigService: MockProxy; + + // Mock authService.logIn params + let code: string; + let codeVerifier: string; + let orgIdFromState: string; + + // Mock component callbacks + let mockOnSuccessfulLogin: jest.Mock; + let mockOnSuccessfulLoginNavigate: jest.Mock; + let mockOnSuccessfulLoginTwoFactorNavigate: jest.Mock; + let mockOnSuccessfulLoginChangePasswordNavigate: jest.Mock; + let mockOnSuccessfulLoginForceResetNavigate: jest.Mock; + let mockOnSuccessfulLoginTdeNavigate: jest.Mock; + + let mockAcctDecryptionOpts: { + noMasterPassword: AccountDecryptionOptions; + withMasterPassword: AccountDecryptionOptions; + withMasterPasswordAndTrustedDevice: AccountDecryptionOptions; + withMasterPasswordAndTrustedDeviceWithManageResetPassword: AccountDecryptionOptions; + withMasterPasswordAndKeyConnector: AccountDecryptionOptions; + noMasterPasswordWithTrustedDevice: AccountDecryptionOptions; + noMasterPasswordWithTrustedDeviceWithManageResetPassword: AccountDecryptionOptions; + noMasterPasswordWithKeyConnector: AccountDecryptionOptions; + }; + + beforeEach(() => { + // Mock Services + mockAuthService = mock(); + mockRouter = mock(); + mockI18nService = mock(); + + // Default mockQueryParams + mockQueryParams = of({ code: "code", state: "state" }); + // Create a custom mock for ActivatedRoute with mock queryParams + mockActivatedRoute = { + queryParams: mockQueryParams, + } as any as ActivatedRoute; + + mockStateService = mock(); + mockPlatformUtilsService = mock(); + mockApiService = mock(); + mockCryptoFunctionService = mock(); + mockEnvironmentService = mock(); + mockPasswordGenerationService = mock(); + mockLogService = mock(); + mockConfigService = mock(); + + // Mock authService.logIn params + code = "code"; + codeVerifier = "codeVerifier"; + orgIdFromState = "orgIdFromState"; + + // Mock component callbacks + mockOnSuccessfulLogin = jest.fn(); + mockOnSuccessfulLoginNavigate = jest.fn(); + mockOnSuccessfulLoginTwoFactorNavigate = jest.fn(); + mockOnSuccessfulLoginChangePasswordNavigate = jest.fn(); + mockOnSuccessfulLoginForceResetNavigate = jest.fn(); + mockOnSuccessfulLoginTdeNavigate = jest.fn(); + + mockAcctDecryptionOpts = { + noMasterPassword: new AccountDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + }), + withMasterPassword: new AccountDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + }), + withMasterPasswordAndTrustedDevice: new AccountDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false), + keyConnectorOption: undefined, + }), + withMasterPasswordAndTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true), + keyConnectorOption: undefined, + }), + withMasterPasswordAndKeyConnector: new AccountDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: undefined, + keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"), + }), + noMasterPasswordWithTrustedDevice: new AccountDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false), + keyConnectorOption: undefined, + }), + noMasterPasswordWithTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true), + keyConnectorOption: undefined, + }), + noMasterPasswordWithKeyConnector: new AccountDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"), + }), + }; + + TestBed.configureTestingModule({ + declarations: [TestSsoComponent], + providers: [ + { provide: AuthService, useValue: mockAuthService }, + { provide: Router, useValue: mockRouter }, + { provide: I18nService, useValue: mockI18nService }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: StateService, useValue: mockStateService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + + { provide: ApiService, useValue: mockApiService }, + { provide: CryptoFunctionService, useValue: mockCryptoFunctionService }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: PasswordGenerationServiceAbstraction, useValue: mockPasswordGenerationService }, + + { provide: LogService, useValue: mockLogService }, + { provide: ConfigServiceAbstraction, useValue: mockConfigService }, + ], + }); + + fixture = TestBed.createComponent(TestSsoComponent); + component = fixture.componentInstance; + _component = component as any; + }); + + afterEach(() => { + // Reset all mocks after each test + jest.resetAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("navigateViaCallbackOrRoute(...)", () => { + it("calls the provided callback when callback is defined", async () => { + const callback = jest.fn().mockResolvedValue(null); + const commands = ["some", "route"]; + + await (component as any).navigateViaCallbackOrRoute(callback, commands); + + expect(callback).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it("calls router.navigate when callback is not defined", async () => { + const commands = ["some", "route"]; + + await (component as any).navigateViaCallbackOrRoute(undefined, commands); + + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith(commands, undefined); + }); + }); + + describe("logIn(...)", () => { + describe("2FA scenarios", () => { + beforeEach(() => { + const authResult = new AuthResult(); + authResult.twoFactorProviders = new Map([[TwoFactorProviderType.Authenticator, {}]]); + + // use standard user with MP because this test is not concerned with password reset. + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.withMasterPassword + ); + + mockAuthService.logIn.mockResolvedValue(authResult); + }); + + it("calls authService.logIn and navigates to the component's defined 2FA route when the auth result requires 2FA and onSuccessfulLoginTwoFactorNavigate is not defined", async () => { + await _component.logIn(code, codeVerifier, orgIdFromState); + expect(mockAuthService.logIn).toHaveBeenCalledTimes(1); + + expect(mockOnSuccessfulLoginTwoFactorNavigate).not.toHaveBeenCalled(); + + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith([_component.twoFactorRoute], { + queryParams: { + identifier: orgIdFromState, + sso: "true", + }, + }); + + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + + it("calls onSuccessfulLoginTwoFactorNavigate instead of router.navigate when response.requiresTwoFactor is true and the callback is defined", async () => { + mockOnSuccessfulLoginTwoFactorNavigate = jest.fn().mockResolvedValue(null); + component.onSuccessfulLoginTwoFactorNavigate = mockOnSuccessfulLoginTwoFactorNavigate; + + await _component.logIn(code, codeVerifier, orgIdFromState); + + expect(mockAuthService.logIn).toHaveBeenCalledTimes(1); + expect(mockOnSuccessfulLoginTwoFactorNavigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + }); + + // Shared test helpers + const testChangePasswordOnSuccessfulLogin = () => { + it("navigates to the component's defined change password route when onSuccessfulLoginChangePasswordNavigate callback is undefined", async () => { + await _component.logIn(code, codeVerifier, orgIdFromState); + expect(mockAuthService.logIn).toHaveBeenCalledTimes(1); + + expect(mockOnSuccessfulLoginChangePasswordNavigate).not.toHaveBeenCalled(); + + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], { + queryParams: { + identifier: orgIdFromState, + }, + }); + + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + }; + + const testOnSuccessfulLoginChangePasswordNavigate = () => { + it("calls onSuccessfulLoginChangePasswordNavigate instead of router.navigate when the callback is defined", async () => { + mockOnSuccessfulLoginChangePasswordNavigate = jest.fn().mockResolvedValue(null); + component.onSuccessfulLoginChangePasswordNavigate = + mockOnSuccessfulLoginChangePasswordNavigate; + + await _component.logIn(code, codeVerifier, orgIdFromState); + + expect(mockAuthService.logIn).toHaveBeenCalledTimes(1); + expect(mockOnSuccessfulLoginChangePasswordNavigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + }; + + const testForceResetOnSuccessfulLogin = (reasonString: string) => { + it(`navigates to the component's defined forcePasswordResetRoute when response.forcePasswordReset is ${reasonString}`, async () => { + await _component.logIn(code, codeVerifier, orgIdFromState); + + expect(mockAuthService.logIn).toHaveBeenCalledTimes(1); + + expect(mockOnSuccessfulLoginForceResetNavigate).not.toHaveBeenCalled(); + + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], { + queryParams: { + identifier: orgIdFromState, + }, + }); + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + }; + + const testOnSuccessfulLoginForceResetNavigate = (reasonString: string) => { + it(`calls onSuccessfulLoginForceResetNavigate instead of router.navigate when response.forcePasswordReset is ${reasonString} and the callback is defined`, async () => { + mockOnSuccessfulLoginForceResetNavigate = jest.fn().mockResolvedValue(null); + component.onSuccessfulLoginForceResetNavigate = mockOnSuccessfulLoginForceResetNavigate; + + await _component.logIn(code, codeVerifier, orgIdFromState); + + expect(mockAuthService.logIn).toHaveBeenCalledTimes(1); + expect(mockOnSuccessfulLoginForceResetNavigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + }; + + describe("Trusted Device Encryption scenarios", () => { + beforeEach(() => { + mockConfigService.getFeatureFlag.mockResolvedValue(true); // TDE enabled + }); + + describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => { + let authResult; + beforeEach(() => { + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword + ); + + authResult = new AuthResult(); + mockAuthService.logIn.mockResolvedValue(authResult); + }); + + testChangePasswordOnSuccessfulLogin(); + testOnSuccessfulLoginChangePasswordNavigate(); + }); + + describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => { + [ + ForceResetPasswordReason.AdminForcePasswordReset, + // ForceResetPasswordReason.WeakMasterPassword, -- not possible in SSO flow as set client side + ].forEach((forceResetPasswordReason) => { + const reasonString = ForceResetPasswordReason[forceResetPasswordReason]; + let authResult; + beforeEach(() => { + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice + ); + + authResult = new AuthResult(); + authResult.forcePasswordReset = ForceResetPasswordReason.AdminForcePasswordReset; + mockAuthService.logIn.mockResolvedValue(authResult); + }); + + testForceResetOnSuccessfulLogin(reasonString); + testOnSuccessfulLoginForceResetNavigate(reasonString); + }); + }); + + describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => { + let authResult; + beforeEach(() => { + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice + ); + + authResult = new AuthResult(); + authResult.forcePasswordReset = ForceResetPasswordReason.None; + mockAuthService.logIn.mockResolvedValue(authResult); + }); + + it("navigates to the component's defined trusted device encryption route when login is successful and no callback is defined", async () => { + await _component.logIn(code, codeVerifier, orgIdFromState); + + expect(mockAuthService.logIn).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith( + [_component.trustedDeviceEncRoute], + undefined + ); + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + + it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => { + mockOnSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(null); + component.onSuccessfulLoginTdeNavigate = mockOnSuccessfulLoginTdeNavigate; + + await _component.logIn(code, codeVerifier, orgIdFromState); + + expect(mockAuthService.logIn).toHaveBeenCalledTimes(1); + + expect(mockOnSuccessfulLoginTdeNavigate).toHaveBeenCalledTimes(1); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + }); + }); + + describe("Set Master Password scenarios", () => { + beforeEach(() => { + const authResult = new AuthResult(); + mockAuthService.logIn.mockResolvedValue(authResult); + }); + + describe("Given user needs to set a master password", () => { + beforeEach(() => { + // Only need to test the case where the user has no master password to test the primary change mp flow here + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.noMasterPassword + ); + }); + + testChangePasswordOnSuccessfulLogin(); + testOnSuccessfulLoginChangePasswordNavigate(); + }); + + it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => { + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.noMasterPasswordWithKeyConnector + ); + + await _component.logIn(code, codeVerifier, orgIdFromState); + expect(mockAuthService.logIn).toHaveBeenCalledTimes(1); + + expect(mockOnSuccessfulLoginChangePasswordNavigate).not.toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], { + queryParams: { + identifier: orgIdFromState, + }, + }); + }); + }); + + describe("Force Master Password Reset scenarios", () => { + [ + ForceResetPasswordReason.AdminForcePasswordReset, + // ForceResetPasswordReason.WeakMasterPassword, -- not possible in SSO flow as set client side + ].forEach((forceResetPasswordReason) => { + const reasonString = ForceResetPasswordReason[forceResetPasswordReason]; + + beforeEach(() => { + // use standard user with MP because this test is not concerned with password reset. + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.withMasterPassword + ); + + const authResult = new AuthResult(); + authResult.forcePasswordReset = forceResetPasswordReason; + mockAuthService.logIn.mockResolvedValue(authResult); + }); + + testForceResetOnSuccessfulLogin(reasonString); + testOnSuccessfulLoginForceResetNavigate(reasonString); + }); + }); + + describe("Success scenarios", () => { + beforeEach(() => { + const authResult = new AuthResult(); + authResult.twoFactorProviders = null; + // use standard user with MP because this test is not concerned with password reset. + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.withMasterPassword + ); + authResult.forcePasswordReset = ForceResetPasswordReason.None; + mockAuthService.logIn.mockResolvedValue(authResult); + }); + + it("calls authService.logIn and navigates to the component's defined success route when the login is successful", async () => { + await _component.logIn(code, codeVerifier, orgIdFromState); + + expect(mockAuthService.logIn).toHaveBeenCalled(); + + expect(mockOnSuccessfulLoginNavigate).not.toHaveBeenCalled(); + expect(mockOnSuccessfulLogin).not.toHaveBeenCalled(); + + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined); + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + + it("calls onSuccessfulLogin if defined when login is successful", async () => { + mockOnSuccessfulLogin = jest.fn().mockResolvedValue(null); + component.onSuccessfulLogin = mockOnSuccessfulLogin; + + await _component.logIn(code, codeVerifier, orgIdFromState); + + expect(mockAuthService.logIn).toHaveBeenCalled(); + expect(mockOnSuccessfulLogin).toHaveBeenCalledTimes(1); + + expect(mockOnSuccessfulLoginNavigate).not.toHaveBeenCalled(); + + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined); + + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + + it("calls onSuccessfulLoginNavigate instead of router.navigate when login is successful and the callback is defined", async () => { + mockOnSuccessfulLoginNavigate = jest.fn().mockResolvedValue(null); + component.onSuccessfulLoginNavigate = mockOnSuccessfulLoginNavigate; + + await _component.logIn(code, codeVerifier, orgIdFromState); + + expect(mockAuthService.logIn).toHaveBeenCalled(); + + expect(mockOnSuccessfulLoginNavigate).toHaveBeenCalledTimes(1); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + + expect(mockLogService.error).not.toHaveBeenCalled(); + }); + }); + + describe("Error scenarios", () => { + it("calls handleLoginError when an error is thrown during logIn", async () => { + const errorMessage = "Key Connector error"; + const error = new Error(errorMessage); + mockAuthService.logIn.mockRejectedValue(error); + + const handleLoginErrorSpy = jest.spyOn(_component, "handleLoginError"); + + await _component.logIn(code, codeVerifier, orgIdFromState); + + expect(handleLoginErrorSpy).toHaveBeenCalledWith(error); + }); + }); + }); + + describe("handleLoginError(e)", () => { + it("logs the error and shows a toast when the error message is 'Key Connector error'", async () => { + const errorMessage = "Key Connector error"; + const error = new Error(errorMessage); + + mockI18nService.t.mockReturnValueOnce("ssoKeyConnectorError"); + + await _component.handleLoginError(error); + + expect(mockLogService.error).toHaveBeenCalledTimes(1); + expect(mockLogService.error).toHaveBeenCalledWith(error); + + expect(mockPlatformUtilsService.showToast).toHaveBeenCalledTimes(1); + expect(mockPlatformUtilsService.showToast).toHaveBeenCalledWith( + "error", + null, + "ssoKeyConnectorError" + ); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/angular/src/auth/components/sso.component.ts b/libs/angular/src/auth/components/sso.component.ts index 83c6494ace0..3484bb46d2f 100644 --- a/libs/angular/src/auth/components/sso.component.ts +++ b/libs/angular/src/auth/components/sso.component.ts @@ -1,20 +1,24 @@ import { Directive } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { ActivatedRoute, NavigationExtras, Router } from "@angular/router"; import { first } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason"; import { SsoLogInCredentials } from "@bitwarden/common/auth/models/domain/log-in-credentials"; +import { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option"; import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; @Directive() @@ -24,14 +28,18 @@ export class SsoComponent { formPromise: Promise; initiateSsoFormPromise: Promise; - onSuccessfulLogin: () => Promise; - onSuccessfulLoginNavigate: () => Promise; - onSuccessfulLoginTwoFactorNavigate: () => Promise; - onSuccessfulLoginChangePasswordNavigate: () => Promise; - onSuccessfulLoginForceResetNavigate: () => Promise; + onSuccessfulLogin: () => Promise; + onSuccessfulLoginNavigate: () => Promise; + onSuccessfulLoginTwoFactorNavigate: () => Promise; + onSuccessfulLoginChangePasswordNavigate: () => Promise; + onSuccessfulLoginForceResetNavigate: () => Promise; + + onSuccessfulLoginTde: () => Promise; + onSuccessfulLoginTdeNavigate: () => Promise; protected twoFactorRoute = "2fa"; protected successRoute = "lock"; + protected trustedDeviceEncRoute = "login-initiated"; protected changePasswordRoute = "set-password"; protected forcePasswordResetRoute = "update-temp-password"; protected clientId: string; @@ -50,7 +58,8 @@ export class SsoComponent { protected cryptoFunctionService: CryptoFunctionService, protected environmentService: EnvironmentService, protected passwordGenerationService: PasswordGenerationServiceAbstraction, - protected logService: LogService + protected logService: LogService, + protected configService: ConfigServiceAbstraction ) {} async ngOnInit() { @@ -67,11 +76,8 @@ export class SsoComponent { state != null && this.checkState(state, qParams.state) ) { - await this.logIn( - qParams.code, - codeVerifier, - this.getOrgIdentifierFromState(qParams.state) - ); + const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state); + await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier); } } else if ( qParams.clientId != null && @@ -173,67 +179,181 @@ export class SsoComponent { return authorizeUrl; } - private async logIn(code: string, codeVerifier: string, orgIdFromState: string) { + private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise { this.loggingIn = true; try { const credentials = new SsoLogInCredentials( code, codeVerifier, this.redirectUri, - orgIdFromState + orgSsoIdentifier ); this.formPromise = this.authService.logIn(credentials); - const response = await this.formPromise; - if (response.requiresTwoFactor) { - if (this.onSuccessfulLoginTwoFactorNavigate != null) { - await this.onSuccessfulLoginTwoFactorNavigate(); - } else { - this.router.navigate([this.twoFactorRoute], { - queryParams: { - identifier: orgIdFromState, - sso: "true", - }, - }); - } - } else if (response.resetMasterPassword) { - if (this.onSuccessfulLoginChangePasswordNavigate != null) { - await this.onSuccessfulLoginChangePasswordNavigate(); - } else { - this.router.navigate([this.changePasswordRoute], { - queryParams: { - identifier: orgIdFromState, - }, - }); - } - } else if (response.forcePasswordReset !== ForceResetPasswordReason.None) { - if (this.onSuccessfulLoginForceResetNavigate != null) { - await this.onSuccessfulLoginForceResetNavigate(); - } else { - this.router.navigate([this.forcePasswordResetRoute]); - } - } else { - if (this.onSuccessfulLogin != null) { - await this.onSuccessfulLogin(); - } - if (this.onSuccessfulLoginNavigate != null) { - await this.onSuccessfulLoginNavigate(); - } else { - this.router.navigate([this.successRoute]); - } + const authResult = await this.formPromise; + + const acctDecryptionOpts: AccountDecryptionOptions = + await this.stateService.getAccountDecryptionOptions(); + + if (authResult.requiresTwoFactor) { + return await this.handleTwoFactorRequired(orgSsoIdentifier); } - } catch (e) { - this.logService.error(e); - - // TODO: Key Connector Service should pass this error message to the logout callback instead of displaying here - if (e.message === "Key Connector error") { - this.platformUtilsService.showToast( - "error", - null, - this.i18nService.t("ssoKeyConnectorError") + + // Everything after the 2FA check is considered a successful login + // Just have to figure out where to send the user + + // Save off the OrgSsoIdentifier for use in the TDE flows + // - TDE login decryption options component + // - Browser SSO on extension open + // Note: you cannot set this in state before 2FA b/c there won't be an account in state. + await this.stateService.setUserSsoOrganizationIdentifier(orgSsoIdentifier); + + const tdeEnabled = await this.isTrustedDeviceEncEnabled( + acctDecryptionOpts.trustedDeviceOption + ); + + if (tdeEnabled) { + return await this.handleTrustedDeviceEncryptionEnabled( + authResult, + orgSsoIdentifier, + acctDecryptionOpts ); } + + // In the standard, non TDE case, a user must set password if they don't + // have one and they aren't using key connector. + // Note: TDE & Key connector are mutually exclusive org config options. + const requireSetPassword = + !acctDecryptionOpts.hasMasterPassword && + acctDecryptionOpts.keyConnectorOption === undefined; + + if (requireSetPassword || authResult.resetMasterPassword) { + // Change implies going no password -> password in this case + return await this.handleChangePasswordRequired(orgSsoIdentifier); + } + + // Users enrolled in admin acct recovery can be forced to set a new password after + // having the admin set a temp password for them + if (authResult.forcePasswordReset == ForceResetPasswordReason.AdminForcePasswordReset) { + return await this.handleForcePasswordReset(orgSsoIdentifier); + } + + // Standard SSO login success case + return await this.handleSuccessfulLogin(); + } catch (e) { + await this.handleLoginError(e); + } + } + + private async isTrustedDeviceEncEnabled( + trustedDeviceOption: TrustedDeviceUserDecryptionOption + ): Promise { + const trustedDeviceEncryptionFeatureActive = await this.configService.getFeatureFlag( + FeatureFlag.TrustedDeviceEncryption + ); + + return trustedDeviceEncryptionFeatureActive && trustedDeviceOption !== undefined; + } + + private async handleTwoFactorRequired(orgIdentifier: string) { + await this.navigateViaCallbackOrRoute( + this.onSuccessfulLoginTwoFactorNavigate, + [this.twoFactorRoute], + { + queryParams: { + identifier: orgIdentifier, + sso: "true", + }, + } + ); + } + + private async handleTrustedDeviceEncryptionEnabled( + authResult: AuthResult, + orgIdentifier: string, + acctDecryptionOpts: AccountDecryptionOptions + ): Promise { + // If user doesn't have a MP, but has reset password permission, they must set a MP + if ( + !acctDecryptionOpts.hasMasterPassword && + acctDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission + ) { + // Change implies going no password -> password in this case + return await this.handleChangePasswordRequired(orgIdentifier); + } + + if (authResult.forcePasswordReset !== ForceResetPasswordReason.None) { + return await this.handleForcePasswordReset(orgIdentifier); + } + + if (this.onSuccessfulLoginTde != null) { + // Don't await b/c causes hang on desktop & browser + this.onSuccessfulLoginTde(); + } + + this.navigateViaCallbackOrRoute( + this.onSuccessfulLoginTdeNavigate, + // Navigate to TDE page (if user was on trusted device and TDE has decrypted + // their user key, the login-initiated guard will redirect them to the vault) + [this.trustedDeviceEncRoute] + ); + } + + private async handleChangePasswordRequired(orgIdentifier: string) { + await this.navigateViaCallbackOrRoute( + this.onSuccessfulLoginChangePasswordNavigate, + [this.changePasswordRoute], + { + queryParams: { + identifier: orgIdentifier, + }, + } + ); + } + + private async handleForcePasswordReset(orgIdentifier: string) { + await this.navigateViaCallbackOrRoute( + this.onSuccessfulLoginForceResetNavigate, + [this.forcePasswordResetRoute], + { + queryParams: { + identifier: orgIdentifier, + }, + } + ); + } + + private async handleSuccessfulLogin() { + if (this.onSuccessfulLogin != null) { + // Don't await b/c causes hang on desktop & browser + this.onSuccessfulLogin(); + } + + await this.navigateViaCallbackOrRoute(this.onSuccessfulLoginNavigate, [this.successRoute]); + } + + private async handleLoginError(e: any) { + this.logService.error(e); + + // TODO: Key Connector Service should pass this error message to the logout callback instead of displaying here + if (e.message === "Key Connector error") { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("ssoKeyConnectorError") + ); + } + } + + private async navigateViaCallbackOrRoute( + callback: () => Promise, + commands: unknown[], + extras?: NavigationExtras + ): Promise { + if (callback) { + await callback(); + } else { + await this.router.navigate(commands, extras); } - this.loggingIn = false; } private getOrgIdentifierFromState(state: string): string { diff --git a/libs/angular/src/auth/components/two-factor-options.component.ts b/libs/angular/src/auth/components/two-factor-options.component.ts index 3b18a5c2f7a..786a9d7bb54 100644 --- a/libs/angular/src/auth/components/two-factor-options.component.ts +++ b/libs/angular/src/auth/components/two-factor-options.component.ts @@ -1,10 +1,10 @@ import { Directive, EventEmitter, OnInit, Output } from "@angular/core"; import { Router } from "@angular/router"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @Directive() export class TwoFactorOptionsComponent implements OnInit { @@ -30,7 +30,7 @@ export class TwoFactorOptionsComponent implements OnInit { } recover() { - this.platformUtilsService.launchUri("https://bitwarden.com/help/lost-two-step-device/"); + this.platformUtilsService.launchUri("https://vault.bitwarden.com/#/recover-2fa"); this.onRecoverSelected.emit(); } } diff --git a/libs/angular/src/auth/components/two-factor.component.spec.ts b/libs/angular/src/auth/components/two-factor.component.spec.ts new file mode 100644 index 00000000000..9e147f33573 --- /dev/null +++ b/libs/angular/src/auth/components/two-factor.component.spec.ts @@ -0,0 +1,451 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ActivatedRoute, Router, convertToParamMap } from "@angular/router"; +import { MockProxy, mock } from "jest-mock-extended"; + +// eslint-disable-next-line no-restricted-imports +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; +import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; +import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason"; +import { KeyConnectorUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/key-connector-user-decryption-option"; +import { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option"; +import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account"; + +import { TwoFactorComponent } from "./two-factor.component"; + +// test component that extends the TwoFactorComponent +@Component({}) +class TestTwoFactorComponent extends TwoFactorComponent {} + +interface TwoFactorComponentProtected { + trustedDeviceEncRoute: string; + changePasswordRoute: string; + forcePasswordResetRoute: string; + successRoute: string; +} + +describe("TwoFactorComponent", () => { + let component: TestTwoFactorComponent; + let _component: TwoFactorComponentProtected; + + let fixture: ComponentFixture; + + // Mock Services + let mockAuthService: MockProxy; + let mockRouter: MockProxy; + let mockI18nService: MockProxy; + let mockApiService: MockProxy; + let mockPlatformUtilsService: MockProxy; + let mockWin: MockProxy; + let mockEnvironmentService: MockProxy; + let mockStateService: MockProxy; + let mockLogService: MockProxy; + let mockTwoFactorService: MockProxy; + let mockAppIdService: MockProxy; + let mockLoginService: MockProxy; + let mockConfigService: MockProxy; + + let mockAcctDecryptionOpts: { + noMasterPassword: AccountDecryptionOptions; + withMasterPassword: AccountDecryptionOptions; + withMasterPasswordAndTrustedDevice: AccountDecryptionOptions; + withMasterPasswordAndTrustedDeviceWithManageResetPassword: AccountDecryptionOptions; + withMasterPasswordAndKeyConnector: AccountDecryptionOptions; + noMasterPasswordWithTrustedDevice: AccountDecryptionOptions; + noMasterPasswordWithTrustedDeviceWithManageResetPassword: AccountDecryptionOptions; + noMasterPasswordWithKeyConnector: AccountDecryptionOptions; + }; + + beforeEach(() => { + mockAuthService = mock(); + mockRouter = mock(); + mockI18nService = mock(); + mockApiService = mock(); + mockPlatformUtilsService = mock(); + mockWin = mock(); + mockEnvironmentService = mock(); + mockStateService = mock(); + mockLogService = mock(); + mockTwoFactorService = mock(); + mockAppIdService = mock(); + mockLoginService = mock(); + mockConfigService = mock(); + + mockAcctDecryptionOpts = { + noMasterPassword: new AccountDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + }), + withMasterPassword: new AccountDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: undefined, + keyConnectorOption: undefined, + }), + withMasterPasswordAndTrustedDevice: new AccountDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false), + keyConnectorOption: undefined, + }), + withMasterPasswordAndTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true), + keyConnectorOption: undefined, + }), + withMasterPasswordAndKeyConnector: new AccountDecryptionOptions({ + hasMasterPassword: true, + trustedDeviceOption: undefined, + keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"), + }), + noMasterPasswordWithTrustedDevice: new AccountDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false), + keyConnectorOption: undefined, + }), + noMasterPasswordWithTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true), + keyConnectorOption: undefined, + }), + noMasterPasswordWithKeyConnector: new AccountDecryptionOptions({ + hasMasterPassword: false, + trustedDeviceOption: undefined, + keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"), + }), + }; + + TestBed.configureTestingModule({ + declarations: [TestTwoFactorComponent], + providers: [ + { provide: AuthService, useValue: mockAuthService }, + { provide: Router, useValue: mockRouter }, + { provide: I18nService, useValue: mockI18nService }, + { provide: ApiService, useValue: mockApiService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: WINDOW, useValue: mockWin }, + { provide: EnvironmentService, useValue: mockEnvironmentService }, + { provide: StateService, useValue: mockStateService }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + // Default to standard 2FA flow - not SSO + 2FA + queryParamMap: convertToParamMap({ sso: "false" }), + }, + }, + }, + { provide: LogService, useValue: mockLogService }, + { provide: TwoFactorService, useValue: mockTwoFactorService }, + { provide: AppIdService, useValue: mockAppIdService }, + { provide: LoginService, useValue: mockLoginService }, + { provide: ConfigServiceAbstraction, useValue: mockConfigService }, + ], + }); + + fixture = TestBed.createComponent(TestTwoFactorComponent); + component = fixture.componentInstance; + _component = component as any; + }); + + afterEach(() => { + // Reset all mocks after each test + jest.resetAllMocks(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + // Shared tests + const testChangePasswordOnSuccessfulLogin = () => { + it("navigates to the component's defined change password route when user doesn't have a MP and key connector isn't enabled", async () => { + // Act + await component.doSubmit(); + + // Assert + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith([_component.changePasswordRoute], { + queryParams: { + identifier: component.orgIdentifier, + }, + }); + }); + }; + + const testForceResetOnSuccessfulLogin = (reasonString: string) => { + it(`navigates to the component's defined forcePasswordResetRoute route when response.forcePasswordReset is ${reasonString}`, async () => { + // Act + await component.doSubmit(); + + // expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith([_component.forcePasswordResetRoute], { + queryParams: { + identifier: component.orgIdentifier, + }, + }); + }); + }; + + describe("Standard 2FA scenarios", () => { + describe("doSubmit", () => { + const token = "testToken"; + const remember = false; + const captchaToken = "testCaptchaToken"; + + beforeEach(() => { + component.token = token; + component.remember = remember; + component.captchaToken = captchaToken; + + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.withMasterPassword + ); + }); + + it("calls authService.logInTwoFactor with correct parameters when form is submitted", async () => { + // Arrange + mockAuthService.logInTwoFactor.mockResolvedValue(new AuthResult()); + + // Act + await component.doSubmit(); + + // Assert + expect(mockAuthService.logInTwoFactor).toHaveBeenCalledWith( + new TokenTwoFactorRequest(component.selectedProviderType, token, remember), + captchaToken + ); + }); + + it("should return when handleCaptchaRequired returns true", async () => { + // Arrange + const captchaSiteKey = "testCaptchaSiteKey"; + const authResult = new AuthResult(); + authResult.captchaSiteKey = captchaSiteKey; + + mockAuthService.logInTwoFactor.mockResolvedValue(authResult); + + // Note: the any casts are required b/c typescript cant recognize that + // handleCaptureRequired is a method on TwoFactorComponent b/c it is inherited + // from the CaptchaProtectedComponent + const handleCaptchaRequiredSpy = jest + .spyOn(component, "handleCaptchaRequired") + .mockReturnValue(true); + + // Act + const result = await component.doSubmit(); + + // Assert + expect(handleCaptchaRequiredSpy).toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it("calls onSuccessfulLogin when defined", async () => { + // Arrange + component.onSuccessfulLogin = jest.fn().mockResolvedValue(undefined); + mockAuthService.logInTwoFactor.mockResolvedValue(new AuthResult()); + + // Act + await component.doSubmit(); + + // Assert + expect(component.onSuccessfulLogin).toHaveBeenCalled(); + }); + + it("calls loginService.clearValues() when login is successful", async () => { + // Arrange + mockAuthService.logInTwoFactor.mockResolvedValue(new AuthResult()); + // spy on loginService.clearValues + const clearValuesSpy = jest.spyOn(mockLoginService, "clearValues"); + + // Act + await component.doSubmit(); + + // Assert + expect(clearValuesSpy).toHaveBeenCalled(); + }); + + describe("Set Master Password scenarios", () => { + beforeEach(() => { + const authResult = new AuthResult(); + mockAuthService.logInTwoFactor.mockResolvedValue(authResult); + }); + + describe("Given user needs to set a master password", () => { + beforeEach(() => { + // Only need to test the case where the user has no master password to test the primary change mp flow here + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.noMasterPassword + ); + }); + + testChangePasswordOnSuccessfulLogin(); + }); + + it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => { + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.noMasterPasswordWithKeyConnector + ); + + await component.doSubmit(); + + expect(mockRouter.navigate).not.toHaveBeenCalledWith([_component.changePasswordRoute], { + queryParams: { + identifier: component.orgIdentifier, + }, + }); + }); + }); + + describe("Force Master Password Reset scenarios", () => { + [ + ForceResetPasswordReason.AdminForcePasswordReset, + ForceResetPasswordReason.WeakMasterPassword, + ].forEach((forceResetPasswordReason) => { + const reasonString = ForceResetPasswordReason[forceResetPasswordReason]; + + beforeEach(() => { + // use standard user with MP because this test is not concerned with password reset. + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.withMasterPassword + ); + + const authResult = new AuthResult(); + authResult.forcePasswordReset = forceResetPasswordReason; + mockAuthService.logInTwoFactor.mockResolvedValue(authResult); + }); + + testForceResetOnSuccessfulLogin(reasonString); + }); + }); + + it("calls onSuccessfulLoginNavigate when the callback is defined", async () => { + // Arrange + component.onSuccessfulLoginNavigate = jest.fn().mockResolvedValue(undefined); + mockAuthService.logInTwoFactor.mockResolvedValue(new AuthResult()); + + // Act + await component.doSubmit(); + + // Assert + expect(component.onSuccessfulLoginNavigate).toHaveBeenCalled(); + }); + + it("navigates to the component's defined success route when the login is successful and onSuccessfulLoginNavigate is undefined", async () => { + mockAuthService.logInTwoFactor.mockResolvedValue(new AuthResult()); + + // Act + await component.doSubmit(); + + // Assert + expect(component.onSuccessfulLoginNavigate).not.toBeDefined(); + + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith([_component.successRoute], undefined); + }); + }); + }); + + describe("SSO > 2FA scenarios", () => { + beforeEach(() => { + const mockActivatedRoute = TestBed.inject(ActivatedRoute); + mockActivatedRoute.snapshot.queryParamMap.get = jest.fn().mockReturnValue("true"); + }); + + describe("doSubmit", () => { + const token = "testToken"; + const remember = false; + const captchaToken = "testCaptchaToken"; + + beforeEach(() => { + component.token = token; + component.remember = remember; + component.captchaToken = captchaToken; + }); + + describe("Trusted Device Encryption scenarios", () => { + beforeEach(() => { + mockConfigService.getFeatureFlag.mockResolvedValue(true); + }); + + describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => { + beforeEach(() => { + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword + ); + + const authResult = new AuthResult(); + mockAuthService.logInTwoFactor.mockResolvedValue(authResult); + }); + + testChangePasswordOnSuccessfulLogin(); + }); + + describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => { + [ + ForceResetPasswordReason.AdminForcePasswordReset, + ForceResetPasswordReason.WeakMasterPassword, + ].forEach((forceResetPasswordReason) => { + const reasonString = ForceResetPasswordReason[forceResetPasswordReason]; + + beforeEach(() => { + // use standard user with MP because this test is not concerned with password reset. + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice + ); + + const authResult = new AuthResult(); + authResult.forcePasswordReset = forceResetPasswordReason; + mockAuthService.logInTwoFactor.mockResolvedValue(authResult); + }); + + testForceResetOnSuccessfulLogin(reasonString); + }); + }); + + describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => { + let authResult; + beforeEach(() => { + mockStateService.getAccountDecryptionOptions.mockResolvedValue( + mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice + ); + + authResult = new AuthResult(); + authResult.forcePasswordReset = ForceResetPasswordReason.None; + mockAuthService.logInTwoFactor.mockResolvedValue(authResult); + }); + + it("navigates to the component's defined trusted device encryption route when login is successful and onSuccessfulLoginTdeNavigate is undefined", async () => { + await component.doSubmit(); + + expect(mockRouter.navigate).toHaveBeenCalledTimes(1); + expect(mockRouter.navigate).toHaveBeenCalledWith( + [_component.trustedDeviceEncRoute], + undefined + ); + }); + + it("calls onSuccessfulLoginTdeNavigate instead of router.navigate when the callback is defined", async () => { + component.onSuccessfulLoginTdeNavigate = jest.fn().mockResolvedValue(undefined); + + await component.doSubmit(); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + expect(component.onSuccessfulLoginTdeNavigate).toHaveBeenCalled(); + }); + }); + }); + }); + }); +}); diff --git a/libs/angular/src/auth/components/two-factor.component.ts b/libs/angular/src/auth/components/two-factor.component.ts index 11b7c9bbac5..756218a1e19 100644 --- a/libs/angular/src/auth/components/two-factor.component.ts +++ b/libs/angular/src/auth/components/two-factor.component.ts @@ -1,25 +1,31 @@ -import { Directive, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { Directive, Inject, OnDestroy, OnInit } from "@angular/core"; +import { ActivatedRoute, NavigationExtras, Router } from "@angular/router"; import * as DuoWebSDK from "duo_web_sdk"; import { first } from "rxjs/operators"; +// eslint-disable-next-line no-restricted-imports +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { AppIdService } from "@bitwarden/common/abstractions/appId.service"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason"; +import { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request"; import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service"; import { WebAuthnIFrame } from "@bitwarden/common/auth/webauthn-iframe"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account"; import { CaptchaProtectedComponent } from "./captcha-protected.component"; @@ -38,11 +44,18 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI twoFactorEmail: string = null; formPromise: Promise; emailPromise: Promise; - identifier: string = null; - onSuccessfulLogin: () => Promise; - onSuccessfulLoginNavigate: () => Promise; + orgIdentifier: string = null; + onSuccessfulLogin: () => Promise; + onSuccessfulLoginNavigate: () => Promise; + + onSuccessfulLoginTde: () => Promise; + onSuccessfulLoginTdeNavigate: () => Promise; protected loginRoute = "login"; + + protected trustedDeviceEncRoute = "login-initiated"; + protected changePasswordRoute = "set-password"; + protected forcePasswordResetRoute = "update-temp-password"; protected successRoute = "vault"; constructor( @@ -51,14 +64,15 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI protected i18nService: I18nService, protected apiService: ApiService, protected platformUtilsService: PlatformUtilsService, - protected win: Window, + @Inject(WINDOW) protected win: Window, protected environmentService: EnvironmentService, protected stateService: StateService, protected route: ActivatedRoute, protected logService: LogService, protected twoFactorService: TwoFactorService, protected appIdService: AppIdService, - protected loginService: LoginService + protected loginService: LoginService, + protected configService: ConfigServiceAbstraction ) { super(environmentService, i18nService, platformUtilsService); this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win); @@ -72,7 +86,7 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI this.route.queryParams.pipe(first()).subscribe((qParams) => { if (qParams.identifier != null) { - this.identifier = qParams.identifier; + this.orgIdentifier = qParams.identifier; } }); @@ -196,32 +210,154 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI new TokenTwoFactorRequest(this.selectedProviderType, this.token, this.remember), this.captchaToken ); - const response: AuthResult = await this.formPromise; - if (this.handleCaptchaRequired(response)) { + const authResult: AuthResult = await this.formPromise; + + await this.handleLoginResponse(authResult); + } + + protected handleMigrateEncryptionKey(result: AuthResult): boolean { + if (!result.requiresEncryptionKeyMigration) { + return false; + } + + this.platformUtilsService.showToast( + "error", + this.i18nService.t("errorOccured"), + this.i18nService.t("encryptionKeyMigrationRequired") + ); + return true; + } + + private async handleLoginResponse(authResult: AuthResult) { + if (this.handleCaptchaRequired(authResult)) { + return; + } else if (this.handleMigrateEncryptionKey(authResult)) { return; } + + // Save off the OrgSsoIdentifier for use in the TDE flows + // - TDE login decryption options component + // - Browser SSO on extension open + await this.stateService.setUserSsoOrganizationIdentifier(this.orgIdentifier); + + this.loginService.clearValues(); + + const acctDecryptionOpts: AccountDecryptionOptions = + await this.stateService.getAccountDecryptionOptions(); + + const tdeEnabled = await this.isTrustedDeviceEncEnabled(acctDecryptionOpts.trustedDeviceOption); + + if (tdeEnabled) { + return await this.handleTrustedDeviceEncryptionEnabled( + authResult, + this.orgIdentifier, + acctDecryptionOpts + ); + } + + // User must set password if they don't have one and they aren't using either TDE or key connector. + const requireSetPassword = + !acctDecryptionOpts.hasMasterPassword && acctDecryptionOpts.keyConnectorOption === undefined; + + if (requireSetPassword || authResult.resetMasterPassword) { + // Change implies going no password -> password in this case + return await this.handleChangePasswordRequired(this.orgIdentifier); + } + + // Users can be forced to reset their password via an admin or org policy + // disallowing weak passwords + if (authResult.forcePasswordReset !== ForceResetPasswordReason.None) { + return await this.handleForcePasswordReset(this.orgIdentifier); + } + + return await this.handleSuccessfulLogin(); + } + + private async isTrustedDeviceEncEnabled( + trustedDeviceOption: TrustedDeviceUserDecryptionOption + ): Promise { + const ssoTo2faFlowActive = this.route.snapshot.queryParamMap.get("sso") === "true"; + const trustedDeviceEncryptionFeatureActive = await this.configService.getFeatureFlag( + FeatureFlag.TrustedDeviceEncryption + ); + + return ( + ssoTo2faFlowActive && + trustedDeviceEncryptionFeatureActive && + trustedDeviceOption !== undefined + ); + } + + private async handleTrustedDeviceEncryptionEnabled( + authResult: AuthResult, + orgIdentifier: string, + acctDecryptionOpts: AccountDecryptionOptions + ): Promise { + // If user doesn't have a MP, but has reset password permission, they must set a MP + if ( + !acctDecryptionOpts.hasMasterPassword && + acctDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission + ) { + // Change implies going no password -> password in this case + return await this.handleChangePasswordRequired(orgIdentifier); + } + + // Users can be forced to reset their password via an admin or org policy disallowing weak passwords + // Note: this is different from SSO component login flow as a user can + // login with MP and then have to pass 2FA to finish login and we can actually + // evaluate if they have a weak password at this time. + if (authResult.forcePasswordReset !== ForceResetPasswordReason.None) { + return await this.handleForcePasswordReset(orgIdentifier); + } + + if (this.onSuccessfulLoginTde != null) { + // Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete + // before navigating to the success route. + this.onSuccessfulLoginTde(); + } + + this.navigateViaCallbackOrRoute( + this.onSuccessfulLoginTdeNavigate, + // Navigate to TDE page (if user was on trusted device and TDE has decrypted + // their user key, the login-initiated guard will redirect them to the vault) + [this.trustedDeviceEncRoute] + ); + } + + private async handleChangePasswordRequired(orgIdentifier: string) { + await this.router.navigate([this.changePasswordRoute], { + queryParams: { + identifier: orgIdentifier, + }, + }); + } + + private async handleForcePasswordReset(orgIdentifier: string) { + this.router.navigate([this.forcePasswordResetRoute], { + queryParams: { + identifier: orgIdentifier, + }, + }); + } + + private async handleSuccessfulLogin() { if (this.onSuccessfulLogin != null) { - this.loginService.clearValues(); // Note: awaiting this will currently cause a hang on desktop & browser as they will wait for a full sync to complete - // before nagivating to the success route. + // before navigating to the success route. this.onSuccessfulLogin(); } - if (response.resetMasterPassword) { - this.successRoute = "set-password"; - } - if (response.forcePasswordReset !== ForceResetPasswordReason.None) { - this.successRoute = "update-temp-password"; - } - if (this.onSuccessfulLoginNavigate != null) { - this.loginService.clearValues(); - await this.onSuccessfulLoginNavigate(); + await this.navigateViaCallbackOrRoute(this.onSuccessfulLoginNavigate, [this.successRoute]); + } + + private async navigateViaCallbackOrRoute( + callback: () => Promise, + commands: unknown[], + extras?: NavigationExtras + ): Promise { + if (callback) { + await callback(); } else { - this.loginService.clearValues(); - this.router.navigate([this.successRoute], { - queryParams: { - identifier: this.identifier, - }, - }); + await this.router.navigate(commands, extras); } } diff --git a/libs/angular/src/auth/components/update-password.component.ts b/libs/angular/src/auth/components/update-password.component.ts index f0389951787..81bdbb31cb9 100644 --- a/libs/angular/src/auth/components/update-password.component.ts +++ b/libs/angular/src/auth/components/update-password.component.ts @@ -2,23 +2,22 @@ import { Directive } from "@angular/core"; import { Router } from "@angular/router"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { MasterKey, UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { Verification } from "@bitwarden/common/types/verification"; - -import { DialogServiceAbstraction } from "../../services/dialog"; +import { DialogService } from "@bitwarden/components"; import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component"; @@ -44,7 +43,7 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent { stateService: StateService, private userVerificationService: UserVerificationService, private logService: LogService, - dialogService: DialogServiceAbstraction + dialogService: DialogService ) { super( i18nService, @@ -95,19 +94,19 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent { } async performSubmitActions( - masterPasswordHash: string, - key: SymmetricCryptoKey, - encKey: [SymmetricCryptoKey, EncString] + newMasterKeyHash: string, + newMasterKey: MasterKey, + newUserKey: [UserKey, EncString] ) { try { // Create Request const request = new PasswordRequest(); - request.masterPasswordHash = await this.cryptoService.hashPassword( + request.masterPasswordHash = await this.cryptoService.hashMasterKey( this.currentMasterPassword, - null + await this.cryptoService.getOrDeriveMasterKey(this.currentMasterPassword) ); - request.newMasterPasswordHash = masterPasswordHash; - request.key = encKey[1].encryptedString; + request.newMasterPasswordHash = newMasterKeyHash; + request.key = newUserKey[1].encryptedString; // Update user's password this.apiService.postPassword(request); diff --git a/libs/angular/src/auth/components/update-temp-password.component.ts b/libs/angular/src/auth/components/update-temp-password.component.ts index 9899b2db273..7bb65ab68db 100644 --- a/libs/angular/src/auth/components/update-temp-password.component.ts +++ b/libs/angular/src/auth/components/update-temp-password.component.ts @@ -2,26 +2,25 @@ import { Directive } from "@angular/core"; import { Router } from "@angular/router"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason"; import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request"; import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { MasterKey, UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { Verification } from "@bitwarden/common/types/verification"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; - -import { DialogServiceAbstraction } from "../../services/dialog"; +import { DialogService } from "@bitwarden/components"; import { ChangePasswordComponent as BaseChangePasswordComponent } from "./change-password.component"; @@ -56,7 +55,7 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent { private logService: LogService, private userVerificationService: UserVerificationService, private router: Router, - dialogService: DialogServiceAbstraction + dialogService: DialogService ) { super( i18nService, @@ -114,21 +113,27 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent { try { // Create new key and hash new password - const newKey = await this.cryptoService.makeKey( + const newMasterKey = await this.cryptoService.makeMasterKey( this.masterPassword, this.email.trim().toLowerCase(), this.kdf, this.kdfConfig ); - const newPasswordHash = await this.cryptoService.hashPassword(this.masterPassword, newKey); + const newPasswordHash = await this.cryptoService.hashMasterKey( + this.masterPassword, + newMasterKey + ); - // Grab user's current enc key - const userEncKey = await this.cryptoService.getEncKey(); + // Grab user key + const userKey = await this.cryptoService.getUserKey(); - // Create new encKey for the User - const newEncKey = await this.cryptoService.remakeEncKey(newKey, userEncKey); + // Encrypt user key with new master key + const newProtectedUserKey = await this.cryptoService.encryptUserKeyWithMasterKey( + newMasterKey, + userKey + ); - await this.performSubmitActions(newPasswordHash, newKey, newEncKey); + await this.performSubmitActions(newPasswordHash, newMasterKey, newProtectedUserKey); } catch (e) { this.logService.error(e); } @@ -136,16 +141,16 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent { async performSubmitActions( masterPasswordHash: string, - key: SymmetricCryptoKey, - encKey: [SymmetricCryptoKey, EncString] + masterKey: MasterKey, + userKey: [UserKey, EncString] ) { try { switch (this.reason) { case ForceResetPasswordReason.AdminForcePasswordReset: - this.formPromise = this.updateTempPassword(masterPasswordHash, encKey); + this.formPromise = this.updateTempPassword(masterPasswordHash, userKey); break; case ForceResetPasswordReason.WeakMasterPassword: - this.formPromise = this.updatePassword(masterPasswordHash, encKey); + this.formPromise = this.updatePassword(masterPasswordHash, userKey); break; } @@ -167,29 +172,23 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent { this.logService.error(e); } } - private async updateTempPassword( - masterPasswordHash: string, - encKey: [SymmetricCryptoKey, EncString] - ) { + private async updateTempPassword(masterPasswordHash: string, userKey: [UserKey, EncString]) { const request = new UpdateTempPasswordRequest(); - request.key = encKey[1].encryptedString; + request.key = userKey[1].encryptedString; request.newMasterPasswordHash = masterPasswordHash; request.masterPasswordHint = this.hint; return this.apiService.putUpdateTempPassword(request); } - private async updatePassword( - newMasterPasswordHash: string, - encKey: [SymmetricCryptoKey, EncString] - ) { + private async updatePassword(newMasterPasswordHash: string, userKey: [UserKey, EncString]) { const request = await this.userVerificationService.buildRequest( this.verification, PasswordRequest ); request.masterPasswordHint = this.hint; request.newMasterPasswordHash = newMasterPasswordHash; - request.key = encKey[1].encryptedString; + request.key = userKey[1].encryptedString; return this.apiService.postPassword(request); } diff --git a/libs/angular/src/auth/components/user-verification-prompt.component.ts b/libs/angular/src/auth/components/user-verification-prompt.component.ts index 842d9e12229..1b0a1dbed86 100644 --- a/libs/angular/src/auth/components/user-verification-prompt.component.ts +++ b/libs/angular/src/auth/components/user-verification-prompt.component.ts @@ -1,9 +1,10 @@ import { Directive } from "@angular/core"; -import { FormBuilder, FormControl } from "@angular/forms"; +import { FormBuilder } from "@angular/forms"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Verification } from "@bitwarden/common/types/verification"; import { ModalRef } from "../../components/modal/modal.ref"; import { ModalConfig } from "../../services/modal.service"; @@ -16,7 +17,12 @@ export class UserVerificationPromptComponent { confirmDescription = this.config.data.confirmDescription; confirmButtonText = this.config.data.confirmButtonText; modalTitle = this.config.data.modalTitle; - secret = new FormControl(); + + formGroup = this.formBuilder.group({ + secret: this.formBuilder.control(null), + }); + + protected invalidSecret = false; constructor( private modalRef: ModalRef, @@ -27,19 +33,31 @@ export class UserVerificationPromptComponent { private i18nService: I18nService ) {} - async submit() { + get secret() { + return this.formGroup.controls.secret; + } + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + try { //Incorrect secret will throw an invalid password error. await this.userVerificationService.verifyUser(this.secret.value); + this.invalidSecret = false; } catch (e) { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("error"), - this.i18nService.t("invalidMasterPassword") - ); + this.invalidSecret = true; + this.platformUtilsService.showToast("error", this.i18nService.t("error"), e.message); return; } - this.modalRef.close(true); + this.close(true); + }; + + close(success: boolean) { + this.modalRef.close(success); } } diff --git a/libs/angular/src/auth/components/user-verification.component.ts b/libs/angular/src/auth/components/user-verification.component.ts index ea514e177c8..b2d4d8a4c31 100644 --- a/libs/angular/src/auth/components/user-verification.component.ts +++ b/libs/angular/src/auth/components/user-verification.component.ts @@ -1,15 +1,17 @@ -import { Directive, OnInit } from "@angular/core"; -import { ControlValueAccessor, FormControl } from "@angular/forms"; +import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { ControlValueAccessor, FormControl, Validators } from "@angular/forms"; +import { Subject, takeUntil } from "rxjs"; -import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; -import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Verification } from "@bitwarden/common/types/verification"; /** * Used for general-purpose user verification throughout the app. - * Collects the user's master password, or if they are using Key Connector, prompts for an OTP via email. + * Collects the user's master password, or if they are not using a password, prompts for an OTP via email. * This is exposed to the parent component via the ControlValueAccessor interface (e.g. bind it to a FormControl). * Use UserVerificationService to verify the user's input. */ @@ -17,30 +19,66 @@ import { Verification } from "@bitwarden/common/types/verification"; selector: "app-user-verification", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class UserVerificationComponent implements ControlValueAccessor, OnInit { - usesKeyConnector = false; +export class UserVerificationComponent implements ControlValueAccessor, OnInit, OnDestroy { + private _invalidSecret = false; + @Input() + get invalidSecret() { + return this._invalidSecret; + } + set invalidSecret(value: boolean) { + this._invalidSecret = value; + this.invalidSecretChange.emit(value); + + // ISSUE: This is pretty hacky but unfortunately there is no way of knowing if the parent + // control has been marked as touched, see: https://github.com/angular/angular/issues/10887 + // When that functionality has been added we should also look into forwarding reactive form + // controls errors so that we don't need a separate input/output `invalidSecret`. + if (value) { + this.secret.markAsTouched(); + } + this.secret.updateValueAndValidity({ emitEvent: false }); + } + @Output() invalidSecretChange = new EventEmitter(); + + hasMasterPassword = true; disableRequestOTP = false; sentCode = false; - secret = new FormControl(""); + secret = new FormControl("", [ + Validators.required, + () => { + if (this.invalidSecret) { + return { + invalidSecret: { + message: this.hasMasterPassword + ? this.i18nService.t("incorrectCode") + : this.i18nService.t("incorrectPassword"), + }, + }; + } + }, + ]); private onChange: (value: Verification) => void; + private destroy$ = new Subject(); constructor( - private keyConnectorService: KeyConnectorService, - private userVerificationService: UserVerificationService + private cryptoService: CryptoService, + private userVerificationService: UserVerificationService, + private i18nService: I18nService ) {} async ngOnInit() { - this.usesKeyConnector = await this.keyConnectorService.getUsesKeyConnector(); + this.hasMasterPassword = await this.userVerificationService.hasMasterPasswordAndMasterKeyHash(); this.processChanges(this.secret.value); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - this.secret.valueChanges.subscribe((secret: string) => this.processChanges(secret)); + this.secret.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((secret: string) => this.processChanges(secret)); } - async requestOTP() { - if (this.usesKeyConnector) { + requestOTP = async () => { + if (!this.hasMasterPassword) { this.disableRequestOTP = true; try { await this.userVerificationService.requestOTP(); @@ -49,7 +87,7 @@ export class UserVerificationComponent implements ControlValueAccessor, OnInit { this.disableRequestOTP = false; } } - } + }; writeValue(obj: any): void { this.secret.setValue(obj); @@ -72,13 +110,20 @@ export class UserVerificationComponent implements ControlValueAccessor, OnInit { } } - private processChanges(secret: string) { + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + protected processChanges(secret: string) { + this.invalidSecret = false; + if (this.onChange == null) { return; } this.onChange({ - type: this.usesKeyConnector ? VerificationType.OTP : VerificationType.MasterPassword, + type: this.hasMasterPassword ? VerificationType.MasterPassword : VerificationType.OTP, secret: Utils.isNullOrWhitespace(secret) ? null : secret, }); } diff --git a/libs/angular/src/auth/guards/auth.guard.ts b/libs/angular/src/auth/guards/auth.guard.ts index 2a7e0554762..b8535da9371 100644 --- a/libs/angular/src/auth/guards/auth.guard.ts +++ b/libs/angular/src/auth/guards/auth.guard.ts @@ -1,12 +1,12 @@ import { Injectable } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from "@angular/router"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ForceResetPasswordReason } from "@bitwarden/common/auth/models/domain/force-reset-password-reason"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @Injectable() export class AuthGuard implements CanActivate { diff --git a/libs/angular/src/auth/guards/index.ts b/libs/angular/src/auth/guards/index.ts new file mode 100644 index 00000000000..1760a870b3a --- /dev/null +++ b/libs/angular/src/auth/guards/index.ts @@ -0,0 +1,5 @@ +export * from "./auth.guard"; +export * from "./lock.guard"; +export * from "./redirect.guard"; +export * from "./tde-decryption-required.guard"; +export * from "./unauth.guard"; diff --git a/libs/angular/src/auth/guards/lock.guard.ts b/libs/angular/src/auth/guards/lock.guard.ts index b4cc01dc169..551391b8c25 100644 --- a/libs/angular/src/auth/guards/lock.guard.ts +++ b/libs/angular/src/auth/guards/lock.guard.ts @@ -1,25 +1,74 @@ -import { Injectable } from "@angular/core"; -import { CanActivate, Router } from "@angular/router"; +import { inject } from "@angular/core"; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, +} from "@angular/router"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ClientType } from "@bitwarden/common/enums"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -@Injectable() -export class LockGuard implements CanActivate { - protected homepage = "vault"; - protected loginpage = "login"; - constructor(private authService: AuthService, private router: Router) {} +/** + * Only allow access to this route if the vault is locked. + * If TDE is enabled then the user must also have had a user key at some point. + * Otherwise redirect to root. + */ +export function lockGuard(): CanActivateFn { + return async ( + activatedRouteSnapshot: ActivatedRouteSnapshot, + routerStateSnapshot: RouterStateSnapshot + ) => { + const authService = inject(AuthService); + const cryptoService = inject(CryptoService); + const deviceTrustCryptoService = inject(DeviceTrustCryptoServiceAbstraction); + const platformUtilService = inject(PlatformUtilsService); + const messagingService = inject(MessagingService); + const router = inject(Router); + const userVerificationService = inject(UserVerificationService); - async canActivate() { - const authStatus = await this.authService.getAuthStatus(); + // If legacy user on web, redirect to migration page + if (await cryptoService.isLegacyUser()) { + if (platformUtilService.getClientType() === ClientType.Web) { + return router.createUrlTree(["migrate-legacy-encryption"]); + } + // Log out legacy users on other clients + messagingService.send("logout"); + return false; + } + + const authStatus = await authService.getAuthStatus(); + if (authStatus !== AuthenticationStatus.Locked) { + return router.createUrlTree(["/"]); + } - if (authStatus === AuthenticationStatus.Locked) { + // User is authN and in locked state. + + const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust(); + + // Create special exception which allows users to go from the login-initiated page to the lock page for the approve w/ MP flow + // The MP check is necessary to prevent direct manual navigation from other locked state pages for users who don't have a MP + if ( + activatedRouteSnapshot.queryParams["from"] === "login-initiated" && + tdeEnabled && + (await userVerificationService.hasMasterPassword()) + ) { return true; } - const redirectUrl = - authStatus === AuthenticationStatus.LoggedOut ? this.loginpage : this.homepage; + // If authN user with TDE directly navigates to lock, kick them upwards so redirect guard can + // properly route them to the login decryption options component. + const everHadUserKey = await cryptoService.getEverHadUserKey(); + if (tdeEnabled && !everHadUserKey) { + return router.createUrlTree(["/"]); + } - return this.router.createUrlTree([redirectUrl]); - } + return true; + }; } diff --git a/libs/angular/src/auth/guards/redirect.guard.ts b/libs/angular/src/auth/guards/redirect.guard.ts new file mode 100644 index 00000000000..4853b26e712 --- /dev/null +++ b/libs/angular/src/auth/guards/redirect.guard.ts @@ -0,0 +1,58 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; + +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; + +export interface RedirectRoutes { + loggedIn: string; + loggedOut: string; + locked: string; + notDecrypted: string; +} + +const defaultRoutes: RedirectRoutes = { + loggedIn: "/vault", + loggedOut: "/login", + locked: "/lock", + notDecrypted: "/login-initiated", +}; + +/** + * Guard that consolidates all redirection logic, should be applied to root route. + */ +export function redirectGuard(overrides: Partial = {}): CanActivateFn { + const routes = { ...defaultRoutes, ...overrides }; + return async (route) => { + const authService = inject(AuthService); + const cryptoService = inject(CryptoService); + const deviceTrustCryptoService = inject(DeviceTrustCryptoServiceAbstraction); + const router = inject(Router); + + const authStatus = await authService.getAuthStatus(); + + if (authStatus === AuthenticationStatus.LoggedOut) { + return router.createUrlTree([routes.loggedOut], { queryParams: route.queryParams }); + } + + if (authStatus === AuthenticationStatus.Unlocked) { + return router.createUrlTree([routes.loggedIn], { queryParams: route.queryParams }); + } + + // If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the + // login decryption options component. + const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust(); + const everHadUserKey = await cryptoService.getEverHadUserKey(); + if (authStatus === AuthenticationStatus.Locked && tdeEnabled && !everHadUserKey) { + return router.createUrlTree([routes.notDecrypted], { queryParams: route.queryParams }); + } + + if (authStatus === AuthenticationStatus.Locked) { + return router.createUrlTree([routes.locked], { queryParams: route.queryParams }); + } + + return router.createUrlTree(["/"]); + }; +} diff --git a/libs/angular/src/auth/guards/tde-decryption-required.guard.ts b/libs/angular/src/auth/guards/tde-decryption-required.guard.ts new file mode 100644 index 00000000000..84a4fef5761 --- /dev/null +++ b/libs/angular/src/auth/guards/tde-decryption-required.guard.ts @@ -0,0 +1,34 @@ +import { inject } from "@angular/core"; +import { + ActivatedRouteSnapshot, + Router, + RouterStateSnapshot, + CanActivateFn, +} from "@angular/router"; + +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; + +/** + * Only allow access to this route if the vault is locked and has never been decrypted. + * Otherwise redirect to root. + */ +export function tdeDecryptionRequiredGuard(): CanActivateFn { + return async (_: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { + const authService = inject(AuthService); + const cryptoService = inject(CryptoService); + const deviceTrustCryptoService = inject(DeviceTrustCryptoServiceAbstraction); + const router = inject(Router); + + const authStatus = await authService.getAuthStatus(); + const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust(); + const everHadUserKey = await cryptoService.getEverHadUserKey(); + if (authStatus !== AuthenticationStatus.Locked || !tdeEnabled || everHadUserKey) { + return router.createUrlTree(["/"]); + } + + return true; + }; +} diff --git a/libs/angular/src/components/callout.component.ts b/libs/angular/src/components/callout.component.ts index 0edaafe17ce..c595beec196 100644 --- a/libs/angular/src/components/callout.component.ts +++ b/libs/angular/src/components/callout.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnInit } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @Component({ selector: "app-callout", diff --git a/libs/angular/src/components/environment.component.ts b/libs/angular/src/components/environment.component.ts index f47fcf91247..6260d34c1d1 100644 --- a/libs/angular/src/components/environment.component.ts +++ b/libs/angular/src/components/environment.component.ts @@ -1,8 +1,11 @@ import { Directive, EventEmitter, Output } from "@angular/core"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { + EnvironmentService, + Region, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ModalService } from "../services/modal.service"; @@ -25,6 +28,9 @@ export class EnvironmentComponent { private modalService: ModalService ) { const urls = this.environmentService.getUrls(); + if (this.environmentService.selectedRegion != Region.SelfHosted) { + return; + } this.baseUrl = urls.base || ""; this.webVaultUrl = urls.webVault || ""; diff --git a/libs/angular/src/components/register.component.ts b/libs/angular/src/components/register.component.ts index cb5ba243d99..5363be39419 100644 --- a/libs/angular/src/components/register.component.ts +++ b/libs/angular/src/components/register.component.ts @@ -4,28 +4,28 @@ import { Router } from "@angular/router"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { - AllValidationErrors, - FormValidationErrorsService, -} from "@bitwarden/common/abstractions/formValidationErrors.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { PasswordLogInCredentials } from "@bitwarden/common/auth/models/domain/log-in-credentials"; import { RegisterResponse } from "@bitwarden/common/auth/models/response/register.response"; import { DEFAULT_KDF_CONFIG, DEFAULT_KDF_TYPE } from "@bitwarden/common/enums"; -import { Utils } from "@bitwarden/common/misc/utils"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request"; import { RegisterRequest } from "@bitwarden/common/models/request/register.request"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { DialogService } from "@bitwarden/components"; import { CaptchaProtectedComponent } from "../auth/components/captcha-protected.component"; -import { DialogServiceAbstraction, SimpleDialogType } from "../services/dialog"; +import { + AllValidationErrors, + FormValidationErrorsService, +} from "../platform/abstractions/form-validation-errors.service"; import { PasswordColorText } from "../shared/components/password-strength/password-strength.component"; import { InputsFieldMatch } from "../validators/inputsFieldMatch.validator"; @@ -92,7 +92,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn environmentService: EnvironmentService, protected logService: LogService, protected auditService: AuditService, - protected dialogService: DialogServiceAbstraction + protected dialogService: DialogService ) { super(environmentService, i18nService, platformUtilsService); this.showTerms = !platformUtilsService.isSelfHost(); @@ -232,7 +232,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn const result = await this.dialogService.openSimpleDialog({ title: { key: "weakAndExposedMasterPassword" }, content: { key: "weakAndBreachedMasterPasswordDesc" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!result) { @@ -242,7 +242,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn const result = await this.dialogService.openSimpleDialog({ title: { key: "weakMasterPassword" }, content: { key: "weakMasterPasswordDesc" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!result) { @@ -252,7 +252,7 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn const result = await this.dialogService.openSimpleDialog({ title: { key: "exposedMasterPassword" }, content: { key: "exposedMasterPasswordDesc" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!result) { @@ -271,16 +271,16 @@ export class RegisterComponent extends CaptchaProtectedComponent implements OnIn const hint = this.formGroup.value.hint; const kdf = DEFAULT_KDF_TYPE; const kdfConfig = DEFAULT_KDF_CONFIG; - const key = await this.cryptoService.makeKey(masterPassword, email, kdf, kdfConfig); - const encKey = await this.cryptoService.makeEncKey(key); - const hashedPassword = await this.cryptoService.hashPassword(masterPassword, key); - const keys = await this.cryptoService.makeKeyPair(encKey[0]); + const key = await this.cryptoService.makeMasterKey(masterPassword, email, kdf, kdfConfig); + const newUserKey = await this.cryptoService.makeUserKey(key); + const masterKeyHash = await this.cryptoService.hashMasterKey(masterPassword, key); + const keys = await this.cryptoService.makeKeyPair(newUserKey[0]); const request = new RegisterRequest( email, name, - hashedPassword, + masterKeyHash, hint, - encKey[1].encryptedString, + newUserKey[1].encryptedString, this.referenceData, this.captchaToken, kdf, diff --git a/libs/angular/src/components/set-password.component.ts b/libs/angular/src/components/set-password.component.ts index 1a2b4a91522..60230e7f313 100644 --- a/libs/angular/src/components/set-password.component.ts +++ b/libs/angular/src/components/set-password.component.ts @@ -3,27 +3,27 @@ import { ActivatedRoute, Router } from "@angular/router"; import { first } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; import { OrganizationUserResetPasswordEnrollmentRequest } from "@bitwarden/common/abstractions/organization-user/requests"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; import { HashPurpose, DEFAULT_KDF_TYPE, DEFAULT_KDF_CONFIG } from "@bitwarden/common/enums"; -import { Utils } from "@bitwarden/common/misc/utils"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetric-crypto-key"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { MasterKey, UserKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { DialogService } from "@bitwarden/components"; import { ChangePasswordComponent as BaseChangePasswordComponent } from "../auth/components/change-password.component"; -import { DialogServiceAbstraction } from "../services/dialog"; @Directive() export class SetPasswordComponent extends BaseChangePasswordComponent { @@ -52,7 +52,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { stateService: StateService, private organizationApiService: OrganizationApiServiceAbstraction, private organizationUserService: OrganizationUserService, - dialogService: DialogServiceAbstraction + dialogService: DialogService ) { super( i18nService, @@ -101,16 +101,16 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { async performSubmitActions( masterPasswordHash: string, - key: SymmetricCryptoKey, - encKey: [SymmetricCryptoKey, EncString] + masterKey: MasterKey, + userKey: [UserKey, EncString] ) { - const keys = await this.cryptoService.makeKeyPair(encKey[0]); + const newKeyPair = await this.cryptoService.makeKeyPair(userKey[0]); const request = new SetPasswordRequest( masterPasswordHash, - encKey[1].encryptedString, + userKey[1].encryptedString, this.hint, this.identifier, - new KeysRequest(keys[0], keys[1].encryptedString), + new KeysRequest(newKeyPair[0], newKeyPair[1].encryptedString), this.kdf, this.kdfConfig.iterations, this.kdfConfig.memory, @@ -121,7 +121,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { this.formPromise = this.apiService .setPassword(request) .then(async () => { - await this.onSetPasswordSuccess(key, encKey, keys); + await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair); return this.organizationApiService.getKeys(this.orgId); }) .then(async (response) => { @@ -131,16 +131,13 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { const userId = await this.stateService.getUserId(); const publicKey = Utils.fromB64ToArray(response.publicKey); - // RSA Encrypt user's encKey.key with organization public key - const userEncKey = await this.cryptoService.getEncKey(); - const encryptedKey = await this.cryptoService.rsaEncrypt( - userEncKey.key, - publicKey.buffer - ); + // RSA Encrypt user key with organization public key + const userKey = await this.cryptoService.getUserKey(); + const encryptedUserKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey); const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest(); resetRequest.masterPasswordHash = masterPasswordHash; - resetRequest.resetPasswordKey = encryptedKey.encryptedString; + resetRequest.resetPasswordKey = encryptedUserKey.encryptedString; return this.organizationUserService.putOrganizationUserResetPasswordEnrollment( this.orgId, @@ -150,7 +147,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { }); } else { this.formPromise = this.apiService.setPassword(request).then(async () => { - await this.onSetPasswordSuccess(key, encKey, keys); + await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair); }); } @@ -172,21 +169,21 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { } private async onSetPasswordSuccess( - key: SymmetricCryptoKey, - encKey: [SymmetricCryptoKey, EncString], - keys: [string, EncString] + masterKey: MasterKey, + userKey: [UserKey, EncString], + keyPair: [string, EncString] ) { await this.stateService.setKdfType(this.kdf); await this.stateService.setKdfConfig(this.kdfConfig); - await this.cryptoService.setKey(key); - await this.cryptoService.setEncKey(encKey[1].encryptedString); - await this.cryptoService.setEncPrivateKey(keys[1].encryptedString); + await this.cryptoService.setMasterKey(masterKey); + await this.cryptoService.setUserKey(userKey[0]); + await this.cryptoService.setPrivateKey(keyPair[1].encryptedString); - const localKeyHash = await this.cryptoService.hashPassword( + const localMasterKeyHash = await this.cryptoService.hashMasterKey( this.masterPassword, - key, + masterKey, HashPurpose.LocalAuthorization ); - await this.cryptoService.setKeyHash(localKeyHash); + await this.cryptoService.setMasterKeyHash(localMasterKeyHash); } } diff --git a/libs/angular/src/components/set-pin.component.ts b/libs/angular/src/components/set-pin.component.ts index 74461d72dff..bf92417ae68 100644 --- a/libs/angular/src/components/set-pin.component.ts +++ b/libs/angular/src/components/set-pin.component.ts @@ -1,9 +1,10 @@ import { Directive, OnInit } from "@angular/core"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { KeySuffixOptions } from "@bitwarden/common/enums/key-suffix-options.enum"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ModalRef } from "./modal/modal.ref"; @@ -17,13 +18,13 @@ export class SetPinComponent implements OnInit { constructor( private modalRef: ModalRef, private cryptoService: CryptoService, - private keyConnectorService: KeyConnectorService, + private userVerificationService: UserVerificationService, private stateService: StateService ) {} async ngOnInit() { this.showMasterPassOnRestart = this.masterPassOnRestart = - !(await this.keyConnectorService.getUsesKeyConnector()); + await this.userVerificationService.hasMasterPassword(); } toggleVisibility() { @@ -35,19 +36,22 @@ export class SetPinComponent implements OnInit { this.modalRef.close(false); } - const kdf = await this.stateService.getKdfType(); - const kdfConfig = await this.stateService.getKdfConfig(); - const email = await this.stateService.getEmail(); - const pinKey = await this.cryptoService.makePinKey(this.pin, email, kdf, kdfConfig); - const key = await this.cryptoService.getKey(); - const pinProtectedKey = await this.cryptoService.encrypt(key.key, pinKey); + const pinKey = await this.cryptoService.makePinKey( + this.pin, + await this.stateService.getEmail(), + await this.stateService.getKdfType(), + await this.stateService.getKdfConfig() + ); + const userKey = await this.cryptoService.getUserKey(); + const pinProtectedKey = await this.cryptoService.encrypt(userKey.key, pinKey); + const encPin = await this.cryptoService.encrypt(this.pin, userKey); + await this.stateService.setProtectedPin(encPin.encryptedString); if (this.masterPassOnRestart) { - const encPin = await this.cryptoService.encrypt(this.pin); - await this.stateService.setProtectedPin(encPin.encryptedString); - await this.stateService.setDecryptedPinProtected(pinProtectedKey); + await this.stateService.setPinKeyEncryptedUserKeyEphemeral(pinProtectedKey); } else { - await this.stateService.setEncryptedPinProtected(pinProtectedKey.encryptedString); + await this.stateService.setPinKeyEncryptedUserKey(pinProtectedKey); } + await this.cryptoService.clearDeprecatedKeys(KeySuffixOptions.Pin); this.modalRef.close(true); } diff --git a/libs/angular/src/components/settings/vault-timeout-input.component.ts b/libs/angular/src/components/settings/vault-timeout-input.component.ts index 8ac3074a4cd..d23fbe29367 100644 --- a/libs/angular/src/components/settings/vault-timeout-input.component.ts +++ b/libs/angular/src/components/settings/vault-timeout-input.component.ts @@ -6,12 +6,22 @@ import { ValidationErrors, Validator, } from "@angular/forms"; -import { filter, Subject, takeUntil } from "rxjs"; +import { filter, map, Observable, Subject, takeUntil } from "rxjs"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +interface VaultTimeoutFormValue { + vaultTimeout: number | null; + custom: { + hours: number | null; + minutes: number | null; + }; +} @Directive() export class VaultTimeoutInputComponent @@ -43,6 +53,8 @@ export class VaultTimeoutInputComponent vaultTimeoutPolicyHours: number; vaultTimeoutPolicyMinutes: number; + protected canLockVault$: Observable; + private onChange: (vaultTimeout: number) => void; private validatorChange: () => void; private destroy$ = new Subject(); @@ -50,6 +62,7 @@ export class VaultTimeoutInputComponent constructor( private formBuilder: FormBuilder, private policyService: PolicyService, + private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private i18nService: I18nService ) {} @@ -65,27 +78,43 @@ export class VaultTimeoutInputComponent this.applyVaultTimeoutPolicy(); }); - this.form.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { - if (this.onChange) { - this.onChange(this.getVaultTimeout(value)); - } - }); + this.form.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((value: VaultTimeoutFormValue) => { + if (this.onChange) { + this.onChange(this.getVaultTimeout(value)); + } + }); - // Assign the previous value to the custom fields + // Assign the current value to the custom fields + // so that if the user goes from a numeric value to custom + // we can initialize the custom fields with the current value + // ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields this.form.controls.vaultTimeout.valueChanges .pipe( filter((value) => value !== VaultTimeoutInputComponent.CUSTOM_VALUE), takeUntil(this.destroy$) ) - .subscribe((_) => { - const current = Math.max(this.form.value.vaultTimeout, 0); - this.form.patchValue({ - custom: { - hours: Math.floor(current / 60), - minutes: current % 60, + .subscribe((value) => { + const current = Math.max(value, 0); + + // This cannot emit an event b/c it would cause form.valueChanges to fire again + // and we are already handling that above so just silently update + // custom fields when vaultTimeout changes to a non-custom value + this.form.patchValue( + { + custom: { + hours: Math.floor(current / 60), + minutes: current % 60, + }, }, - }); + { emitEvent: false } + ); }); + + this.canLockVault$ = this.vaultTimeoutSettingsService + .availableVaultTimeoutActions$() + .pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock))); } ngOnDestroy() { @@ -104,7 +133,7 @@ export class VaultTimeoutInputComponent } } - getVaultTimeout(value: any) { + getVaultTimeout(value: VaultTimeoutFormValue) { if (value.vaultTimeout !== VaultTimeoutInputComponent.CUSTOM_VALUE) { return value.vaultTimeout; } diff --git a/libs/angular/src/components/share.component.ts b/libs/angular/src/components/share.component.ts index 286e4da00db..6d0764be9b3 100644 --- a/libs/angular/src/components/share.component.ts +++ b/libs/angular/src/components/share.component.ts @@ -1,18 +1,18 @@ import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { firstValueFrom, map, Observable, Subject, takeUntil } from "rxjs"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Checkable, isChecked } from "@bitwarden/common/types/checkable"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; @Directive() export class ShareComponent implements OnInit, OnDestroy { @@ -66,7 +66,9 @@ export class ShareComponent implements OnInit, OnDestroy { }); const cipherDomain = await this.cipherService.get(this.cipherId); - this.cipher = await cipherDomain.decrypt(); + this.cipher = await cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain) + ); this.filterCollections(); } @@ -94,7 +96,9 @@ export class ShareComponent implements OnInit, OnDestroy { } const cipherDomain = await this.cipherService.get(this.cipherId); - const cipherView = await cipherDomain.decrypt(); + const cipherView = await cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain) + ); const orgs = await firstValueFrom(this.organizations$); const orgName = orgs.find((o) => o.id === this.organizationId)?.name ?? this.i18nService.t("organization"); diff --git a/libs/angular/src/directives/api-action.directive.ts b/libs/angular/src/directives/api-action.directive.ts index a156b3c78fe..4ace69d7d75 100644 --- a/libs/angular/src/directives/api-action.directive.ts +++ b/libs/angular/src/directives/api-action.directive.ts @@ -1,8 +1,8 @@ import { Directive, ElementRef, Input, OnChanges } from "@angular/core"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; /** * Provides error handling, in particular for any error returned by the server in an api call. diff --git a/libs/angular/src/directives/autofocus.directive.ts b/libs/angular/src/directives/autofocus.directive.ts index 168cd1cc211..c45dc0f8f45 100644 --- a/libs/angular/src/directives/autofocus.directive.ts +++ b/libs/angular/src/directives/autofocus.directive.ts @@ -1,7 +1,7 @@ import { Directive, ElementRef, Input, NgZone } from "@angular/core"; import { take } from "rxjs/operators"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; @Directive({ selector: "[appAutofocus]", diff --git a/libs/angular/src/directives/copy-click.directive.ts b/libs/angular/src/directives/copy-click.directive.ts index a735896211d..5e449572bf8 100644 --- a/libs/angular/src/directives/copy-click.directive.ts +++ b/libs/angular/src/directives/copy-click.directive.ts @@ -1,6 +1,6 @@ import { Directive, HostListener, Input } from "@angular/core"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @Directive({ selector: "[appCopyClick]", diff --git a/libs/angular/src/directives/copy-text.directive.ts b/libs/angular/src/directives/copy-text.directive.ts new file mode 100644 index 00000000000..b595085e43b --- /dev/null +++ b/libs/angular/src/directives/copy-text.directive.ts @@ -0,0 +1,24 @@ +import { Directive, ElementRef, HostListener, Input } from "@angular/core"; + +import { ClientType } from "@bitwarden/common/enums"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +@Directive({ + selector: "[appCopyText]", +}) +export class CopyTextDirective { + constructor(private el: ElementRef, private platformUtilsService: PlatformUtilsService) {} + + @Input("appCopyText") copyText: string; + + @HostListener("copy") onCopy() { + if (window == null) { + return; + } + + const timeout = this.platformUtilsService.getClientType() === ClientType.Desktop ? 100 : 0; + setTimeout(() => { + this.platformUtilsService.copyToClipboard(this.copyText, { window: window }); + }, timeout); + } +} diff --git a/libs/angular/src/directives/if-feature.directive.spec.ts b/libs/angular/src/directives/if-feature.directive.spec.ts new file mode 100644 index 00000000000..9d492b7f01f --- /dev/null +++ b/libs/angular/src/directives/if-feature.directive.spec.ts @@ -0,0 +1,128 @@ +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { FeatureFlag, FeatureFlagValue } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +import { IfFeatureDirective } from "./if-feature.directive"; + +const testBooleanFeature: FeatureFlag = "boolean-feature" as FeatureFlag; +const testStringFeature: FeatureFlag = "string-feature" as FeatureFlag; +const testStringFeatureValue = "test-value"; + +@Component({ + template: ` +
    +
    Hidden behind feature flag
    +
    +
    +
    Hidden behind feature flag
    +
    +
    +
    + Hidden behind missing flag. Should not be visible. +
    +
    + `, +}) +class TestComponent { + testBooleanFeature = testBooleanFeature; + stringFeature = testStringFeature; + stringFeatureValue = testStringFeatureValue; + + missingFlag = "missing-flag" as FeatureFlag; +} + +describe("IfFeatureDirective", () => { + let fixture: ComponentFixture; + let content: HTMLElement; + let mockConfigService: MockProxy; + + const mockConfigFlagValue = (flag: FeatureFlag, flagValue: FeatureFlagValue) => { + mockConfigService.getFeatureFlag.mockImplementation((f, defaultValue) => + flag == f ? Promise.resolve(flagValue) : Promise.resolve(defaultValue) + ); + }; + + const queryContent = (testId: string) => + fixture.debugElement.query(By.css(`[data-testid="${testId}"]`))?.nativeElement; + + beforeEach(async () => { + mockConfigService = mock(); + + await TestBed.configureTestingModule({ + declarations: [IfFeatureDirective, TestComponent], + providers: [ + { provide: LogService, useValue: mock() }, + { + provide: ConfigServiceAbstraction, + useValue: mockConfigService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + }); + + it("renders content when the feature flag is enabled", async () => { + mockConfigFlagValue(testBooleanFeature, true); + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("boolean-content"); + + expect(content).toBeDefined(); + }); + + it("renders content when the feature flag value matches the provided value", async () => { + mockConfigFlagValue(testStringFeature, testStringFeatureValue); + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("string-content"); + + expect(content).toBeDefined(); + }); + + it("hides content when the feature flag is disabled", async () => { + mockConfigFlagValue(testBooleanFeature, false); + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("boolean-content"); + + expect(content).toBeUndefined(); + }); + + it("hides content when the feature flag value does not match the provided value", async () => { + mockConfigFlagValue(testStringFeature, "wrong-value"); + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("string-content"); + + expect(content).toBeUndefined(); + }); + + it("hides content when the feature flag is missing", async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("missing-flag-content"); + + expect(content).toBeUndefined(); + }); + + it("hides content when the directive throws an unexpected exception", async () => { + mockConfigService.getFeatureFlag.mockImplementation(() => Promise.reject("Some error")); + fixture.detectChanges(); + await fixture.whenStable(); + + content = queryContent("boolean-content"); + + expect(content).toBeUndefined(); + }); +}); diff --git a/libs/angular/src/directives/if-feature.directive.ts b/libs/angular/src/directives/if-feature.directive.ts new file mode 100644 index 00000000000..e9aca531bb7 --- /dev/null +++ b/libs/angular/src/directives/if-feature.directive.ts @@ -0,0 +1,56 @@ +import { Directive, Input, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; + +import { FeatureFlag, FeatureFlagValue } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +/** + * Directive that conditionally renders the element when the feature flag is enabled and/or + * matches the value specified by {@link appIfFeatureValue}. + * + * When a feature flag is not found in the config service, the element is hidden. + */ +@Directive({ + selector: "[appIfFeature]", +}) +export class IfFeatureDirective implements OnInit { + /** + * The feature flag to check. + */ + @Input() appIfFeature: FeatureFlag; + + /** + * Optional value to compare against the value of the feature flag in the config service. + * @default true + */ + @Input() appIfFeatureValue: FeatureFlagValue = true; + + private hasView = false; + + constructor( + private templateRef: TemplateRef, + private viewContainer: ViewContainerRef, + private configService: ConfigServiceAbstraction, + private logService: LogService + ) {} + + async ngOnInit() { + try { + const flagValue = await this.configService.getFeatureFlag(this.appIfFeature); + + if (this.appIfFeatureValue === flagValue) { + if (!this.hasView) { + this.viewContainer.createEmbeddedView(this.templateRef); + this.hasView = true; + } + } else { + this.viewContainer.clear(); + this.hasView = false; + } + } catch (e) { + this.logService.error(e); + this.viewContainer.clear(); + this.hasView = false; + } + } +} diff --git a/libs/angular/src/directives/launch-click.directive.ts b/libs/angular/src/directives/launch-click.directive.ts index 547af519442..e748afabf49 100644 --- a/libs/angular/src/directives/launch-click.directive.ts +++ b/libs/angular/src/directives/launch-click.directive.ts @@ -1,7 +1,7 @@ import { Directive, HostListener, Input } from "@angular/core"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; @Directive({ selector: "[appLaunchClick]", diff --git a/libs/angular/src/directives/not-premium.directive.ts b/libs/angular/src/directives/not-premium.directive.ts index b25615a8edb..ad7f4a7b47f 100644 --- a/libs/angular/src/directives/not-premium.directive.ts +++ b/libs/angular/src/directives/not-premium.directive.ts @@ -1,6 +1,6 @@ import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; /** * Hides the element if the user has premium. diff --git a/libs/angular/src/directives/premium.directive.ts b/libs/angular/src/directives/premium.directive.ts index 8729fea2450..ca9f36bc694 100644 --- a/libs/angular/src/directives/premium.directive.ts +++ b/libs/angular/src/directives/premium.directive.ts @@ -1,6 +1,6 @@ import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; /** * Only shows the element if the user has premium. diff --git a/libs/angular/src/directives/select-copy.directive.ts b/libs/angular/src/directives/select-copy.directive.ts deleted file mode 100644 index 8d894b14d56..00000000000 --- a/libs/angular/src/directives/select-copy.directive.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Directive, ElementRef, HostListener } from "@angular/core"; - -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; - -@Directive({ - selector: "[appSelectCopy]", -}) -export class SelectCopyDirective { - constructor(private el: ElementRef, private platformUtilsService: PlatformUtilsService) {} - - @HostListener("copy") onCopy() { - if (window == null) { - return; - } - let copyText = ""; - const selection = window.getSelection(); - for (let i = 0; i < selection.rangeCount; i++) { - const range = selection.getRangeAt(i); - const text = range.toString(); - - // The selection should only contain one line of text. In some cases however, the - // selection contains newlines and space characters from the indentation of following - // sibling nodes. To avoid copying passwords containing trailing newlines and spaces - // that aren't part of the password, the selection has to be trimmed. - let stringEndPos = text.length; - const newLinePos = text.search(/(?:\r\n|\r|\n)/); - if (newLinePos > -1) { - const otherPart = text.substr(newLinePos).trim(); - if (otherPart === "") { - stringEndPos = newLinePos; - } - } - copyText += text.substring(0, stringEndPos); - } - this.platformUtilsService.copyToClipboard(copyText, { window: window }); - } -} diff --git a/libs/angular/src/guard/feature-flag.guard.spec.ts b/libs/angular/src/guard/feature-flag.guard.spec.ts new file mode 100644 index 00000000000..1ac2a90ae0d --- /dev/null +++ b/libs/angular/src/guard/feature-flag.guard.spec.ts @@ -0,0 +1,152 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { CanActivateFn, Router } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; +import { mock, MockProxy } from "jest-mock-extended"; + +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"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { I18nMockService } from "@bitwarden/components/src"; + +import { canAccessFeature } from "./feature-flag.guard"; + +@Component({ template: "" }) +export class EmptyComponent {} + +describe("canAccessFeature", () => { + const testFlag: FeatureFlag = "test-flag" as FeatureFlag; + const featureRoute = "enabled-feature"; + const redirectRoute = "redirect"; + + let mockConfigService: MockProxy; + let mockPlatformUtilsService: MockProxy; + + const setup = (featureGuard: CanActivateFn, flagValue: any) => { + mockConfigService = mock(); + mockPlatformUtilsService = mock(); + + // Mock the correct getter based on the type of flagValue; also mock default values if one is not provided + if (typeof flagValue === "boolean") { + mockConfigService.getFeatureFlag.mockImplementation((flag, defaultValue = false) => + flag == testFlag ? Promise.resolve(flagValue) : Promise.resolve(defaultValue) + ); + } else if (typeof flagValue === "string") { + mockConfigService.getFeatureFlag.mockImplementation((flag, defaultValue = "") => + flag == testFlag ? Promise.resolve(flagValue) : Promise.resolve(defaultValue) + ); + } else if (typeof flagValue === "number") { + mockConfigService.getFeatureFlag.mockImplementation((flag, defaultValue = 0) => + flag == testFlag ? Promise.resolve(flagValue) : Promise.resolve(defaultValue) + ); + } + + const testBed = TestBed.configureTestingModule({ + imports: [ + RouterTestingModule.withRoutes([ + { path: "", component: EmptyComponent }, + { + path: featureRoute, + component: EmptyComponent, + canActivate: [featureGuard], + }, + { path: redirectRoute, component: EmptyComponent }, + ]), + ], + providers: [ + { provide: ConfigServiceAbstraction, useValue: mockConfigService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: LogService, useValue: mock() }, + { + provide: I18nService, + useValue: new I18nMockService({ + accessDenied: "Access Denied!", + }), + }, + ], + }); + return { + router: testBed.inject(Router), + }; + }; + + it("successfully navigates when the feature flag is enabled", async () => { + const { router } = setup(canAccessFeature(testFlag), true); + + await router.navigate([featureRoute]); + + expect(router.url).toBe(`/${featureRoute}`); + }); + + it("successfully navigates when the feature flag value matches the required value", async () => { + const { router } = setup(canAccessFeature(testFlag, "some-value"), "some-value"); + + await router.navigate([featureRoute]); + + expect(router.url).toBe(`/${featureRoute}`); + }); + + it("fails to navigate when the feature flag is disabled", async () => { + const { router } = setup(canAccessFeature(testFlag), false); + + await router.navigate([featureRoute]); + + expect(router.url).toBe("/"); + }); + + it("fails to navigate when the feature flag value does not match the required value", async () => { + const { router } = setup(canAccessFeature(testFlag, "some-value"), "some-wrong-value"); + + await router.navigate([featureRoute]); + + expect(router.url).toBe("/"); + }); + + it("fails to navigate when the feature flag does not exist", async () => { + const { router } = setup(canAccessFeature("missing-flag" as FeatureFlag), true); + + await router.navigate([featureRoute]); + + expect(router.url).toBe("/"); + }); + + it("shows an error toast when the feature flag is disabled", async () => { + const { router } = setup(canAccessFeature(testFlag), false); + + await router.navigate([featureRoute]); + + expect(mockPlatformUtilsService.showToast).toHaveBeenCalledWith( + "error", + null, + "Access Denied!" + ); + }); + + it("does not show an error toast when the feature flag is enabled", async () => { + const { router } = setup(canAccessFeature(testFlag), true); + + await router.navigate([featureRoute]); + + expect(mockPlatformUtilsService.showToast).not.toHaveBeenCalled(); + }); + + it("redirects to the specified redirect url when the feature flag is disabled", async () => { + const { router } = setup(canAccessFeature(testFlag, true, redirectRoute), false); + + await router.navigate([featureRoute]); + + expect(router.url).toBe(`/${redirectRoute}`); + }); + + it("fails to navigate when the config service throws an unexpected exception", async () => { + const { router } = setup(canAccessFeature(testFlag), true); + + mockConfigService.getFeatureFlag.mockImplementation(() => Promise.reject("Some error")); + + await router.navigate([featureRoute]); + + expect(router.url).toBe("/"); + }); +}); diff --git a/libs/angular/src/guard/feature-flag.guard.ts b/libs/angular/src/guard/feature-flag.guard.ts new file mode 100644 index 00000000000..d9297cbd978 --- /dev/null +++ b/libs/angular/src/guard/feature-flag.guard.ts @@ -0,0 +1,50 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; + +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"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +// Replace this with a type safe lookup of the feature flag values in PM-2282 +type FlagValue = boolean | number | string; + +/** + * Returns a CanActivateFn that checks if the feature flag is enabled. If not, it shows an "Access Denied!" + * toast and optionally redirects to the specified url. + * @param featureFlag - The feature flag to check + * @param requiredFlagValue - Optional value to the feature flag must be equal to, defaults to true + * @param redirectUrlOnDisabled - Optional url to redirect to if the feature flag is disabled + */ +export const canAccessFeature = ( + featureFlag: FeatureFlag, + requiredFlagValue: FlagValue = true, + redirectUrlOnDisabled?: string +): CanActivateFn => { + return async () => { + const configService = inject(ConfigServiceAbstraction); + const platformUtilsService = inject(PlatformUtilsService); + const router = inject(Router); + const i18nService = inject(I18nService); + const logService = inject(LogService); + + try { + const flagValue = await configService.getFeatureFlag(featureFlag); + + if (flagValue === requiredFlagValue) { + return true; + } + + platformUtilsService.showToast("error", null, i18nService.t("accessDenied")); + + if (redirectUrlOnDisabled != null) { + return router.createUrlTree([redirectUrlOnDisabled]); + } + return false; + } catch (e) { + logService.error(e); + return false; + } + }; +}; diff --git a/libs/angular/src/interfaces/selectOptions.ts b/libs/angular/src/interfaces/selectOptions.ts deleted file mode 100644 index 19da14d9f3d..00000000000 --- a/libs/angular/src/interfaces/selectOptions.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface SelectOptions { - name: string; - value: any; - disabled?: boolean; -} diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index dac7a0779ea..649dacf24b3 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -10,22 +10,24 @@ import { ApiActionDirective } from "./directives/api-action.directive"; import { AutofocusDirective } from "./directives/autofocus.directive"; import { BoxRowDirective } from "./directives/box-row.directive"; import { CopyClickDirective } from "./directives/copy-click.directive"; +import { CopyTextDirective } from "./directives/copy-text.directive"; import { FallbackSrcDirective } from "./directives/fallback-src.directive"; +import { IfFeatureDirective } from "./directives/if-feature.directive"; import { InputStripSpacesDirective } from "./directives/input-strip-spaces.directive"; import { InputVerbatimDirective } from "./directives/input-verbatim.directive"; import { LaunchClickDirective } from "./directives/launch-click.directive"; import { NotPremiumDirective } from "./directives/not-premium.directive"; -import { SelectCopyDirective } from "./directives/select-copy.directive"; import { StopClickDirective } from "./directives/stop-click.directive"; import { StopPropDirective } from "./directives/stop-prop.directive"; import { TrueFalseValueDirective } from "./directives/true-false-value.directive"; import { CreditCardNumberPipe } from "./pipes/credit-card-number.pipe"; -import { EllipsisPipe } from "./pipes/ellipsis.pipe"; -import { I18nPipe } from "./pipes/i18n.pipe"; import { SearchCiphersPipe } from "./pipes/search-ciphers.pipe"; import { SearchPipe } from "./pipes/search.pipe"; import { UserNamePipe } from "./pipes/user-name.pipe"; import { UserTypePipe } from "./pipes/user-type.pipe"; +import { EllipsisPipe } from "./platform/pipes/ellipsis.pipe"; +import { FingerprintPipe } from "./platform/pipes/fingerprint.pipe"; +import { I18nPipe } from "./platform/pipes/i18n.pipe"; import { PasswordStrengthComponent } from "./shared/components/password-strength/password-strength.component"; import { ExportScopeCalloutComponent } from "./tools/export/components/export-scope-callout.component"; import { IconComponent } from "./vault/components/icon.component"; @@ -48,6 +50,7 @@ import { IconComponent } from "./vault/components/icon.component"; AutofocusDirective, BoxRowDirective, CalloutComponent, + CopyTextDirective, CreditCardNumberPipe, EllipsisPipe, ExportScopeCalloutComponent, @@ -59,7 +62,6 @@ import { IconComponent } from "./vault/components/icon.component"; NotPremiumDirective, SearchCiphersPipe, SearchPipe, - SelectCopyDirective, StopClickDirective, StopPropDirective, TrueFalseValueDirective, @@ -68,6 +70,8 @@ import { IconComponent } from "./vault/components/icon.component"; UserNamePipe, PasswordStrengthComponent, UserTypePipe, + IfFeatureDirective, + FingerprintPipe, ], exports: [ A11yInvalidDirective, @@ -77,6 +81,7 @@ import { IconComponent } from "./vault/components/icon.component"; BitwardenToastModule, BoxRowDirective, CalloutComponent, + CopyTextDirective, CreditCardNumberPipe, EllipsisPipe, ExportScopeCalloutComponent, @@ -88,7 +93,6 @@ import { IconComponent } from "./vault/components/icon.component"; NotPremiumDirective, SearchCiphersPipe, SearchPipe, - SelectCopyDirective, StopClickDirective, StopPropDirective, TrueFalseValueDirective, @@ -97,7 +101,17 @@ import { IconComponent } from "./vault/components/icon.component"; UserNamePipe, PasswordStrengthComponent, UserTypePipe, + IfFeatureDirective, + FingerprintPipe, + ], + providers: [ + CreditCardNumberPipe, + DatePipe, + I18nPipe, + SearchPipe, + UserNamePipe, + UserTypePipe, + FingerprintPipe, ], - providers: [CreditCardNumberPipe, DatePipe, I18nPipe, SearchPipe, UserNamePipe, UserTypePipe], }) export class JslibModule {} diff --git a/libs/angular/src/pipes/color-password.pipe.ts b/libs/angular/src/pipes/color-password.pipe.ts index 2b3a6bdf1ea..ae5791b834b 100644 --- a/libs/angular/src/pipes/color-password.pipe.ts +++ b/libs/angular/src/pipes/color-password.pipe.ts @@ -1,6 +1,6 @@ import { Pipe, PipeTransform } from "@angular/core"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; /* An updated pipe that sanitizes HTML, highlights numbers and special characters (in different colors each) diff --git a/libs/angular/src/pipes/user-type.pipe.ts b/libs/angular/src/pipes/user-type.pipe.ts index cc499f81c9b..49feb3c530d 100644 --- a/libs/angular/src/pipes/user-type.pipe.ts +++ b/libs/angular/src/pipes/user-type.pipe.ts @@ -1,7 +1,7 @@ import { Pipe, PipeTransform } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @Pipe({ name: "userType", diff --git a/libs/common/src/abstractions/formValidationErrors.service.ts b/libs/angular/src/platform/abstractions/form-validation-errors.service.ts similarity index 100% rename from libs/common/src/abstractions/formValidationErrors.service.ts rename to libs/angular/src/platform/abstractions/form-validation-errors.service.ts diff --git a/libs/angular/src/pipes/ellipsis.pipe.ts b/libs/angular/src/platform/pipes/ellipsis.pipe.ts similarity index 88% rename from libs/angular/src/pipes/ellipsis.pipe.ts rename to libs/angular/src/platform/pipes/ellipsis.pipe.ts index 081dba11abe..dd271f94627 100644 --- a/libs/angular/src/pipes/ellipsis.pipe.ts +++ b/libs/angular/src/platform/pipes/ellipsis.pipe.ts @@ -3,6 +3,9 @@ import { Pipe, PipeTransform } from "@angular/core"; @Pipe({ name: "ellipsis", }) +/** + * @deprecated Use the tailwind class 'tw-truncate' instead + */ export class EllipsisPipe implements PipeTransform { transform(value: string, limit = 25, completeWords = false, ellipsis = "...") { if (value.length <= limit) { diff --git a/libs/angular/src/platform/pipes/fingerprint.pipe.ts b/libs/angular/src/platform/pipes/fingerprint.pipe.ts new file mode 100644 index 00000000000..a01ec21e6cb --- /dev/null +++ b/libs/angular/src/platform/pipes/fingerprint.pipe.ts @@ -0,0 +1,29 @@ +import { Pipe } from "@angular/core"; + +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +@Pipe({ + name: "fingerprint", +}) +export class FingerprintPipe { + constructor(private cryptoService: CryptoService) {} + + async transform(publicKey: string | Uint8Array, fingerprintMaterial: string): Promise { + try { + if (typeof publicKey === "string") { + publicKey = Utils.fromB64ToArray(publicKey); + } + + const fingerprint = await this.cryptoService.getFingerprint(fingerprintMaterial, publicKey); + + if (fingerprint != null) { + return fingerprint.join("-"); + } + + return ""; + } catch { + return ""; + } + } +} diff --git a/libs/angular/src/pipes/i18n.pipe.ts b/libs/angular/src/platform/pipes/i18n.pipe.ts similarity index 80% rename from libs/angular/src/pipes/i18n.pipe.ts rename to libs/angular/src/platform/pipes/i18n.pipe.ts index afab9b8d024..1f92bbb19a4 100644 --- a/libs/angular/src/pipes/i18n.pipe.ts +++ b/libs/angular/src/platform/pipes/i18n.pipe.ts @@ -1,6 +1,6 @@ import { Pipe, PipeTransform } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @Pipe({ name: "i18n", diff --git a/libs/angular/src/services/broadcaster.service.ts b/libs/angular/src/platform/services/broadcaster.service.ts similarity index 77% rename from libs/angular/src/services/broadcaster.service.ts rename to libs/angular/src/platform/services/broadcaster.service.ts index 9f62ecbd10c..cf58d2b311c 100644 --- a/libs/angular/src/services/broadcaster.service.ts +++ b/libs/angular/src/platform/services/broadcaster.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@angular/core"; -import { BroadcasterService as BaseBroadcasterService } from "@bitwarden/common/services/broadcaster.service"; +import { BroadcasterService as BaseBroadcasterService } from "@bitwarden/common/platform/services/broadcaster.service"; @Injectable() export class BroadcasterService extends BaseBroadcasterService {} diff --git a/libs/common/src/services/formValidationErrors.service.ts b/libs/angular/src/platform/services/form-validation-errors.service.ts similarity index 94% rename from libs/common/src/services/formValidationErrors.service.ts rename to libs/angular/src/platform/services/form-validation-errors.service.ts index a01d3e64295..674a5740c56 100644 --- a/libs/common/src/services/formValidationErrors.service.ts +++ b/libs/angular/src/platform/services/form-validation-errors.service.ts @@ -4,7 +4,7 @@ import { FormGroupControls, FormValidationErrorsService as FormValidationErrorsAbstraction, AllValidationErrors, -} from "../abstractions/formValidationErrors.service"; +} from "../abstractions/form-validation-errors.service"; export class FormValidationErrorsService implements FormValidationErrorsAbstraction { getFormValidationErrors(controls: FormGroupControls): AllValidationErrors[] { diff --git a/libs/angular/src/services/dialog/dialog.service.abstraction.ts b/libs/angular/src/services/dialog/dialog.service.abstraction.ts deleted file mode 100644 index f940dd1fa50..00000000000 --- a/libs/angular/src/services/dialog/dialog.service.abstraction.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Dialog, DialogRef } from "@angular/cdk/dialog"; - -import { SimpleDialogOptions } from "./simple-dialog-options"; - -export abstract class DialogServiceAbstraction extends Dialog { - /** - * Opens a simple dialog, returns true if the user accepted the dialog. - * - * @param {SimpleDialogOptions} simpleDialogOptions - An object containing options for the dialog. - * @returns `boolean` - True if the user accepted the dialog, false otherwise. - */ - openSimpleDialog: (simpleDialogOptions: SimpleDialogOptions) => Promise; - - /** - * Opens a simple dialog. - * - * @deprecated Use `openSimpleDialogAcceptedPromise` instead. If you find a use case for the `dialogRef` - * please let #wg-component-library know and we can un-deprecate this method. - * - * @param {SimpleDialogOptions} simpleDialogOptions - An object containing options for the dialog. - * @returns `DialogRef` - The reference to the opened dialog. - * Contains a closed observable which can be subscribed to for determining which button - * a user pressed (see `SimpleDialogCloseType`) - */ - openSimpleDialogRef: (simpleDialogOptions: SimpleDialogOptions) => DialogRef; -} diff --git a/libs/angular/src/services/dialog/dialog.service.ts b/libs/angular/src/services/dialog/dialog.service.ts deleted file mode 100644 index aaddf651e31..00000000000 --- a/libs/angular/src/services/dialog/dialog.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - DialogRef, - DialogConfig, - Dialog, - DEFAULT_DIALOG_CONFIG, - DIALOG_SCROLL_STRATEGY, -} from "@angular/cdk/dialog"; -import { Overlay, OverlayContainer } from "@angular/cdk/overlay"; -import { ComponentType } from "@angular/cdk/portal"; -import { Inject, Injectable, Injector, Optional, SkipSelf, TemplateRef } from "@angular/core"; - -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; - -import { DialogServiceAbstraction } from "./dialog.service.abstraction"; -import { SimpleDialogOptions } from "./simple-dialog-options"; -import { Translation } from "./translation"; - -// This is a temporary base class for Dialogs. It is intended to be removed once the Component Library is adopted by each app. -@Injectable() -export abstract class DialogService extends Dialog implements DialogServiceAbstraction { - constructor( - /** Parent class constructor */ - _overlay: Overlay, - _injector: Injector, - @Optional() @Inject(DEFAULT_DIALOG_CONFIG) _defaultOptions: DialogConfig, - @Optional() @SkipSelf() _parentDialog: Dialog, - _overlayContainer: OverlayContainer, - @Inject(DIALOG_SCROLL_STRATEGY) scrollStrategy: any, - protected i18nService: I18nService - ) { - super(_overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, scrollStrategy); - } - - async openSimpleDialog(options: SimpleDialogOptions): Promise { - throw new Error("Method not implemented."); - } - - openSimpleDialogRef(simpleDialogOptions: SimpleDialogOptions): DialogRef { - throw new Error("Method not implemented."); - } - - override open( - componentOrTemplateRef: ComponentType | TemplateRef, - config?: DialogConfig> - ): DialogRef { - throw new Error("Method not implemented."); - } - - protected translate(translation: string | Translation, defaultKey?: string): string { - if (translation == null && defaultKey == null) { - return null; - } - - if (translation == null) { - return this.i18nService.t(defaultKey); - } - - // Translation interface use implies we must localize. - if (typeof translation === "object") { - return this.i18nService.t(translation.key, ...(translation.placeholders ?? [])); - } - - return translation; - } -} diff --git a/libs/angular/src/services/dialog/index.ts b/libs/angular/src/services/dialog/index.ts deleted file mode 100644 index b664d179e08..00000000000 --- a/libs/angular/src/services/dialog/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./dialog.service.abstraction"; -export * from "./simple-dialog-options"; -export * from "./simple-dialog-type.enum"; -export * from "./simple-dialog-close-type.enum"; -export * from "./dialog.service"; -export * from "./translation"; diff --git a/libs/angular/src/services/dialog/simple-dialog-close-type.enum.ts b/libs/angular/src/services/dialog/simple-dialog-close-type.enum.ts deleted file mode 100644 index d6eff80cdd0..00000000000 --- a/libs/angular/src/services/dialog/simple-dialog-close-type.enum.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum SimpleDialogCloseType { - ACCEPT = "accept", - CANCEL = "cancel", -} diff --git a/libs/angular/src/services/dialog/simple-dialog-type.enum.ts b/libs/angular/src/services/dialog/simple-dialog-type.enum.ts deleted file mode 100644 index e7fa460aac2..00000000000 --- a/libs/angular/src/services/dialog/simple-dialog-type.enum.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum SimpleDialogType { - PRIMARY = "primary", - SUCCESS = "success", - INFO = "info", - WARNING = "warning", - DANGER = "danger", -} diff --git a/libs/angular/src/services/dialog/translation.ts b/libs/angular/src/services/dialog/translation.ts deleted file mode 100644 index d8dca21b3f1..00000000000 --- a/libs/angular/src/services/dialog/translation.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Translation { - key: string; - placeholders?: Array; -} diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 6ae4f45ea40..af3350193ec 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -3,19 +3,20 @@ import { InjectionToken } from "@angular/core"; import { AbstractMemoryStorageService, AbstractStorageService, -} from "@bitwarden/common/abstractions/storage.service"; -import { StateFactory } from "@bitwarden/common/factories/stateFactory"; +} from "@bitwarden/common/platform/abstractions/storage.service"; +import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; export const WINDOW = new InjectionToken("WINDOW"); export const MEMORY_STORAGE = new InjectionToken("MEMORY_STORAGE"); export const SECURE_STORAGE = new InjectionToken("SECURE_STORAGE"); export const STATE_FACTORY = new InjectionToken("STATE_FACTORY"); export const STATE_SERVICE_USE_CACHE = new InjectionToken("STATE_SERVICE_USE_CACHE"); -export const LOGOUT_CALLBACK = new InjectionToken<(expired: boolean, userId?: string) => void>( - "LOGOUT_CALLBACK" +export const LOGOUT_CALLBACK = new InjectionToken< + (expired: boolean, userId?: string) => Promise +>("LOGOUT_CALLBACK"); +export const LOCKED_CALLBACK = new InjectionToken<(userId?: string) => Promise>( + "LOCKED_CALLBACK" ); -export const LOCKED_CALLBACK = new InjectionToken<() => void>("LOCKED_CALLBACK"); -export const CLIENT_TYPE = new InjectionToken("CLIENT_TYPE"); export const LOCALES_DIRECTORY = new InjectionToken("LOCALES_DIRECTORY"); export const SYSTEM_LANGUAGE = new InjectionToken("SYSTEM_LANGUAGE"); export const LOG_MAC_FAILURES = new InjectionToken("LOG_MAC_FAILURES"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 7ae54c92133..e2019c7d33f 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -3,24 +3,10 @@ import { LOCALE_ID, NgModule } from "@angular/core"; import { AvatarUpdateService as AccountUpdateServiceAbstraction } from "@bitwarden/common/abstractions/account/avatar-update.service"; import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/abstractions/anonymousHub.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; -import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/abstractions/appId.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; -import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/abstractions/broadcaster.service"; -import { ConfigApiServiceAbstraction } from "@bitwarden/common/abstractions/config/config-api.service.abstraction"; -import { ConfigServiceAbstraction } from "@bitwarden/common/abstractions/config/config.service.abstraction"; -import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/abstractions/crypto.service"; -import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/abstractions/cryptoFunction.service"; -import { DeviceCryptoServiceAbstraction } from "@bitwarden/common/abstractions/device-crypto.service.abstraction"; -import { DevicesApiServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices-api.service.abstraction"; -import { EncryptService } from "@bitwarden/common/abstractions/encrypt.service"; -import { EnvironmentService as EnvironmentServiceAbstraction } from "@bitwarden/common/abstractions/environment.service"; +import { DevicesServiceAbstraction } from "@bitwarden/common/abstractions/devices/devices.service.abstraction"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service"; -import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/abstractions/file-upload/file-upload.service"; -import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "@bitwarden/common/abstractions/formValidationErrors.service"; -import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/abstractions/messaging.service"; import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service"; import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/abstractions/organization-domain/org-domain-api.service.abstraction"; import { @@ -28,22 +14,14 @@ import { OrgDomainServiceAbstraction, } from "@bitwarden/common/abstractions/organization-domain/org-domain.service.abstraction"; import { OrganizationUserService } from "@bitwarden/common/abstractions/organization-user/organization-user.service"; -import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/abstractions/platformUtils.service"; import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; import { SettingsService as SettingsServiceAbstraction } from "@bitwarden/common/abstractions/settings.service"; -import { StateService as StateServiceAbstraction } from "@bitwarden/common/abstractions/state.service"; -import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/abstractions/stateMigration.service"; -import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/abstractions/totp.service"; -import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/abstractions/userVerification/userVerification-api.service.abstraction"; -import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; -import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/abstractions/validation.service"; -import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; -import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service"; -import { CollectionService as CollectionServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/collection.service"; +import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { - InternalOrganizationService, + InternalOrganizationServiceAbstraction, OrganizationService as OrganizationServiceAbstraction, } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; @@ -52,7 +30,6 @@ import { PolicyService as PolicyServiceAbstraction, } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider.service"; -import { CollectionService } from "@bitwarden/common/admin-console/services/collection.service"; import { OrganizationApiService } from "@bitwarden/common/admin-console/services/organization/organization-api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/services/organization/organization.service"; import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; @@ -63,55 +40,78 @@ import { AccountService as AccountServiceAbstraction, InternalAccountService, } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-crypto.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service"; +import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction"; +import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; +import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; +import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; +import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; import { LoginService } from "@bitwarden/common/auth/services/login.service"; +import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; -import { StateFactory } from "@bitwarden/common/factories/stateFactory"; -import { flagEnabled } from "@bitwarden/common/misc/flags"; -import { Account } from "@bitwarden/common/models/domain/account"; -import { GlobalState } from "@bitwarden/common/models/domain/global-state"; +import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.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"; +import { EnvironmentService as EnvironmentServiceAbstraction } from "@bitwarden/common/platform/abstractions/environment.service"; +import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service"; +import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; +import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service"; +import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; +import { flagEnabled } from "@bitwarden/common/platform/misc/flags"; +import { Account } from "@bitwarden/common/platform/models/domain/account"; +import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; +import { 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 { CryptoService } from "@bitwarden/common/platform/services/crypto.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 { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; +import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; +import { StateService } from "@bitwarden/common/platform/services/state.service"; +import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; +import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; import { AvatarUpdateService } from "@bitwarden/common/services/account/avatar-update.service"; import { AnonymousHubService } from "@bitwarden/common/services/anonymousHub.service"; import { ApiService } from "@bitwarden/common/services/api.service"; -import { AppIdService } from "@bitwarden/common/services/appId.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; -import { ConfigApiService } from "@bitwarden/common/services/config/config-api.service"; -import { ConfigService } from "@bitwarden/common/services/config/config.service"; -import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service"; -import { CryptoService } from "@bitwarden/common/services/crypto.service"; -import { EncryptServiceImplementation } from "@bitwarden/common/services/cryptography/encrypt.service.implementation"; -import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/services/cryptography/multithread-encrypt.service.implementation"; -import { DeviceCryptoService } from "@bitwarden/common/services/device-crypto.service.implementation"; -import { DevicesApiServiceImplementation } from "@bitwarden/common/services/devices/devices-api.service.implementation"; -import { EnvironmentService } from "@bitwarden/common/services/environment.service"; +import { DevicesServiceImplementation } from "@bitwarden/common/services/devices/devices.service.implementation"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; -import { FileUploadService } from "@bitwarden/common/services/file-upload/file-upload.service"; -import { FormValidationErrorsService } from "@bitwarden/common/services/formValidationErrors.service"; import { NotificationsService } from "@bitwarden/common/services/notifications.service"; import { OrgDomainApiService } from "@bitwarden/common/services/organization-domain/org-domain-api.service"; import { OrgDomainService } from "@bitwarden/common/services/organization-domain/org-domain.service"; import { OrganizationUserServiceImplementation } from "@bitwarden/common/services/organization-user/organization-user.service.implementation"; import { SearchService } from "@bitwarden/common/services/search.service"; import { SettingsService } from "@bitwarden/common/services/settings.service"; -import { StateService } from "@bitwarden/common/services/state.service"; -import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service"; import { TotpService } from "@bitwarden/common/services/totp.service"; -import { ValidationService } from "@bitwarden/common/services/validation.service"; -import { VaultTimeoutService } from "@bitwarden/common/services/vaultTimeout/vaultTimeout.service"; -import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vaultTimeout/vaultTimeoutSettings.service"; -import { WebCryptoFunctionService } from "@bitwarden/common/services/webCryptoFunction.service"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; +import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service"; import { PasswordGenerationService, PasswordGenerationServiceAbstraction, @@ -120,21 +120,26 @@ import { UsernameGenerationService, UsernameGenerationServiceAbstraction, } from "@bitwarden/common/tools/generator/username"; +import { + PasswordStrengthService, + PasswordStrengthServiceAbstraction, +} from "@bitwarden/common/tools/password-strength"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; import { SendService as SendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService as CollectionServiceAbstraction } from "@bitwarden/common/vault/abstractions/collection.service"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; import { FolderService as FolderServiceAbstraction, InternalFolderService, } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; import { SyncNotifierService as SyncNotifierServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync-notifier.service.abstraction"; import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/services/collection.service"; import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service"; import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service"; import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; @@ -144,13 +149,20 @@ import { VaultExportService, VaultExportServiceAbstraction, } from "@bitwarden/exporter/vault-export"; +import { + ImportApiService, + ImportApiServiceAbstraction, + ImportService, + ImportServiceAbstraction, +} from "@bitwarden/importer"; +import { PasswordRepromptService } from "@bitwarden/vault"; import { AuthGuard } from "../auth/guards/auth.guard"; -import { LockGuard } from "../auth/guards/lock.guard"; import { UnauthGuard } from "../auth/guards/unauth.guard"; -import { PasswordRepromptService } from "../vault/services/password-reprompt.service"; +import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; +import { BroadcasterService } from "../platform/services/broadcaster.service"; +import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; -import { BroadcasterService } from "./broadcaster.service"; import { LOCALES_DIRECTORY, LOCKED_CALLBACK, @@ -172,8 +184,9 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; providers: [ AuthGuard, UnauthGuard, - LockGuard, ModalService, + PasswordRepromptService, + { provide: WINDOW, useValue: window }, { provide: LOCALE_ID, @@ -239,8 +252,10 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; TwoFactorServiceAbstraction, I18nServiceAbstraction, EncryptService, - PasswordGenerationServiceAbstraction, + PasswordStrengthServiceAbstraction, PolicyServiceAbstraction, + DeviceTrustCryptoServiceAbstraction, + AuthRequestCryptoServiceAbstraction, ], }, { @@ -263,7 +278,8 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; searchService: SearchServiceAbstraction, stateService: StateServiceAbstraction, encryptService: EncryptService, - fileUploadService: CipherFileUploadServiceAbstraction + fileUploadService: CipherFileUploadServiceAbstraction, + configService: ConfigServiceAbstraction ) => new CipherService( cryptoService, @@ -273,7 +289,8 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; searchService, stateService, encryptService, - fileUploadService + fileUploadService, + configService ), deps: [ CryptoServiceAbstraction, @@ -284,6 +301,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; StateServiceAbstraction, EncryptService, CipherFileUploadServiceAbstraction, + ConfigServiceAbstraction, ], }, { @@ -359,6 +377,11 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; DevicesApiServiceAbstraction, ], }, + { + provide: PasswordStrengthServiceAbstraction, + useClass: PasswordStrengthService, + deps: [], + }, { provide: PasswordGenerationServiceAbstraction, useClass: PasswordGenerationService, @@ -432,10 +455,11 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; TokenServiceAbstraction, PolicyServiceAbstraction, StateServiceAbstraction, + UserVerificationServiceAbstraction, ], }, { - provide: VaultTimeoutServiceAbstraction, + provide: VaultTimeoutService, useClass: VaultTimeoutService, deps: [ CipherServiceAbstraction, @@ -445,7 +469,6 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; PlatformUtilsServiceAbstraction, MessagingServiceAbstraction, SearchServiceAbstraction, - KeyConnectorServiceAbstraction, StateServiceAbstraction, AuthServiceAbstraction, VaultTimeoutSettingsServiceAbstraction, @@ -453,6 +476,10 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; LOGOUT_CALLBACK, ], }, + { + provide: VaultTimeoutServiceAbstraction, + useExisting: VaultTimeoutService, + }, { provide: StateServiceAbstraction, useClass: StateService, @@ -461,15 +488,26 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; SECURE_STORAGE, MEMORY_STORAGE, LogService, - StateMigrationServiceAbstraction, STATE_FACTORY, STATE_SERVICE_USE_CACHE, ], }, { - provide: StateMigrationServiceAbstraction, - useClass: StateMigrationService, - deps: [AbstractStorageService, SECURE_STORAGE, STATE_FACTORY], + provide: ImportApiServiceAbstraction, + useClass: ImportApiService, + deps: [ApiServiceAbstraction], + }, + { + provide: ImportServiceAbstraction, + useClass: ImportService, + deps: [ + CipherServiceAbstraction, + FolderServiceAbstraction, + ImportApiServiceAbstraction, + I18nServiceAbstraction, + CollectionServiceAbstraction, + CryptoServiceAbstraction, + ], }, { provide: VaultExportServiceAbstraction, @@ -553,8 +591,6 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; LogService, OrganizationServiceAbstraction, CryptoFunctionServiceAbstraction, - SyncNotifierServiceAbstraction, - MessagingServiceAbstraction, LOGOUT_CALLBACK, ], }, @@ -562,19 +598,19 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; provide: UserVerificationServiceAbstraction, useClass: UserVerificationService, deps: [ + StateServiceAbstraction, CryptoServiceAbstraction, I18nServiceAbstraction, UserVerificationApiServiceAbstraction, ], }, - { provide: PasswordRepromptServiceAbstraction, useClass: PasswordRepromptService }, { provide: OrganizationServiceAbstraction, useClass: OrganizationService, deps: [StateServiceAbstraction], }, { - provide: InternalOrganizationService, + provide: InternalOrganizationServiceAbstraction, useExisting: OrganizationServiceAbstraction, }, { @@ -582,6 +618,17 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; useClass: OrganizationUserServiceImplementation, deps: [ApiServiceAbstraction], }, + { + provide: PasswordResetEnrollmentServiceAbstraction, + useClass: PasswordResetEnrollmentServiceImplementation, + deps: [ + OrganizationApiServiceAbstraction, + StateServiceAbstraction, + CryptoServiceAbstraction, + OrganizationUserService, + I18nServiceAbstraction, + ], + }, { provide: ProviderServiceAbstraction, useClass: ProviderService, @@ -619,19 +666,24 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; useClass: SyncNotifierService, }, { - provide: ConfigServiceAbstraction, + provide: ConfigService, useClass: ConfigService, deps: [ StateServiceAbstraction, ConfigApiServiceAbstraction, AuthServiceAbstraction, EnvironmentServiceAbstraction, + LogService, ], }, + { + provide: ConfigServiceAbstraction, + useExisting: ConfigService, + }, { provide: ConfigApiServiceAbstraction, useClass: ConfigApiService, - deps: [ApiServiceAbstraction], + deps: [ApiServiceAbstraction, AuthServiceAbstraction], }, { provide: AnonymousHubServiceAbstraction, @@ -668,8 +720,13 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; deps: [ApiServiceAbstraction], }, { - provide: DeviceCryptoServiceAbstraction, - useClass: DeviceCryptoService, + provide: DevicesServiceAbstraction, + useClass: DevicesServiceImplementation, + deps: [DevicesApiServiceAbstraction], + }, + { + provide: DeviceTrustCryptoServiceAbstraction, + useClass: DeviceTrustCryptoService, deps: [ CryptoFunctionServiceAbstraction, CryptoServiceAbstraction, @@ -677,8 +734,15 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; StateServiceAbstraction, AppIdServiceAbstraction, DevicesApiServiceAbstraction, + I18nServiceAbstraction, + PlatformUtilsServiceAbstraction, ], }, + { + provide: AuthRequestCryptoServiceAbstraction, + useClass: AuthRequestCryptoServiceImplementation, + deps: [CryptoServiceAbstraction], + }, ], }) export class JslibServicesModule {} diff --git a/libs/angular/src/services/modal.service.ts b/libs/angular/src/services/modal.service.ts index 18afcbbea84..ba461764ba8 100644 --- a/libs/angular/src/services/modal.service.ts +++ b/libs/angular/src/services/modal.service.ts @@ -21,6 +21,9 @@ export class ModalConfig { replaceTopModal?: boolean; } +/** + * @deprecated Use the Component Library's `DialogService` instead. + */ @Injectable() export class ModalService { protected modalList: ComponentRef[] = []; @@ -50,7 +53,7 @@ export class ModalService { } /** - * @deprecated Use `dialogService.open` (in web) or `modalService.open` (in desktop/browser) instead. + * @deprecated Use `dialogService.open` instead. * If replacing an existing call to this method, also remove any `@ViewChild` and `` associated with the * existing usage. */ diff --git a/libs/angular/src/services/theming/theming.service.ts b/libs/angular/src/services/theming/theming.service.ts index fbae277cabb..91760e07fec 100644 --- a/libs/angular/src/services/theming/theming.service.ts +++ b/libs/angular/src/services/theming/theming.service.ts @@ -2,8 +2,8 @@ import { DOCUMENT } from "@angular/common"; import { Inject, Injectable } from "@angular/core"; import { BehaviorSubject, filter, fromEvent, Observable } from "rxjs"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { ThemeType } from "@bitwarden/common/enums"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { WINDOW } from "../injection-tokens"; diff --git a/libs/angular/src/shared/components/password-strength/password-strength.component.ts b/libs/angular/src/shared/components/password-strength/password-strength.component.ts index 77a2e3f8098..75dd687efe4 100644 --- a/libs/angular/src/shared/components/password-strength/password-strength.component.ts +++ b/libs/angular/src/shared/components/password-strength/password-strength.component.ts @@ -1,7 +1,7 @@ import { Component, EventEmitter, Input, OnChanges, Output } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; export interface PasswordColorText { color: string; @@ -59,7 +59,7 @@ export class PasswordStrengthComponent implements OnChanges { constructor( private i18nService: I18nService, - private passwordGenerationService: PasswordGenerationServiceAbstraction + private passwordStrengthService: PasswordStrengthServiceAbstraction ) {} ngOnChanges(): void { @@ -96,7 +96,7 @@ export class PasswordStrengthComponent implements OnChanges { clearTimeout(this.masterPasswordStrengthTimeout); } - const strengthResult = this.passwordGenerationService.passwordStrength( + const strengthResult = this.passwordStrengthService.getPasswordStrength( masterPassword, this.email, this.name?.trim().toLowerCase().split(" ") diff --git a/libs/angular/src/tools/export/components/export-scope-callout.component.ts b/libs/angular/src/tools/export/components/export-scope-callout.component.ts index d7c15657f5e..a97db1079a7 100644 --- a/libs/angular/src/tools/export/components/export-scope-callout.component.ts +++ b/libs/angular/src/tools/export/components/export-scope-callout.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnInit } from "@angular/core"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @Component({ selector: "app-export-scope-callout", @@ -30,12 +30,12 @@ export class ExportScopeCalloutComponent implements OnInit { this.organizationId != null ? { title: "exportingOrganizationVaultTitle", - description: "exportingOrganizationVaultDescription", + description: "exportingOrganizationVaultDesc", scopeIdentifier: this.organizationService.get(this.organizationId).name, } : { title: "exportingPersonalVaultTitle", - description: "exportingPersonalVaultDescription", + description: "exportingIndividualVaultDescription", scopeIdentifier: await this.stateService.getEmail(), }; this.show = true; diff --git a/libs/angular/src/tools/export/components/export.component.ts b/libs/angular/src/tools/export/components/export.component.ts index a1a8b583b28..9d782d2822d 100644 --- a/libs/angular/src/tools/export/components/export.component.ts +++ b/libs/angular/src/tools/export/components/export.component.ts @@ -1,27 +1,30 @@ import { Directive, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core"; import { UntypedFormBuilder, Validators } from "@angular/forms"; -import { merge, takeUntil, Subject, startWith } from "rxjs"; +import { merge, startWith, Subject, takeUntil } from "rxjs"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { UserVerificationService } from "@bitwarden/common/abstractions/userVerification/userVerification.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { EncryptedExportType, EventType } from "@bitwarden/common/enums"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; import { VaultExportServiceAbstraction } from "@bitwarden/exporter/vault-export"; -import { DialogServiceAbstraction, SimpleDialogType } from "../../../services/dialog"; - @Directive() export class ExportComponent implements OnInit, OnDestroy { @Output() onSaved = new EventEmitter(); formPromise: Promise; - disabledByPolicy = false; + private _disabledByPolicy = false; + + protected get disabledByPolicy(): boolean { + return this._disabledByPolicy; + } exportForm = this.formBuilder.group({ format: ["json"], @@ -51,7 +54,7 @@ export class ExportComponent implements OnInit, OnDestroy { private userVerificationService: UserVerificationService, private formBuilder: UntypedFormBuilder, protected fileDownloadService: FileDownloadService, - protected dialogService: DialogServiceAbstraction + protected dialogService: DialogService ) {} async ngOnInit() { @@ -59,11 +62,12 @@ export class ExportComponent implements OnInit, OnDestroy { .policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport) .pipe(takeUntil(this.destroy$)) .subscribe((policyAppliesToActiveUser) => { - this.disabledByPolicy = policyAppliesToActiveUser; + this._disabledByPolicy = policyAppliesToActiveUser; + if (this.disabledByPolicy) { + this.exportForm.disable(); + } }); - await this.checkExportDisabled(); - merge( this.exportForm.get("format").valueChanges, this.exportForm.get("fileEncryptionType").valueChanges @@ -77,12 +81,6 @@ export class ExportComponent implements OnInit, OnDestroy { this.destroy$.next(); } - async checkExportDisabled() { - if (this.disabledByPolicy) { - this.exportForm.disable(); - } - } - get encryptedFormat() { return this.format === "encrypted_json"; } @@ -136,14 +134,14 @@ export class ExportComponent implements OnInit, OnDestroy { " " + this.i18nService.t("encExportAccountWarningDesc"), acceptButtonText: { key: "exportVault" }, - type: SimpleDialogType.WARNING, + type: "warning", }); } else { return await this.dialogService.openSimpleDialog({ title: { key: "confirmVaultExport" }, content: { key: "exportWarningDesc" }, acceptButtonText: { key: "exportVault" }, - type: SimpleDialogType.WARNING, + type: "warning", }); } } diff --git a/libs/angular/src/tools/generator/components/generator.component.ts b/libs/angular/src/tools/generator/components/generator.component.ts index e5e48a7f8eb..b0a5d13915f 100644 --- a/libs/angular/src/tools/generator/components/generator.component.ts +++ b/libs/angular/src/tools/generator/components/generator.component.ts @@ -2,14 +2,21 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; import { first } from "rxjs/operators"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options"; import { EmailForwarderOptions } from "@bitwarden/common/models/domain/email-forwarder-options"; -import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; -import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { GeneratorOptions } from "@bitwarden/common/tools/generator/generator-options"; +import { + PasswordGenerationServiceAbstraction, + PasswordGeneratorOptions, +} from "@bitwarden/common/tools/generator/password"; +import { + UsernameGenerationServiceAbstraction, + UsernameGeneratorOptions, +} from "@bitwarden/common/tools/generator/username"; @Directive() export class GeneratorComponent implements OnInit { @@ -24,8 +31,8 @@ export class GeneratorComponent implements OnInit { subaddressOptions: any[]; catchallOptions: any[]; forwardOptions: EmailForwarderOptions[]; - usernameOptions: any = {}; - passwordOptions: any = {}; + usernameOptions: UsernameGeneratorOptions = {}; + passwordOptions: PasswordGeneratorOptions = {}; username = "-"; password = "-"; showOptions = false; @@ -118,7 +125,7 @@ export class GeneratorComponent implements OnInit { } async typeChanged() { - await this.stateService.setGeneratorOptions({ type: this.type }); + await this.stateService.setGeneratorOptions({ type: this.type } as GeneratorOptions); if (this.regenerateWithoutButtonPress()) { await this.regenerate(); } @@ -237,11 +244,12 @@ export class GeneratorComponent implements OnInit { private async initForwardOptions() { this.forwardOptions = [ - { name: "AnonAddy", value: "anonaddy", validForSelfHosted: true }, + { name: "addy.io", value: "anonaddy", validForSelfHosted: true }, { name: "DuckDuckGo", value: "duckduckgo", validForSelfHosted: false }, { name: "Fastmail", value: "fastmail", validForSelfHosted: true }, { name: "Firefox Relay", value: "firefoxrelay", validForSelfHosted: false }, { name: "SimpleLogin", value: "simplelogin", validForSelfHosted: true }, + { name: "Forward Email", value: "forwardemail", validForSelfHosted: true }, ]; this.usernameOptions = await this.usernameGenerationService.getOptions(); diff --git a/libs/angular/src/tools/generator/components/password-generator-history.component.ts b/libs/angular/src/tools/generator/components/password-generator-history.component.ts index 1c2e93928ab..5c0da324db1 100644 --- a/libs/angular/src/tools/generator/components/password-generator-history.component.ts +++ b/libs/angular/src/tools/generator/components/password-generator-history.component.ts @@ -1,7 +1,7 @@ import { Directive, OnInit } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { GeneratedPasswordHistory, PasswordGenerationServiceAbstraction, diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index 747df5780cb..32d14db471c 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -1,16 +1,18 @@ import { DatePipe } from "@angular/common"; import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { Subject, takeUntil } from "rxjs"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { EncArrayBuffer } from "@bitwarden/common/models/domain/enc-array-buffer"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view"; @@ -18,8 +20,24 @@ import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { DialogService } from "@bitwarden/components"; + +// Value = hours +enum DatePreset { + OneHour = 1, + OneDay = 24, + TwoDays = 48, + ThreeDays = 72, + SevenDays = 168, + ThirtyDays = 720, + Custom = 0, + Never = null, +} -import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog"; +interface DatePresetSelectOption { + name: string; + value: DatePreset; +} @Directive() export class AddEditComponent implements OnInit, OnDestroy { @@ -30,14 +48,26 @@ export class AddEditComponent implements OnInit, OnDestroy { @Output() onDeletedSend = new EventEmitter(); @Output() onCancelled = new EventEmitter(); + deletionDatePresets: DatePresetSelectOption[] = [ + { name: this.i18nService.t("oneHour"), value: DatePreset.OneHour }, + { name: this.i18nService.t("oneDay"), value: DatePreset.OneDay }, + { name: this.i18nService.t("days", "2"), value: DatePreset.TwoDays }, + { name: this.i18nService.t("days", "3"), value: DatePreset.ThreeDays }, + { name: this.i18nService.t("days", "7"), value: DatePreset.SevenDays }, + { name: this.i18nService.t("days", "30"), value: DatePreset.ThirtyDays }, + { name: this.i18nService.t("custom"), value: DatePreset.Custom }, + ]; + + expirationDatePresets: DatePresetSelectOption[] = [ + { name: this.i18nService.t("never"), value: DatePreset.Never }, + ...this.deletionDatePresets, + ]; + copyLink = false; disableSend = false; disableHideEmail = false; send: SendView; - deletionDate: string; - expirationDate: string; hasPassword: boolean; - password: string; showPassword = false; formPromise: Promise; deletePromise: Promise; @@ -52,6 +82,27 @@ export class AddEditComponent implements OnInit, OnDestroy { private sendLinkBaseUrl: string; private destroy$ = new Subject(); + protected formGroup = this.formBuilder.group({ + name: ["", Validators.required], + text: [], + textHidden: [false], + fileContents: [], + file: [null, Validators.required], + link: [], + copyLink: false, + maxAccessCount: [], + accessCount: [], + password: [], + notes: [], + hideEmail: false, + disabled: false, + type: [], + defaultExpirationDateTime: [], + defaultDeletionDateTime: ["", Validators.required], + selectedDeletionDatePreset: [DatePreset.SevenDays, Validators.required], + selectedExpirationDatePreset: [], + }); + constructor( protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, @@ -60,10 +111,11 @@ export class AddEditComponent implements OnInit, OnDestroy { protected sendService: SendService, protected messagingService: MessagingService, protected policyService: PolicyService, - private logService: LogService, + protected logService: LogService, protected stateService: StateService, protected sendApiService: SendApiService, - protected dialogService: DialogServiceAbstraction + protected dialogService: DialogService, + protected formBuilder: FormBuilder ) { this.typeOptions = [ { name: i18nService.t("sendTypeFile"), value: SendType.File }, @@ -73,7 +125,7 @@ export class AddEditComponent implements OnInit, OnDestroy { } get link(): string { - if (this.send.id != null && this.send.accessId != null) { + if (this.send != null && this.send.id != null && this.send.accessId != null) { return this.sendLinkBaseUrl + this.send.accessId + "/" + this.send.urlB64Key; } return null; @@ -93,13 +145,44 @@ export class AddEditComponent implements OnInit, OnDestroy { .pipe(takeUntil(this.destroy$)) .subscribe((policyAppliesToActiveUser) => { this.disableSend = policyAppliesToActiveUser; + if (this.disableSend) { + this.formGroup.disable(); + } }); this.policyService .policyAppliesToActiveUser$(PolicyType.SendOptions, (p) => p.data.disableHideEmail) .pipe(takeUntil(this.destroy$)) .subscribe((policyAppliesToActiveUser) => { - this.disableHideEmail = policyAppliesToActiveUser; + if ( + (this.disableHideEmail = policyAppliesToActiveUser) && + !this.formGroup.controls.hideEmail.value + ) { + this.formGroup.controls.hideEmail.disable(); + } else { + this.formGroup.controls.hideEmail.enable(); + } + }); + + this.formGroup.controls.type.valueChanges.subscribe((val) => { + this.type = val; + this.typeChanged(); + }); + + this.formGroup.controls.selectedDeletionDatePreset.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((datePreset) => { + datePreset === DatePreset.Custom + ? this.formGroup.controls.defaultDeletionDateTime.enable() + : this.formGroup.controls.defaultDeletionDateTime.disable(); + }); + + this.formGroup.controls.hideEmail.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((val) => { + if (!val && this.disableHideEmail) { + this.formGroup.controls.hideEmail.disable(); + } }); await this.load(); @@ -118,29 +201,30 @@ export class AddEditComponent implements OnInit, OnDestroy { return this.i18nService.t(this.editMode ? "editSend" : "createSend"); } - setDates(event: { deletionDate: string; expirationDate: string }) { - this.deletionDate = event.deletionDate; - this.expirationDate = event.expirationDate; - } - async load() { this.canAccessPremium = await this.stateService.getCanAccessPremium(); this.emailVerified = await this.stateService.getEmailVerified(); - if (!this.canAccessPremium || !this.emailVerified) { - this.type = SendType.Text; - } + this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File; if (this.send == null) { if (this.editMode) { const send = this.loadSend(); this.send = await send.decrypt(); + this.type = this.send.type; + this.updateFormValues(); } else { this.send = new SendView(); - this.send.type = this.type == null ? SendType.File : this.type; + this.send.type = this.type; this.send.file = new SendFileView(); this.send.text = new SendTextView(); this.send.deletionDate = new Date(); this.send.deletionDate.setDate(this.send.deletionDate.getDate() + 7); + this.formGroup.controls.type.patchValue(this.send.type); + + this.formGroup.patchValue({ + selectedDeletionDatePreset: DatePreset.SevenDays, + selectedExpirationDatePreset: DatePreset.Never, + }); } } @@ -148,6 +232,8 @@ export class AddEditComponent implements OnInit, OnDestroy { } async submit(): Promise { + this.formGroup.markAllAsTouched(); + if (this.disableSend) { this.platformUtilsService.showToast( "error", @@ -157,7 +243,18 @@ export class AddEditComponent implements OnInit, OnDestroy { return false; } - if (this.send.name == null || this.send.name === "") { + this.send.name = this.formGroup.controls.name.value; + this.send.text.text = this.formGroup.controls.text.value; + this.send.text.hidden = this.formGroup.controls.textHidden.value; + this.send.maxAccessCount = this.formGroup.controls.maxAccessCount.value; + this.send.accessCount = this.formGroup.controls.accessCount.value; + this.send.password = this.formGroup.controls.password.value; + this.send.notes = this.formGroup.controls.notes.value; + this.send.hideEmail = this.formGroup.controls.hideEmail.value; + this.send.disabled = this.formGroup.controls.disabled.value; + this.send.type = this.type; + + if (Utils.isNullOrWhitespace(this.send.name)) { this.platformUtilsService.showToast( "error", this.i18nService.t("errorOccurred"), @@ -167,7 +264,7 @@ export class AddEditComponent implements OnInit, OnDestroy { } let file: File = null; - if (this.send.type === SendType.File && !this.editMode) { + if (this.type === SendType.File && !this.editMode) { const fileEl = document.getElementById("file") as HTMLInputElement; const files = fileEl.files; if (files == null || files.length === 0) { @@ -191,8 +288,8 @@ export class AddEditComponent implements OnInit, OnDestroy { } } - if (this.password != null && this.password.trim() === "") { - this.password = null; + if (Utils.isNullOrWhitespace(this.send.password)) { + this.send.password = null; } this.formPromise = this.encryptSend(file).then(async (encSend) => { @@ -205,7 +302,7 @@ export class AddEditComponent implements OnInit, OnDestroy { this.send.accessId = encSend[0].accessId; } this.onSavedSend.emit(this.send); - if (this.copyLink && this.link != null) { + if (this.formGroup.controls.copyLink.value && this.link != null) { await this.handleCopyLinkToClipboard(); return; } @@ -228,7 +325,7 @@ export class AddEditComponent implements OnInit, OnDestroy { return Promise.resolve(this.platformUtilsService.copyToClipboard(link)); } - async delete(): Promise { + protected async delete(): Promise { if (this.deletePromise != null) { return false; } @@ -236,7 +333,7 @@ export class AddEditComponent implements OnInit, OnDestroy { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "deleteSend" }, content: { key: "deleteSendConfirmation" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!confirmed) { @@ -258,7 +355,7 @@ export class AddEditComponent implements OnInit, OnDestroy { } typeChanged() { - if (this.send.type === SendType.File && !this.alertShown) { + if (this.type === SendType.File && !this.alertShown) { if (!this.canAccessPremium) { this.alertShown = true; this.messagingService.send("premiumRequired"); @@ -267,6 +364,9 @@ export class AddEditComponent implements OnInit, OnDestroy { this.messagingService.send("emailVerificationRequired"); } } + this.type === SendType.Text || this.editMode + ? this.formGroup.controls.file.disable() + : this.formGroup.controls.file.enable(); } toggleOptions() { @@ -278,17 +378,18 @@ export class AddEditComponent implements OnInit, OnDestroy { } protected async encryptSend(file: File): Promise<[Send, EncArrayBuffer]> { - const sendData = await this.sendService.encrypt(this.send, file, this.password, null); + const sendData = await this.sendService.encrypt(this.send, file, this.send.password, null); // Parse dates try { - sendData[0].deletionDate = this.deletionDate == null ? null : new Date(this.deletionDate); + sendData[0].deletionDate = + this.formattedDeletionDate == null ? null : new Date(this.formattedDeletionDate); } catch { sendData[0].deletionDate = null; } try { sendData[0].expirationDate = - this.expirationDate == null ? null : new Date(this.expirationDate); + this.formattedExpirationDate == null ? null : new Date(this.formattedExpirationDate); } catch { sendData[0].expirationDate = null; } @@ -300,6 +401,38 @@ export class AddEditComponent implements OnInit, OnDestroy { this.showPassword = !this.showPassword; document.getElementById("password").focus(); } + + updateFormValues() { + this.formGroup.patchValue({ + name: this.send?.name ?? "", + text: this.send?.text?.text ?? "", + textHidden: this.send?.text?.hidden ?? false, + link: this.link ?? "", + maxAccessCount: this.send?.maxAccessCount, + accessCount: this.send?.accessCount ?? 0, + notes: this.send?.notes ?? "", + hideEmail: this.send?.hideEmail ?? false, + disabled: this.send?.disabled ?? false, + type: this.send.type ?? this.type, + password: null, + + selectedDeletionDatePreset: this.editMode ? DatePreset.Custom : DatePreset.SevenDays, + selectedExpirationDatePreset: this.editMode ? DatePreset.Custom : DatePreset.Never, + defaultExpirationDateTime: + this.send.expirationDate != null + ? this.datePipe.transform(new Date(this.send.expirationDate), "yyyy-MM-ddTHH:mm") + : null, + defaultDeletionDateTime: this.datePipe.transform( + new Date(this.send.deletionDate), + "yyyy-MM-ddTHH:mm" + ), + }); + + if (this.send.hideEmail) { + this.formGroup.controls.hideEmail.enable(); + } + } + private async handleCopyLinkToClipboard() { const copySuccess = await this.copyLinkToClipboard(this.link); if (copySuccess ?? true) { @@ -314,10 +447,52 @@ export class AddEditComponent implements OnInit, OnDestroy { content: { key: this.editMode ? "editedSend" : "createdSend" }, acceptButtonText: { key: "ok" }, cancelButtonText: null, - type: SimpleDialogType.SUCCESS, + type: "success", }); await this.copyLinkToClipboard(this.link); } } + + clearExpiration() { + this.formGroup.controls.defaultExpirationDateTime.patchValue(null); + } + + get formattedExpirationDate(): string { + switch (this.formGroup.controls.selectedExpirationDatePreset.value as DatePreset) { + case DatePreset.Never: + return null; + case DatePreset.Custom: + if (!this.formGroup.controls.defaultExpirationDateTime.value) { + return null; + } + return this.formGroup.controls.defaultExpirationDateTime.value; + default: { + const now = new Date(); + const milliseconds = now.setTime( + now.getTime() + + (this.formGroup.controls.selectedExpirationDatePreset.value as number) * 60 * 60 * 1000 + ); + return new Date(milliseconds).toString(); + } + } + } + + get formattedDeletionDate(): string { + switch (this.formGroup.controls.selectedDeletionDatePreset.value as DatePreset) { + case DatePreset.Never: + this.formGroup.controls.selectedDeletionDatePreset.patchValue(DatePreset.SevenDays); + return this.formattedDeletionDate; + case DatePreset.Custom: + return this.formGroup.controls.defaultDeletionDateTime.value; + default: { + const now = new Date(); + const milliseconds = now.setTime( + now.getTime() + + (this.formGroup.controls.selectedDeletionDatePreset.value as number) * 60 * 60 * 1000 + ); + return new Date(milliseconds).toString(); + } + } + } } diff --git a/libs/angular/src/tools/send/efflux-dates.component.ts b/libs/angular/src/tools/send/efflux-dates.component.ts deleted file mode 100644 index 16389789309..00000000000 --- a/libs/angular/src/tools/send/efflux-dates.component.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { DatePipe } from "@angular/common"; -import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; -import { UntypedFormControl, UntypedFormGroup } from "@angular/forms"; - -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; - -// Different BrowserPath = different controls. -enum BrowserPath { - // Native datetime-locale. - // We are happy. - Default = "default", - - // Native date and time inputs, but no datetime-locale. - // We use individual date and time inputs and create a datetime programatically on submit. - Firefox = "firefox", - - // No native date, time, or datetime-locale inputs. - // We use a polyfill for dates and a dropdown for times. - Safari = "safari", -} - -enum DateField { - DeletionDate = "deletion", - ExpirationDate = "expiration", -} - -// Value = hours -enum DatePreset { - OneHour = 1, - OneDay = 24, - TwoDays = 48, - ThreeDays = 72, - SevenDays = 168, - ThirtyDays = 720, - Custom = 0, - Never = null, -} - -// TimeOption is used for the dropdown implementation of custom times -// twelveHour = displayed time; twentyFourHour = time used in logic -interface TimeOption { - twelveHour: string; - twentyFourHour: string; -} - -@Directive() -export class EffluxDatesComponent implements OnInit { - @Input() readonly initialDeletionDate: Date; - @Input() readonly initialExpirationDate: Date; - @Input() readonly editMode: boolean; - @Input() readonly disabled: boolean; - - @Output() datesChanged = new EventEmitter<{ deletionDate: string; expirationDate: string }>(); - - get browserPath(): BrowserPath { - if (this.platformUtilsService.isFirefox()) { - return BrowserPath.Firefox; - } else if (this.platformUtilsService.isSafari()) { - return BrowserPath.Safari; - } - return BrowserPath.Default; - } - - datesForm = new UntypedFormGroup({ - selectedDeletionDatePreset: new UntypedFormControl(), - selectedExpirationDatePreset: new UntypedFormControl(), - defaultDeletionDateTime: new UntypedFormControl(), - defaultExpirationDateTime: new UntypedFormControl(), - fallbackDeletionDate: new UntypedFormControl(), - fallbackDeletionTime: new UntypedFormControl(), - fallbackExpirationDate: new UntypedFormControl(), - fallbackExpirationTime: new UntypedFormControl(), - }); - - deletionDatePresets: any[] = [ - { name: this.i18nService.t("oneHour"), value: DatePreset.OneHour }, - { name: this.i18nService.t("oneDay"), value: DatePreset.OneDay }, - { name: this.i18nService.t("days", "2"), value: DatePreset.TwoDays }, - { name: this.i18nService.t("days", "3"), value: DatePreset.ThreeDays }, - { name: this.i18nService.t("days", "7"), value: DatePreset.SevenDays }, - { name: this.i18nService.t("days", "30"), value: DatePreset.ThirtyDays }, - { name: this.i18nService.t("custom"), value: DatePreset.Custom }, - ]; - - expirationDatePresets: any[] = [ - { name: this.i18nService.t("never"), value: DatePreset.Never }, - ].concat([...this.deletionDatePresets]); - - get selectedDeletionDatePreset(): UntypedFormControl { - return this.datesForm.get("selectedDeletionDatePreset") as UntypedFormControl; - } - - get selectedExpirationDatePreset(): UntypedFormControl { - return this.datesForm.get("selectedExpirationDatePreset") as UntypedFormControl; - } - - get defaultDeletionDateTime(): UntypedFormControl { - return this.datesForm.get("defaultDeletionDateTime") as UntypedFormControl; - } - - get defaultExpirationDateTime(): UntypedFormControl { - return this.datesForm.get("defaultExpirationDateTime") as UntypedFormControl; - } - - get fallbackDeletionDate(): UntypedFormControl { - return this.datesForm.get("fallbackDeletionDate") as UntypedFormControl; - } - - get fallbackDeletionTime(): UntypedFormControl { - return this.datesForm.get("fallbackDeletionTime") as UntypedFormControl; - } - - get fallbackExpirationDate(): UntypedFormControl { - return this.datesForm.get("fallbackExpirationDate") as UntypedFormControl; - } - - get fallbackExpirationTime(): UntypedFormControl { - return this.datesForm.get("fallbackExpirationTime") as UntypedFormControl; - } - - // Should be able to call these at any time and compute a submitable value - get formattedDeletionDate(): string { - switch (this.selectedDeletionDatePreset.value as DatePreset) { - case DatePreset.Never: - this.selectedDeletionDatePreset.setValue(DatePreset.SevenDays); - return this.formattedDeletionDate; - case DatePreset.Custom: - switch (this.browserPath) { - case BrowserPath.Safari: - case BrowserPath.Firefox: - return this.fallbackDeletionDate.value + "T" + this.fallbackDeletionTime.value; - default: - return this.defaultDeletionDateTime.value; - } - default: { - const now = new Date(); - const milliseconds = now.setTime( - now.getTime() + (this.selectedDeletionDatePreset.value as number) * 60 * 60 * 1000 - ); - return new Date(milliseconds).toString(); - } - } - } - - get formattedExpirationDate(): string { - switch (this.selectedExpirationDatePreset.value as DatePreset) { - case DatePreset.Never: - return null; - case DatePreset.Custom: - switch (this.browserPath) { - case BrowserPath.Safari: - case BrowserPath.Firefox: - if ( - (!this.fallbackExpirationDate.value || !this.fallbackExpirationTime.value) && - this.editMode - ) { - return null; - } - return this.fallbackExpirationDate.value + "T" + this.fallbackExpirationTime.value; - default: - if (!this.defaultExpirationDateTime.value) { - return null; - } - return this.defaultExpirationDateTime.value; - } - default: { - const now = new Date(); - const milliseconds = now.setTime( - now.getTime() + (this.selectedExpirationDatePreset.value as number) * 60 * 60 * 1000 - ); - return new Date(milliseconds).toString(); - } - } - } - // - - get safariDeletionTimePresetOptions() { - return this.safariTimePresetOptions(DateField.DeletionDate); - } - - get safariExpirationTimePresetOptions() { - return this.safariTimePresetOptions(DateField.ExpirationDate); - } - - private get nextWeek(): Date { - const nextWeek = new Date(); - nextWeek.setDate(nextWeek.getDate() + 7); - return nextWeek; - } - - constructor( - protected i18nService: I18nService, - protected platformUtilsService: PlatformUtilsService, - protected datePipe: DatePipe - ) {} - - ngOnInit(): void { - this.setInitialFormValues(); - this.emitDates(); - this.datesForm.valueChanges.subscribe(() => { - this.emitDates(); - }); - } - - onDeletionDatePresetSelect(value: DatePreset) { - this.selectedDeletionDatePreset.setValue(value); - } - - clearExpiration() { - switch (this.browserPath) { - case BrowserPath.Safari: - case BrowserPath.Firefox: - this.fallbackExpirationDate.setValue(null); - this.fallbackExpirationTime.setValue(null); - break; - case BrowserPath.Default: - this.defaultExpirationDateTime.setValue(null); - break; - } - } - - protected emitDates() { - this.datesChanged.emit({ - deletionDate: this.formattedDeletionDate, - expirationDate: this.formattedExpirationDate, - }); - } - - protected setInitialFormValues() { - if (this.editMode) { - this.selectedDeletionDatePreset.setValue(DatePreset.Custom); - this.selectedExpirationDatePreset.setValue(DatePreset.Custom); - switch (this.browserPath) { - case BrowserPath.Safari: - case BrowserPath.Firefox: - this.fallbackDeletionDate.setValue(this.initialDeletionDate.toISOString().slice(0, 10)); - this.fallbackDeletionTime.setValue(this.initialDeletionDate.toTimeString().slice(0, 5)); - if (this.initialExpirationDate != null) { - this.fallbackExpirationDate.setValue( - this.initialExpirationDate.toISOString().slice(0, 10) - ); - this.fallbackExpirationTime.setValue( - this.initialExpirationDate.toTimeString().slice(0, 5) - ); - } - break; - case BrowserPath.Default: - if (this.initialExpirationDate) { - this.defaultExpirationDateTime.setValue( - this.datePipe.transform(new Date(this.initialExpirationDate), "yyyy-MM-ddTHH:mm") - ); - } - this.defaultDeletionDateTime.setValue( - this.datePipe.transform(new Date(this.initialDeletionDate), "yyyy-MM-ddTHH:mm") - ); - break; - } - } else { - this.selectedDeletionDatePreset.setValue(DatePreset.SevenDays); - this.selectedExpirationDatePreset.setValue(DatePreset.Never); - - switch (this.browserPath) { - case BrowserPath.Safari: - this.fallbackDeletionDate.setValue(this.nextWeek.toISOString().slice(0, 10)); - this.fallbackDeletionTime.setValue( - this.safariTimePresetOptions(DateField.DeletionDate)[1].twentyFourHour - ); - break; - default: - break; - } - } - } - - protected safariTimePresetOptions(field: DateField): TimeOption[] { - // init individual arrays for major sort groups - const noon: TimeOption[] = []; - const midnight: TimeOption[] = []; - const ams: TimeOption[] = []; - const pms: TimeOption[] = []; - - // determine minute skip (5 min, 10 min, 15 min, etc.) - const minuteIncrementer = 15; - - // loop through each hour on a 12 hour system - for (let h = 1; h <= 12; h++) { - // loop through each minute in the hour using the skip to increment - for (let m = 0; m < 60; m += minuteIncrementer) { - // init the final strings that will be added to the lists - let hour = h.toString(); - let minutes = m.toString(); - - // add prepending 0s to single digit hours/minutes - if (h < 10) { - hour = "0" + hour; - } - if (m < 10) { - minutes = "0" + minutes; - } - - // build time strings and push to relevant sort groups - if (h === 12) { - const midnightOption: TimeOption = { - twelveHour: `${hour}:${minutes} AM`, - twentyFourHour: `00:${minutes}`, - }; - midnight.push(midnightOption); - - const noonOption: TimeOption = { - twelveHour: `${hour}:${minutes} PM`, - twentyFourHour: `${hour}:${minutes}`, - }; - noon.push(noonOption); - } else { - const amOption: TimeOption = { - twelveHour: `${hour}:${minutes} AM`, - twentyFourHour: `${hour}:${minutes}`, - }; - ams.push(amOption); - - const pmOption: TimeOption = { - twelveHour: `${hour}:${minutes} PM`, - twentyFourHour: `${h + 12}:${minutes}`, - }; - pms.push(pmOption); - } - } - } - - // bring all the arrays together in the right order - const validTimes = [...midnight, ...ams, ...noon, ...pms]; - - // determine if an unsupported value already exists on the send & add that to the top of the option list - // example: if the Send was created with a different client - if (field === DateField.ExpirationDate && this.initialExpirationDate != null && this.editMode) { - const previousValue: TimeOption = { - twelveHour: this.datePipe.transform(this.initialExpirationDate, "hh:mm a"), - twentyFourHour: this.datePipe.transform(this.initialExpirationDate, "HH:mm"), - }; - return [previousValue, { twelveHour: null, twentyFourHour: null }, ...validTimes]; - } else if ( - field === DateField.DeletionDate && - this.initialDeletionDate != null && - this.editMode - ) { - const previousValue: TimeOption = { - twelveHour: this.datePipe.transform(this.initialDeletionDate, "hh:mm a"), - twentyFourHour: this.datePipe.transform(this.initialDeletionDate, "HH:mm"), - }; - return [previousValue, ...validTimes]; - } else { - return [{ twelveHour: null, twentyFourHour: null }, ...validTimes]; - } - } -} diff --git a/libs/angular/src/tools/send/send.component.ts b/libs/angular/src/tools/send/send.component.ts index bec82d7c69a..4669f5ccf4d 100644 --- a/libs/angular/src/tools/send/send.component.ts +++ b/libs/angular/src/tools/send/send.component.ts @@ -1,19 +1,18 @@ import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Subject, takeUntil } from "rxjs"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; - -import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog"; +import { DialogService } from "@bitwarden/components"; @Directive() export class SendComponent implements OnInit, OnDestroy { @@ -25,11 +24,9 @@ export class SendComponent implements OnInit, OnDestroy { expired = false; type: SendType = null; sends: SendView[] = []; - filteredSends: SendView[] = []; searchText: string; selectedType: SendType; selectedAll: boolean; - searchPlaceholder: string; filter: (cipher: SendView) => boolean; searchPending = false; hasSearched = false; // search() function called - returns true if text qualifies for search @@ -41,6 +38,15 @@ export class SendComponent implements OnInit, OnDestroy { private searchTimeout: any; private destroy$ = new Subject(); + private _filteredSends: SendView[]; + + get filteredSends(): SendView[] { + return this._filteredSends; + } + + set filteredSends(filteredSends: SendView[]) { + this._filteredSends = filteredSends; + } constructor( protected sendService: SendService, @@ -52,7 +58,7 @@ export class SendComponent implements OnInit, OnDestroy { protected policyService: PolicyService, private logService: LogService, protected sendApiService: SendApiService, - protected dialogService: DialogServiceAbstraction + protected dialogService: DialogService ) {} async ngOnInit() { @@ -132,7 +138,7 @@ export class SendComponent implements OnInit, OnDestroy { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "removePassword" }, content: { key: "removePasswordConfirmation" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!confirmed) { @@ -163,7 +169,7 @@ export class SendComponent implements OnInit, OnDestroy { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "deleteSend" }, content: { key: "deleteSendConfirmation" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!confirmed) { diff --git a/libs/angular/src/validators/inputsFieldMatch.validator.ts b/libs/angular/src/validators/inputsFieldMatch.validator.ts index bf9779d9dc0..f360b5e5ee5 100644 --- a/libs/angular/src/validators/inputsFieldMatch.validator.ts +++ b/libs/angular/src/validators/inputsFieldMatch.validator.ts @@ -1,6 +1,6 @@ import { AbstractControl, UntypedFormGroup, ValidatorFn } from "@angular/forms"; -import { FormGroupControls } from "@bitwarden/common/abstractions/formValidationErrors.service"; +import { FormGroupControls } from "../platform/abstractions/form-validation-errors.service"; export class InputsFieldMatch { //check to ensure two fields do not have the same value diff --git a/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts b/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts index 74e34921259..6672c45138a 100644 --- a/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts +++ b/libs/angular/src/vault/abstractions/deprecated-vault-filter.service.ts @@ -1,7 +1,7 @@ import { Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { DynamicTreeNode } from "../vault-filter/models/dynamic-tree-node.model"; diff --git a/libs/angular/src/vault/components/add-edit-custom-fields.component.ts b/libs/angular/src/vault/components/add-edit-custom-fields.component.ts index 063fd73838b..7d696e99a1d 100644 --- a/libs/angular/src/vault/components/add-edit-custom-fields.component.ts +++ b/libs/angular/src/vault/components/add-edit-custom-fields.component.ts @@ -2,9 +2,9 @@ import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop"; import { Directive, Input, OnChanges, SimpleChanges } from "@angular/core"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { EventType, FieldType } from "@bitwarden/common/enums"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 2d53d6a7d98..fb065587bf9 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -3,12 +3,6 @@ import { Observable, Subject, takeUntil, concatMap } from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service"; import { isMember, OrganizationService, @@ -16,25 +10,30 @@ import { import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; import { EventType, SecureNoteType, UriMatchType } from "@bitwarden/common/enums"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; - -import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog"; +import { DialogService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; @Directive() export class AddEditComponent implements OnInit, OnDestroy { @@ -101,7 +100,7 @@ export class AddEditComponent implements OnInit, OnDestroy { protected passwordRepromptService: PasswordRepromptService, private organizationService: OrganizationService, protected sendApiService: SendApiService, - protected dialogService: DialogServiceAbstraction + protected dialogService: DialogService ) { this.typeOptions = [ { name: i18nService.t("typeLogin"), value: CipherType.Login }, @@ -226,7 +225,9 @@ export class AddEditComponent implements OnInit, OnDestroy { if (this.cipher == null) { if (this.editMode) { const cipher = await this.loadCipher(); - this.cipher = await cipher.decrypt(); + this.cipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher) + ); // Adjust Cipher Name if Cloning if (this.cloneMode) { @@ -273,6 +274,9 @@ export class AddEditComponent implements OnInit, OnDestroy { } this.previousCipherId = this.cipherId; this.reprompt = this.cipher.reprompt !== CipherRepromptType.None; + if (this.reprompt) { + this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[2].value; + } } async submit(): Promise { @@ -368,6 +372,10 @@ export class AddEditComponent implements OnInit, OnDestroy { } } + onCardNumberChange(): void { + this.cipher.card.brand = CardView.getCardBrandByPatterns(this.cipher.card.number); + } + getCardExpMonthDisplay() { return this.cardExpMonthOptions.find((x) => x.value == this.cipher.card.expMonth)?.name; } @@ -398,7 +406,7 @@ export class AddEditComponent implements OnInit, OnDestroy { content: { key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation", }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!confirmed) { @@ -429,16 +437,6 @@ export class AddEditComponent implements OnInit, OnDestroy { return false; } - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "restoreItem" }, - content: { key: "restoreItemConfirmation" }, - type: SimpleDialogType.WARNING, - }); - - if (!confirmed) { - return false; - } - try { this.restorePromise = this.restoreCipher(); await this.restorePromise; @@ -457,7 +455,7 @@ export class AddEditComponent implements OnInit, OnDestroy { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "overwriteUsername" }, content: { key: "overwriteUsernameConfirmation" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!confirmed) { @@ -474,7 +472,7 @@ export class AddEditComponent implements OnInit, OnDestroy { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "overwritePassword" }, content: { key: "overwritePasswordConfirmation" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!confirmed) { @@ -577,8 +575,10 @@ export class AddEditComponent implements OnInit, OnDestroy { this.reprompt = !this.reprompt; if (this.reprompt) { this.cipher.reprompt = CipherRepromptType.Password; + this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[2].value; } else { this.cipher.reprompt = CipherRepromptType.None; + this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[0].value; } } @@ -596,9 +596,11 @@ export class AddEditComponent implements OnInit, OnDestroy { } protected saveCipher(cipher: Cipher) { + const isNotClone = this.editMode && !this.cloneMode; + const orgAdmin = this.organization?.isAdmin; return this.cipher.id == null - ? this.cipherService.createWithServer(cipher) - : this.cipherService.updateWithServer(cipher); + ? this.cipherService.createWithServer(cipher, orgAdmin) + : this.cipherService.updateWithServer(cipher, orgAdmin, isNotClone); } protected deleteCipher() { diff --git a/libs/angular/src/vault/components/attachments.component.ts b/libs/angular/src/vault/components/attachments.component.ts index d137375b188..e1974e2b7d5 100644 --- a/libs/angular/src/vault/components/attachments.component.ts +++ b/libs/angular/src/vault/components/attachments.component.ts @@ -1,20 +1,19 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { EncArrayBuffer } from "@bitwarden/common/models/domain/enc-array-buffer"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; - -import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog"; +import { DialogService } from "@bitwarden/components"; @Directive() export class AttachmentsComponent implements OnInit { @@ -25,7 +24,6 @@ export class AttachmentsComponent implements OnInit { cipher: CipherView; cipherDomain: Cipher; - hasUpdatedKey: boolean; canAccessAttachments: boolean; formPromise: Promise; deletePromises: { [id: string]: Promise } = {}; @@ -43,7 +41,7 @@ export class AttachmentsComponent implements OnInit { protected logService: LogService, protected stateService: StateService, protected fileDownloadService: FileDownloadService, - protected dialogService: DialogServiceAbstraction + protected dialogService: DialogService ) {} async ngOnInit() { @@ -51,15 +49,6 @@ export class AttachmentsComponent implements OnInit { } async submit() { - if (!this.hasUpdatedKey) { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("updateKey") - ); - return; - } - const fileEl = document.getElementById("file") as HTMLInputElement; const files = fileEl.files; if (files == null || files.length === 0) { @@ -84,7 +73,9 @@ export class AttachmentsComponent implements OnInit { try { this.formPromise = this.saveCipherAttachment(files[0]); this.cipherDomain = await this.formPromise; - this.cipher = await this.cipherDomain.decrypt(); + this.cipher = await this.cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain) + ); this.platformUtilsService.showToast("success", null, this.i18nService.t("attachmentSaved")); this.onUploadedAttachment.emit(); } catch (e) { @@ -106,7 +97,7 @@ export class AttachmentsComponent implements OnInit { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "deleteAttachment" }, content: { key: "deleteAttachmentConfirmation" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!confirmed) { @@ -190,9 +181,10 @@ export class AttachmentsComponent implements OnInit { protected async init() { this.cipherDomain = await this.loadCipher(); - this.cipher = await this.cipherDomain.decrypt(); + this.cipher = await this.cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain) + ); - this.hasUpdatedKey = await this.cryptoService.hasEncKey(); const canAccessPremium = await this.stateService.getCanAccessPremium(); this.canAccessAttachments = canAccessPremium || this.cipher.organizationId != null; @@ -201,25 +193,12 @@ export class AttachmentsComponent implements OnInit { title: { key: "premiumRequired" }, content: { key: "premiumRequiredDesc" }, acceptButtonText: { key: "learnMore" }, - type: SimpleDialogType.SUCCESS, + type: "success", }); if (confirmed) { this.platformUtilsService.launchUri("https://vault.bitwarden.com/#/?premium=purchase"); } - } else if (!this.hasUpdatedKey) { - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "featureUnavailable" }, - content: { key: "updateKey" }, - acceptButtonText: { key: "learnMore" }, - type: SimpleDialogType.WARNING, - }); - - if (confirmed) { - this.platformUtilsService.launchUri( - "https://bitwarden.com/help/account-encryption-key/#rotate-your-encryption-key" - ); - } } } @@ -254,7 +233,9 @@ export class AttachmentsComponent implements OnInit { decBuf, admin ); - this.cipher = await this.cipherDomain.decrypt(); + this.cipher = await this.cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain) + ); // 3. Delete old this.deletePromises[attachment.id] = this.deleteCipherAttachment(attachment.id); diff --git a/libs/angular/src/vault/components/folder-add-edit.component.ts b/libs/angular/src/vault/components/folder-add-edit.component.ts index 768a2dc519e..194d927ad1c 100644 --- a/libs/angular/src/vault/components/folder-add-edit.component.ts +++ b/libs/angular/src/vault/components/folder-add-edit.component.ts @@ -1,13 +1,13 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Validators, FormBuilder } from "@angular/forms"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; - -import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog"; +import { DialogService } from "@bitwarden/components"; @Directive() export class FolderAddEditComponent implements OnInit { @@ -22,13 +22,18 @@ export class FolderAddEditComponent implements OnInit { deletePromise: Promise; protected componentName = ""; + formGroup = this.formBuilder.group({ + name: ["", [Validators.required]], + }); + constructor( protected folderService: FolderService, protected folderApiService: FolderApiServiceAbstraction, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, - private logService: LogService, - protected dialogService: DialogServiceAbstraction + protected logService: LogService, + protected dialogService: DialogService, + protected formBuilder: FormBuilder ) {} async ngOnInit() { @@ -36,6 +41,7 @@ export class FolderAddEditComponent implements OnInit { } async submit(): Promise { + this.folder.name = this.formGroup.controls.name.value; if (this.folder.name == null || this.folder.name === "") { this.platformUtilsService.showToast( "error", @@ -67,7 +73,7 @@ export class FolderAddEditComponent implements OnInit { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "deleteFolder" }, content: { key: "deleteFolderConfirmation" }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!confirmed) { @@ -97,5 +103,6 @@ export class FolderAddEditComponent implements OnInit { } else { this.title = this.i18nService.t("addFolder"); } + this.formGroup.controls.name.setValue(this.folder.name); } } diff --git a/libs/angular/src/vault/components/icon.component.ts b/libs/angular/src/vault/components/icon.component.ts index 7fac39a6386..84a72ca0c3c 100644 --- a/libs/angular/src/vault/components/icon.component.ts +++ b/libs/angular/src/vault/components/icon.component.ts @@ -8,9 +8,9 @@ import { Observable, } from "rxjs"; -import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; diff --git a/libs/angular/src/vault/components/password-history.component.ts b/libs/angular/src/vault/components/password-history.component.ts index d9d9654c8ef..3a25b6930a8 100644 --- a/libs/angular/src/vault/components/password-history.component.ts +++ b/libs/angular/src/vault/components/password-history.component.ts @@ -1,7 +1,7 @@ import { Directive, OnInit } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view"; @@ -33,7 +33,9 @@ export class PasswordHistoryComponent implements OnInit { protected async init() { const cipher = await this.cipherService.get(this.cipherId); - const decCipher = await cipher.decrypt(); + const decCipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher) + ); this.history = decCipher.passwordHistory == null ? [] : decCipher.passwordHistory; } } diff --git a/libs/angular/src/vault/components/password-reprompt.component.ts b/libs/angular/src/vault/components/password-reprompt.component.ts deleted file mode 100644 index ca0d6fb7696..00000000000 --- a/libs/angular/src/vault/components/password-reprompt.component.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Directive } from "@angular/core"; - -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; - -import { ModalRef } from "../../components/modal/modal.ref"; - -/** - * Used to verify the user's Master Password for the "Master Password Re-prompt" feature only. - * See UserVerificationComponent for any other situation where you need to verify the user's identity. - */ -@Directive() -export class PasswordRepromptComponent { - showPassword = false; - masterPassword = ""; - - constructor( - private modalRef: ModalRef, - private cryptoService: CryptoService, - private platformUtilsService: PlatformUtilsService, - private i18nService: I18nService - ) {} - - togglePassword() { - this.showPassword = !this.showPassword; - } - - async submit() { - if (!(await this.cryptoService.compareAndUpdateKeyHash(this.masterPassword, null))) { - this.platformUtilsService.showToast( - "error", - this.i18nService.t("errorOccurred"), - this.i18nService.t("invalidMasterPassword") - ); - return; - } - - this.modalRef.close(true); - } -} diff --git a/libs/angular/src/vault/components/premium.component.ts b/libs/angular/src/vault/components/premium.component.ts index a44c30187db..7e5deaf2cf6 100644 --- a/libs/angular/src/vault/components/premium.component.ts +++ b/libs/angular/src/vault/components/premium.component.ts @@ -1,18 +1,19 @@ import { Directive, OnInit } from "@angular/core"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; - -import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { DialogService } from "@bitwarden/components"; @Directive() export class PremiumComponent implements OnInit { isPremium = false; price = 10; refreshPromise: Promise; + cloudWebVaultUrl: string; constructor( protected i18nService: I18nService, @@ -20,8 +21,11 @@ export class PremiumComponent implements OnInit { protected apiService: ApiService, private logService: LogService, protected stateService: StateService, - protected dialogService: DialogServiceAbstraction - ) {} + protected dialogService: DialogService, + private environmentService: EnvironmentService + ) { + this.cloudWebVaultUrl = this.environmentService.getCloudWebVaultUrl(); + } async ngOnInit() { this.isPremium = await this.stateService.getCanAccessPremium(); @@ -42,11 +46,11 @@ export class PremiumComponent implements OnInit { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "premiumPurchase" }, content: { key: "premiumPurchaseAlert" }, - type: SimpleDialogType.INFO, + type: "info", }); if (confirmed) { - this.platformUtilsService.launchUri("https://vault.bitwarden.com/#/?premium=purchase"); + this.platformUtilsService.launchUri(`${this.cloudWebVaultUrl}/#/?premium=purchase`); } } @@ -54,11 +58,11 @@ export class PremiumComponent implements OnInit { const confirmed = await this.dialogService.openSimpleDialog({ title: { key: "premiumManage" }, content: { key: "premiumManageAlert" }, - type: SimpleDialogType.INFO, + type: "info", }); if (confirmed) { - this.platformUtilsService.launchUri("https://vault.bitwarden.com/#/?premium=manage"); + this.platformUtilsService.launchUri(`${this.cloudWebVaultUrl}/#/?premium=manage`); } } } diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 2dbb6d65e5a..781034aa9f3 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -12,30 +12,29 @@ import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; -import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.service"; -import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; -import { FileDownloadService } from "@bitwarden/common/abstractions/fileDownload/fileDownload.service"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; import { TotpService } from "@bitwarden/common/abstractions/totp.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { EventType, FieldType } from "@bitwarden/common/enums"; -import { EncArrayBuffer } from "@bitwarden/common/models/domain/enc-array-buffer"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; - -import { DialogServiceAbstraction, SimpleDialogType } from "../../services/dialog"; +import { DialogService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; const BroadcasterSubscriptionId = "ViewComponent"; @@ -87,7 +86,7 @@ export class ViewComponent implements OnDestroy, OnInit { private logService: LogService, protected stateService: StateService, protected fileDownloadService: FileDownloadService, - protected dialogService: DialogServiceAbstraction + protected dialogService: DialogService ) {} ngOnInit() { @@ -114,7 +113,9 @@ export class ViewComponent implements OnDestroy, OnInit { this.cleanUp(); const cipher = await this.cipherService.get(this.cipherId); - this.cipher = await cipher.decrypt(); + this.cipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher) + ); this.canAccessPremium = await this.stateService.getCanAccessPremium(); this.showPremiumRequiredTotp = this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp; @@ -182,7 +183,7 @@ export class ViewComponent implements OnDestroy, OnInit { content: { key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation", }, - type: SimpleDialogType.WARNING, + type: "warning", }); if (!confirmed) { @@ -209,16 +210,6 @@ export class ViewComponent implements OnDestroy, OnInit { return false; } - const confirmed = await this.dialogService.openSimpleDialog({ - title: { key: "restoreItem" }, - content: { key: "restoreItemConfirmation" }, - type: SimpleDialogType.WARNING, - }); - - if (!confirmed) { - return false; - } - try { await this.restoreCipher(); this.platformUtilsService.showToast("success", null, this.i18nService.t("restoredItem")); @@ -316,16 +307,16 @@ export class ViewComponent implements OnDestroy, OnInit { this.platformUtilsService.launchUri(uri.launchUri); } - async copy(value: string, typeI18nKey: string, aType: string) { + async copy(value: string, typeI18nKey: string, aType: string): Promise { if (value == null) { - return; + return false; } if ( this.passwordRepromptService.protectedFields().includes(aType) && !(await this.promptPassword()) ) { - return; + return false; } const copyOptions = this.win != null ? { window: this.win } : null; @@ -343,6 +334,8 @@ export class ViewComponent implements OnDestroy, OnInit { } else if (aType === "H_Field") { this.eventCollectionService.collect(EventType.Cipher_ClientCopiedHiddenField, this.cipherId); } + + return true; } setTextDataOnDrag(event: DragEvent, data: string) { diff --git a/libs/angular/src/vault/services/password-reprompt.service.ts b/libs/angular/src/vault/services/password-reprompt.service.ts deleted file mode 100644 index 0bd945001c1..00000000000 --- a/libs/angular/src/vault/services/password-reprompt.service.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Injectable } from "@angular/core"; - -import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; -import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@bitwarden/common/vault/abstractions/password-reprompt.service"; - -import { ModalService } from "../../services/modal.service"; -import { PasswordRepromptComponent } from "../components/password-reprompt.component"; - -/** - * Used to verify the user's Master Password for the "Master Password Re-prompt" feature only. - * See UserVerificationService for any other situation where you need to verify the user's identity. - */ -@Injectable() -export class PasswordRepromptService implements PasswordRepromptServiceAbstraction { - protected component = PasswordRepromptComponent; - - constructor( - private modalService: ModalService, - private keyConnectorService: KeyConnectorService - ) {} - - protectedFields() { - return ["TOTP", "Password", "H_Field", "Card Number", "Security Code"]; - } - - async showPasswordPrompt() { - if (!(await this.enabled())) { - return true; - } - - const ref = this.modalService.open(this.component, { allowMultipleModals: true }); - - if (ref == null) { - return false; - } - - const result = await ref.onClosedPromise(); - return result === true; - } - - async enabled() { - return !(await this.keyConnectorService.getUsesKeyConnector()); - } -} diff --git a/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts b/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts index 1d90f43ba2d..f752f9a9a9a 100644 --- a/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts @@ -1,7 +1,7 @@ import { Directive, EventEmitter, Input, Output } from "@angular/core"; -import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; import { ITreeNodeObject } from "@bitwarden/common/models/domain/tree-node"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { DynamicTreeNode } from "../models/dynamic-tree-node.model"; import { TopLevelTreeNode } from "../models/top-level-tree-node.model"; diff --git a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts index 1d4b5cf8bbb..7c37436492a 100644 --- a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts @@ -2,8 +2,8 @@ import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { firstValueFrom, Observable } from "rxjs"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; import { ITreeNodeObject } from "@bitwarden/common/models/domain/tree-node"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { DeprecatedVaultFilterService } from "../../abstractions/deprecated-vault-filter.service"; diff --git a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts index 7bb216983df..b4fb8f62e0e 100644 --- a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts +++ b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts @@ -1,8 +1,6 @@ import { Injectable } from "@angular/core"; import { firstValueFrom, from, mergeMap, Observable } from "rxjs"; -import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { CollectionService } from "@bitwarden/common/admin-console/abstractions/collection.service"; import { isMember, OrganizationService, @@ -10,11 +8,13 @@ import { 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 { CollectionView } from "@bitwarden/common/admin-console/models/view/collection.view"; import { ServiceUtils } from "@bitwarden/common/misc/serviceUtils"; import { TreeNode } from "@bitwarden/common/models/domain/tree-node"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "../../abstractions/deprecated-vault-filter.service"; diff --git a/libs/auth/README.md b/libs/auth/README.md new file mode 100644 index 00000000000..59e6384fae4 --- /dev/null +++ b/libs/auth/README.md @@ -0,0 +1,3 @@ +# Auth + +This lib represents the public API of the Auth team at Bitwarden. Modules are imported using `@bitwarden/auth`. diff --git a/libs/auth/jest.config.js b/libs/auth/jest.config.js new file mode 100644 index 00000000000..3db83db07a4 --- /dev/null +++ b/libs/auth/jest.config.js @@ -0,0 +1,16 @@ +const { pathsToModuleNameMapper } = require("ts-jest"); + +const { compilerOptions } = require("../shared/tsconfig.libs"); + +const sharedConfig = require("../../libs/shared/jest.config.angular"); + +/** @type {import('jest').Config} */ +module.exports = { + ...sharedConfig, + displayName: "libs/auth tests", + preset: "jest-preset-angular", + setupFilesAfterEnv: ["/test.setup.ts"], + moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { + prefix: "/", + }), +}; diff --git a/libs/auth/package.json b/libs/auth/package.json new file mode 100644 index 00000000000..52c1be63f81 --- /dev/null +++ b/libs/auth/package.json @@ -0,0 +1,20 @@ +{ + "name": "@bitwarden/auth", + "version": "0.0.0", + "description": "Common code used across Bitwarden JavaScript projects.", + "keywords": [ + "bitwarden" + ], + "author": "Bitwarden Inc.", + "homepage": "https://bitwarden.com", + "repository": { + "type": "git", + "url": "https://github.com/bitwarden/clients" + }, + "license": "GPL-3.0", + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && tsc", + "build:watch": "npm run clean && tsc -watch" + } +} diff --git a/libs/auth/src/components/fingerprint-dialog.component.html b/libs/auth/src/components/fingerprint-dialog.component.html new file mode 100644 index 00000000000..709cc4747d2 --- /dev/null +++ b/libs/auth/src/components/fingerprint-dialog.component.html @@ -0,0 +1,23 @@ + + + {{ "yourAccountsFingerprint" | i18n }}: + + {{ data.fingerprint.join("-") }} + + + + {{ "learnMore" | i18n }} + + + + + diff --git a/libs/auth/src/components/fingerprint-dialog.component.ts b/libs/auth/src/components/fingerprint-dialog.component.ts new file mode 100644 index 00000000000..2a7b3e10997 --- /dev/null +++ b/libs/auth/src/components/fingerprint-dialog.component.ts @@ -0,0 +1,22 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, Inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; + +export type FingerprintDialogData = { + fingerprint: string[]; +}; + +@Component({ + templateUrl: "fingerprint-dialog.component.html", + standalone: true, + imports: [JslibModule, ButtonModule, DialogModule], +}) +export class FingerprintDialogComponent { + constructor(@Inject(DIALOG_DATA) protected data: FingerprintDialogData) {} + + static open(dialogService: DialogService, data: FingerprintDialogData) { + return dialogService.open(FingerprintDialogComponent, { data }); + } +} diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts new file mode 100644 index 00000000000..7b0dfec05f9 --- /dev/null +++ b/libs/auth/src/index.ts @@ -0,0 +1,2 @@ +export * from "./components/fingerprint-dialog.component"; +export * from "./password-callout/password-callout.component"; diff --git a/libs/auth/src/password-callout/password-callout.component.html b/libs/auth/src/password-callout/password-callout.component.html new file mode 100644 index 00000000000..c0f77938475 --- /dev/null +++ b/libs/auth/src/password-callout/password-callout.component.html @@ -0,0 +1,24 @@ + + {{ message | i18n }} + +
      +
    • + {{ "policyInEffectMinComplexity" | i18n : getPasswordScoreAlertDisplay() }} +
    • +
    • + {{ "policyInEffectMinLength" | i18n : policy?.minLength.toString() }} +
    • +
    • + {{ "policyInEffectUppercase" | i18n }} +
    • +
    • + {{ "policyInEffectLowercase" | i18n }} +
    • +
    • + {{ "policyInEffectNumbers" | i18n }} +
    • +
    • + {{ "policyInEffectSpecial" | i18n : "!@#$%^&*" }} +
    • +
    +
    diff --git a/libs/auth/src/password-callout/password-callout.component.ts b/libs/auth/src/password-callout/password-callout.component.ts new file mode 100644 index 00000000000..b56acb37c1d --- /dev/null +++ b/libs/auth/src/password-callout/password-callout.component.ts @@ -0,0 +1,36 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CalloutModule } from "@bitwarden/components"; + +@Component({ + selector: "auth-password-callout", + templateUrl: "password-callout.component.html", + standalone: true, + imports: [CommonModule, JslibModule, CalloutModule], +}) +export class PasswordCalloutComponent { + @Input() message = "masterPasswordPolicyInEffect"; + @Input() policy: MasterPasswordPolicyOptions; + + constructor(private i18nService: I18nService) {} + + getPasswordScoreAlertDisplay() { + let str: string; + switch (this.policy.minComplexity) { + case 4: + str = this.i18nService.t("strong"); + break; + case 3: + str = this.i18nService.t("good"); + break; + default: + str = this.i18nService.t("weak"); + break; + } + return str + " (" + this.policy.minComplexity + ")"; + } +} diff --git a/libs/auth/src/password-callout/password-callout.stories.ts b/libs/auth/src/password-callout/password-callout.stories.ts new file mode 100644 index 00000000000..ce5b698b2e8 --- /dev/null +++ b/libs/auth/src/password-callout/password-callout.stories.ts @@ -0,0 +1,52 @@ +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; + +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { I18nMockService } from "@bitwarden/components"; + +import { PasswordCalloutComponent } from "./password-callout.component"; + +export default { + title: "Auth/Password Callout", + component: PasswordCalloutComponent, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + masterPasswordPolicyInEffect: + "One or more organization policies require your master password to meet the following requirements:", + policyInEffectMinLength: "Minimum length of __$1__", + policyInEffectMinComplexity: "Minimum complexity score of __$1__", + policyInEffectUppercase: "Contain one or more uppercase characters", + policyInEffectLowercase: "Contain one or more lowercase characters", + policyInEffectNumbers: "Contain one or more numbers", + policyInEffectSpecial: + "Contain one or more of the following special characters $CHARS$", + weak: "Weak", + good: "Good", + strong: "Strong", + }); + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + policy: { + minComplexity: 3, + minLength: 10, + requireUpper: true, + requireLower: true, + requireNumbers: true, + requireSpecial: true, + } as MasterPasswordPolicyOptions, + }, +}; diff --git a/libs/auth/test.setup.ts b/libs/auth/test.setup.ts new file mode 100644 index 00000000000..6be6e7b8dd1 --- /dev/null +++ b/libs/auth/test.setup.ts @@ -0,0 +1,28 @@ +import { webcrypto } from "crypto"; +import "jest-preset-angular/setup-jest"; + +Object.defineProperty(window, "CSS", { value: null }); +Object.defineProperty(window, "getComputedStyle", { + value: () => { + return { + display: "none", + appearance: ["-webkit-appearance"], + }; + }, +}); + +Object.defineProperty(document, "doctype", { + value: "", +}); +Object.defineProperty(document.body.style, "transform", { + value: () => { + return { + enumerable: true, + configurable: true, + }; + }, +}); + +Object.defineProperty(window, "crypto", { + value: webcrypto, +}); diff --git a/libs/auth/tsconfig.json b/libs/auth/tsconfig.json new file mode 100644 index 00000000000..6004a56fb55 --- /dev/null +++ b/libs/auth/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../shared/tsconfig.libs", + "include": ["src", "spec"], + "exclude": ["node_modules", "dist"] +} diff --git a/libs/auth/tsconfig.spec.json b/libs/auth/tsconfig.spec.json new file mode 100644 index 00000000000..de184bd7608 --- /dev/null +++ b/libs/auth/tsconfig.spec.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "files": ["./test.setup.ts"] +} diff --git a/libs/common/custom-matchers.d.ts b/libs/common/custom-matchers.d.ts new file mode 100644 index 00000000000..214529ff021 --- /dev/null +++ b/libs/common/custom-matchers.d.ts @@ -0,0 +1,15 @@ +import type { CustomMatchers } from "./test.setup"; + +// This declares the types for our custom matchers so that they're recognised by Typescript +// This file must also be included in the TS compilation (via the tsconfig.json "include" property) to be recognised by +// vscode + +/* eslint-disable */ +declare global { + namespace jest { + interface Expect extends CustomMatchers {} + interface Matchers extends CustomMatchers {} + interface InverseAsymmetricMatchers extends CustomMatchers {} + } +} +/* eslint-enable */ diff --git a/libs/common/spec/matchers/to-equal-buffer.ts b/libs/common/spec/matchers/to-equal-buffer.ts index 796d88cd82b..e723d85a571 100644 --- a/libs/common/spec/matchers/to-equal-buffer.ts +++ b/libs/common/spec/matchers/to-equal-buffer.ts @@ -5,14 +5,11 @@ * (and optionally, the expected value) and then call toEqual() on the resulting Uint8Arrays. */ export const toEqualBuffer: jest.CustomMatcher = function ( - received: ArrayBuffer, - expected: Uint8Array | ArrayBuffer + received: ArrayBuffer | Uint8Array, + expected: ArrayBuffer | Uint8Array ) { received = new Uint8Array(received); - - if (expected instanceof ArrayBuffer) { - expected = new Uint8Array(expected); - } + expected = new Uint8Array(expected); if (this.equals(received, expected)) { return { diff --git a/libs/common/spec/utils.ts b/libs/common/spec/utils.ts index 21b0f581e0c..4013d3ac36f 100644 --- a/libs/common/spec/utils.ts +++ b/libs/common/spec/utils.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line no-restricted-imports import { Substitute, Arg } from "@fluffy-spoon/substitute"; -import { EncString } from "@bitwarden/common/models/domain/enc-string"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; function newGuid() { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 2273b290191..c51ffd3d3cb 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -1,5 +1,4 @@ import { OrganizationConnectionType } from "../admin-console/enums"; -import { CollectionRequest } from "../admin-console/models/request/collection.request"; import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request"; import { OrganizationSponsorshipRedeemRequest } from "../admin-console/models/request/organization/organization-sponsorship-redeem.request"; import { OrganizationConnectionRequest } from "../admin-console/models/request/organization-connection.request"; @@ -14,10 +13,6 @@ import { ProviderUserConfirmRequest } from "../admin-console/models/request/prov import { ProviderUserInviteRequest } from "../admin-console/models/request/provider/provider-user-invite.request"; import { ProviderUserUpdateRequest } from "../admin-console/models/request/provider/provider-user-update.request"; import { SelectionReadOnlyRequest } from "../admin-console/models/request/selection-read-only.request"; -import { - CollectionAccessDetailsResponse, - CollectionResponse, -} from "../admin-console/models/response/collection.response"; import { OrganizationConnectionConfigApis, OrganizationConnectionResponse, @@ -135,9 +130,14 @@ import { CipherCreateRequest } from "../vault/models/request/cipher-create.reque import { CipherPartialRequest } from "../vault/models/request/cipher-partial.request"; import { CipherShareRequest } from "../vault/models/request/cipher-share.request"; import { CipherRequest } from "../vault/models/request/cipher.request"; +import { CollectionRequest } from "../vault/models/request/collection.request"; import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response"; import { AttachmentResponse } from "../vault/models/response/attachment.response"; import { CipherResponse } from "../vault/models/response/cipher.response"; +import { + CollectionAccessDetailsResponse, + CollectionResponse, +} from "../vault/models/response/collection.response"; import { SyncResponse } from "../vault/models/response/sync.response"; /** @@ -200,6 +200,7 @@ export abstract class ApiService { postConvertToKeyConnector: () => Promise; //passwordless postAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise; + postAdminAuthRequest: (request: PasswordlessCreateAuthRequest) => Promise; getAuthResponse: (id: string, accessCode: string) => Promise; getAuthRequest: (id: string) => Promise; putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise; @@ -243,6 +244,9 @@ export abstract class ApiService { putRestoreManyCiphers: ( request: CipherBulkRestoreRequest ) => Promise>; + putRestoreManyCiphersAdmin: ( + request: CipherBulkRestoreRequest + ) => Promise>; /** * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. @@ -520,7 +524,7 @@ export abstract class ApiService { ) => Promise; postResendSponsorshipOffer: (sponsoringOrgId: string) => Promise; - getUserKeyFromKeyConnector: (keyConnectorUrl: string) => Promise; + getMasterKeyFromKeyConnector: (keyConnectorUrl: string) => Promise; postUserKeyToKeyConnector: ( keyConnectorUrl: string, request: KeyConnectorUserKeyRequest diff --git a/libs/common/src/abstractions/config/config.service.abstraction.ts b/libs/common/src/abstractions/config/config.service.abstraction.ts deleted file mode 100644 index dd9d435ed3a..00000000000 --- a/libs/common/src/abstractions/config/config.service.abstraction.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Observable } from "rxjs"; - -import { FeatureFlag } from "../../enums/feature-flag.enum"; - -import { ServerConfig } from "./server-config"; - -export abstract class ConfigServiceAbstraction { - serverConfig$: Observable; - fetchServerConfig: () => Promise; - getFeatureFlagBool: (key: FeatureFlag, defaultValue?: boolean) => Promise; - getFeatureFlagString: (key: FeatureFlag, defaultValue?: string) => Promise; - getFeatureFlagNumber: (key: FeatureFlag, defaultValue?: number) => Promise; -} diff --git a/libs/common/src/abstractions/crypto.service.ts b/libs/common/src/abstractions/crypto.service.ts deleted file mode 100644 index 19a0017737d..00000000000 --- a/libs/common/src/abstractions/crypto.service.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { ProfileOrganizationResponse } from "../admin-console/models/response/profile-organization.response"; -import { ProfileProviderOrganizationResponse } from "../admin-console/models/response/profile-provider-organization.response"; -import { ProfileProviderResponse } from "../admin-console/models/response/profile-provider.response"; -import { KdfConfig } from "../auth/models/domain/kdf-config"; -import { HashPurpose, KdfType, KeySuffixOptions } from "../enums"; -import { EncArrayBuffer } from "../models/domain/enc-array-buffer"; -import { EncString } from "../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; - -export abstract class CryptoService { - setKey: (key: SymmetricCryptoKey) => Promise; - setKeyHash: (keyHash: string) => Promise; - setEncKey: (encKey: string) => Promise; - setEncPrivateKey: (encPrivateKey: string) => Promise; - setOrgKeys: ( - orgs: ProfileOrganizationResponse[], - providerOrgs: ProfileProviderOrganizationResponse[] - ) => Promise; - setProviderKeys: (orgs: ProfileProviderResponse[]) => Promise; - getKey: (keySuffix?: KeySuffixOptions, userId?: string) => Promise; - getKeyFromStorage: (keySuffix: KeySuffixOptions, userId?: string) => Promise; - getKeyHash: () => Promise; - compareAndUpdateKeyHash: (masterPassword: string, key: SymmetricCryptoKey) => Promise; - getEncKey: (key?: SymmetricCryptoKey) => Promise; - getPublicKey: () => Promise; - getPrivateKey: () => Promise; - getFingerprint: (userId: string, publicKey?: ArrayBuffer) => Promise; - getOrgKeys: () => Promise>; - getOrgKey: (orgId: string) => Promise; - getProviderKey: (providerId: string) => Promise; - getKeyForUserEncryption: (key?: SymmetricCryptoKey) => Promise; - hasKey: () => Promise; - hasKeyInMemory: (userId?: string) => Promise; - hasKeyStored: (keySuffix?: KeySuffixOptions, userId?: string) => Promise; - hasEncKey: () => Promise; - clearKey: (clearSecretStorage?: boolean, userId?: string) => Promise; - clearKeyHash: () => Promise; - clearEncKey: (memoryOnly?: boolean, userId?: string) => Promise; - clearKeyPair: (memoryOnly?: boolean, userId?: string) => Promise; - clearOrgKeys: (memoryOnly?: boolean, userId?: string) => Promise; - clearProviderKeys: (memoryOnly?: boolean) => Promise; - clearPinProtectedKey: () => Promise; - clearKeys: (userId?: string) => Promise; - toggleKey: () => Promise; - makeKey: ( - password: string, - salt: string, - kdf: KdfType, - kdfConfig: KdfConfig - ) => Promise; - makeKeyFromPin: ( - pin: string, - salt: string, - kdf: KdfType, - kdfConfig: KdfConfig, - protectedKeyCs?: EncString - ) => Promise; - makeShareKey: () => Promise<[EncString, SymmetricCryptoKey]>; - makeKeyPair: (key?: SymmetricCryptoKey) => Promise<[string, EncString]>; - makePinKey: ( - pin: string, - salt: string, - kdf: KdfType, - kdfConfig: KdfConfig - ) => Promise; - makeSendKey: (keyMaterial: ArrayBuffer) => Promise; - hashPassword: ( - password: string, - key: SymmetricCryptoKey, - hashPurpose?: HashPurpose - ) => Promise; - makeEncKey: (key: SymmetricCryptoKey) => Promise<[SymmetricCryptoKey, EncString]>; - remakeEncKey: ( - key: SymmetricCryptoKey, - encKey?: SymmetricCryptoKey - ) => Promise<[SymmetricCryptoKey, EncString]>; - encrypt: (plainValue: string | ArrayBuffer, key?: SymmetricCryptoKey) => Promise; - encryptToBytes: (plainValue: ArrayBuffer, key?: SymmetricCryptoKey) => Promise; - rsaEncrypt: (data: ArrayBuffer, publicKey?: ArrayBuffer) => Promise; - rsaDecrypt: (encValue: string, privateKeyValue?: ArrayBuffer) => Promise; - decryptToBytes: (encString: EncString, key?: SymmetricCryptoKey) => Promise; - decryptToUtf8: (encString: EncString, key?: SymmetricCryptoKey) => Promise; - decryptFromBytes: (encBuffer: EncArrayBuffer, key: SymmetricCryptoKey) => Promise; - randomNumber: (min: number, max: number) => Promise; - validateKey: (key: SymmetricCryptoKey) => Promise; -} diff --git a/libs/common/src/abstractions/cryptoFunction.service.ts b/libs/common/src/abstractions/cryptoFunction.service.ts deleted file mode 100644 index 313f9436cb6..00000000000 --- a/libs/common/src/abstractions/cryptoFunction.service.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { DecryptParameters } from "../models/domain/decrypt-parameters"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; -import { CsprngArray } from "../types/csprng"; - -export abstract class CryptoFunctionService { - pbkdf2: ( - password: string | ArrayBuffer, - salt: string | ArrayBuffer, - algorithm: "sha256" | "sha512", - iterations: number - ) => Promise; - argon2: ( - password: string | ArrayBuffer, - salt: string | ArrayBuffer, - iterations: number, - memory: number, - parallelism: number - ) => Promise; - hkdf: ( - ikm: ArrayBuffer, - salt: string | ArrayBuffer, - info: string | ArrayBuffer, - outputByteSize: number, - algorithm: "sha256" | "sha512" - ) => Promise; - hkdfExpand: ( - prk: ArrayBuffer, - info: string | ArrayBuffer, - outputByteSize: number, - algorithm: "sha256" | "sha512" - ) => Promise; - hash: ( - value: string | ArrayBuffer, - algorithm: "sha1" | "sha256" | "sha512" | "md5" - ) => Promise; - hmac: ( - value: ArrayBuffer, - key: ArrayBuffer, - algorithm: "sha1" | "sha256" | "sha512" - ) => Promise; - compare: (a: ArrayBuffer, b: ArrayBuffer) => Promise; - hmacFast: ( - value: ArrayBuffer | string, - key: ArrayBuffer | string, - algorithm: "sha1" | "sha256" | "sha512" - ) => Promise; - compareFast: (a: ArrayBuffer | string, b: ArrayBuffer | string) => Promise; - aesEncrypt: (data: ArrayBuffer, iv: ArrayBuffer, key: ArrayBuffer) => Promise; - aesDecryptFastParameters: ( - data: string, - iv: string, - mac: string, - key: SymmetricCryptoKey - ) => DecryptParameters; - aesDecryptFast: (parameters: DecryptParameters) => Promise; - aesDecrypt: (data: ArrayBuffer, iv: ArrayBuffer, key: ArrayBuffer) => Promise; - rsaEncrypt: ( - data: ArrayBuffer, - publicKey: ArrayBuffer, - algorithm: "sha1" | "sha256" - ) => Promise; - rsaDecrypt: ( - data: ArrayBuffer, - privateKey: ArrayBuffer, - algorithm: "sha1" | "sha256" - ) => Promise; - rsaExtractPublicKey: (privateKey: ArrayBuffer) => Promise; - rsaGenerateKeyPair: (length: 1024 | 2048 | 4096) => Promise<[ArrayBuffer, ArrayBuffer]>; - randomBytes: (length: number) => Promise; -} diff --git a/libs/common/src/abstractions/device-crypto.service.abstraction.ts b/libs/common/src/abstractions/device-crypto.service.abstraction.ts deleted file mode 100644 index 23b3be967f5..00000000000 --- a/libs/common/src/abstractions/device-crypto.service.abstraction.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { DeviceKey } from "../models/domain/symmetric-crypto-key"; - -import { DeviceResponse } from "./devices/responses/device.response"; - -export abstract class DeviceCryptoServiceAbstraction { - trustDevice: () => Promise; - getDeviceKey: () => Promise; -} diff --git a/libs/common/src/abstractions/devices/devices-api.service.abstraction.ts b/libs/common/src/abstractions/devices/devices-api.service.abstraction.ts deleted file mode 100644 index 345b728977e..00000000000 --- a/libs/common/src/abstractions/devices/devices-api.service.abstraction.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DeviceResponse } from "./responses/device.response"; - -export abstract class DevicesApiServiceAbstraction { - getKnownDevice: (email: string, deviceIdentifier: string) => Promise; - - getDeviceByIdentifier: (deviceIdentifier: string) => Promise; - - updateTrustedDeviceKeys: ( - deviceIdentifier: string, - devicePublicKeyEncryptedUserSymKey: string, - userSymKeyEncryptedDevicePublicKey: string, - deviceKeyEncryptedDevicePrivateKey: string - ) => Promise; -} diff --git a/libs/common/src/abstractions/devices/devices.service.abstraction.ts b/libs/common/src/abstractions/devices/devices.service.abstraction.ts new file mode 100644 index 00000000000..ca803f92422 --- /dev/null +++ b/libs/common/src/abstractions/devices/devices.service.abstraction.ts @@ -0,0 +1,15 @@ +import { Observable } from "rxjs"; + +import { DeviceView } from "./views/device.view"; + +export abstract class DevicesServiceAbstraction { + getDevices$: () => Observable>; + getDeviceByIdentifier$: (deviceIdentifier: string) => Observable; + isDeviceKnownForUser$: (email: string, deviceIdentifier: string) => Observable; + updateTrustedDeviceKeys$: ( + deviceIdentifier: string, + devicePublicKeyEncryptedUserKey: string, + userKeyEncryptedDevicePublicKey: string, + deviceKeyEncryptedDevicePrivateKey: string + ) => Observable; +} diff --git a/libs/common/src/abstractions/devices/responses/device.response.ts b/libs/common/src/abstractions/devices/responses/device.response.ts index 331df2e16cd..46874027cda 100644 --- a/libs/common/src/abstractions/devices/responses/device.response.ts +++ b/libs/common/src/abstractions/devices/responses/device.response.ts @@ -3,23 +3,20 @@ import { BaseResponse } from "../../../models/response/base.response"; export class DeviceResponse extends BaseResponse { id: string; - name: number; + userId: string; + name: string; identifier: string; type: DeviceType; creationDate: string; - encryptedUserKey: string; - encryptedPublicKey: string; - encryptedPrivateKey: string; - + revisionDate: string; constructor(response: any) { super(response); this.id = this.getResponseProperty("Id"); + this.userId = this.getResponseProperty("UserId"); this.name = this.getResponseProperty("Name"); this.identifier = this.getResponseProperty("Identifier"); this.type = this.getResponseProperty("Type"); this.creationDate = this.getResponseProperty("CreationDate"); - this.encryptedUserKey = this.getResponseProperty("EncryptedUserKey"); - this.encryptedPublicKey = this.getResponseProperty("EncryptedPublicKey"); - this.encryptedPrivateKey = this.getResponseProperty("EncryptedPrivateKey"); + this.revisionDate = this.getResponseProperty("RevisionDate"); } } diff --git a/libs/common/src/abstractions/devices/views/device.view.ts b/libs/common/src/abstractions/devices/views/device.view.ts new file mode 100644 index 00000000000..5438aa4de1b --- /dev/null +++ b/libs/common/src/abstractions/devices/views/device.view.ts @@ -0,0 +1,17 @@ +import { DeviceType } from "../../../enums"; +import { View } from "../../../models/view/view"; +import { DeviceResponse } from "../responses/device.response"; + +export class DeviceView implements View { + id: string; + userId: string; + name: string; + identifier: string; + type: DeviceType; + creationDate: string; + revisionDate: string; + + constructor(deviceResponse: DeviceResponse) { + Object.assign(this, deviceResponse); + } +} diff --git a/libs/common/src/abstractions/organization-user/organization-user.service.ts b/libs/common/src/abstractions/organization-user/organization-user.service.ts index 3c595961fc3..3e0167ba5c3 100644 --- a/libs/common/src/abstractions/organization-user/organization-user.service.ts +++ b/libs/common/src/abstractions/organization-user/organization-user.service.ts @@ -201,6 +201,17 @@ export abstract class OrganizationUserService { request: OrganizationUserResetPasswordRequest ): Promise; + /** + * Enable Secrets Manager for many users + * @param organizationId - Identifier for the organization the user belongs to + * @param ids - List of organization user identifiers to enable + * @return List of user ids, including both those that were successfully enabled and those that had an error + */ + abstract putOrganizationUserBulkEnableSecretsManager( + organizationId: string, + ids: string[] + ): Promise; + /** * Delete an organization user * @param organizationId - Identifier for the organization the user belongs to diff --git a/libs/common/src/abstractions/organization-user/responses/organization-user.response.ts b/libs/common/src/abstractions/organization-user/responses/organization-user.response.ts index b5af4253c51..973049968a8 100644 --- a/libs/common/src/abstractions/organization-user/responses/organization-user.response.ts +++ b/libs/common/src/abstractions/organization-user/responses/organization-user.response.ts @@ -14,6 +14,7 @@ export class OrganizationUserResponse extends BaseResponse { accessSecretsManager: boolean; permissions: PermissionsApi; resetPasswordEnrolled: boolean; + hasMasterPassword: boolean; collections: SelectionReadOnlyResponse[] = []; groups: string[] = []; @@ -28,6 +29,7 @@ export class OrganizationUserResponse extends BaseResponse { this.accessAll = this.getResponseProperty("AccessAll"); this.accessSecretsManager = this.getResponseProperty("AccessSecretsManager"); this.resetPasswordEnrolled = this.getResponseProperty("ResetPasswordEnrolled"); + this.hasMasterPassword = this.getResponseProperty("HasMasterPassword"); const collections = this.getResponseProperty("Collections"); if (collections != null) { diff --git a/libs/common/src/abstractions/settings.service.ts b/libs/common/src/abstractions/settings.service.ts index ade21aaaae7..78ed7183c88 100644 --- a/libs/common/src/abstractions/settings.service.ts +++ b/libs/common/src/abstractions/settings.service.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { AccountSettingsSettings } from "../models/domain/account"; +import { AccountSettingsSettings } from "../platform/models/domain/account"; export abstract class SettingsService { settings$: Observable; diff --git a/libs/common/src/abstractions/stateMigration.service.ts b/libs/common/src/abstractions/stateMigration.service.ts deleted file mode 100644 index f16777a159f..00000000000 --- a/libs/common/src/abstractions/stateMigration.service.ts +++ /dev/null @@ -1,4 +0,0 @@ -export abstract class StateMigrationService { - needsMigration: () => Promise; - migrate: () => Promise; -} diff --git a/libs/common/src/abstractions/userVerification/userVerification.service.abstraction.ts b/libs/common/src/abstractions/userVerification/userVerification.service.abstraction.ts deleted file mode 100644 index dcfd52bf052..00000000000 --- a/libs/common/src/abstractions/userVerification/userVerification.service.abstraction.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { SecretVerificationRequest } from "../../auth/models/request/secret-verification.request"; -import { Verification } from "../../types/verification"; - -export abstract class UserVerificationService { - buildRequest: ( - verification: Verification, - requestClass?: new () => T, - alreadyHashed?: boolean - ) => Promise; - verifyUser: (verification: Verification) => Promise; - requestOTP: () => Promise; -} diff --git a/libs/common/src/abstractions/vault-timeout/vault-timeout-settings.service.ts b/libs/common/src/abstractions/vault-timeout/vault-timeout-settings.service.ts new file mode 100644 index 00000000000..3f0e7ae98a4 --- /dev/null +++ b/libs/common/src/abstractions/vault-timeout/vault-timeout-settings.service.ts @@ -0,0 +1,56 @@ +import { Observable } from "rxjs"; + +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { PinLockType } from "../../services/vault-timeout/vault-timeout-settings.service"; + +export abstract class VaultTimeoutSettingsService { + /** + * Set the vault timeout options for the user + * @param vaultTimeout The vault timeout in minutes + * @param vaultTimeoutAction The vault timeout action + * @param userId The user id to set. If not provided, the current user is used + */ + setVaultTimeoutOptions: ( + vaultTimeout: number, + vaultTimeoutAction: VaultTimeoutAction + ) => Promise; + + /** + * Get the available vault timeout actions for the current user + * + * **NOTE:** This observable is not yet connected to the state service, so it will not update when the state changes + * @param userId The user id to check. If not provided, the current user is used + */ + availableVaultTimeoutActions$: (userId?: string) => Observable; + + /** + * Get the current vault timeout action for the user. This is not the same as the current state, it is + * calculated based on the current state, the user's policy, and the user's available unlock methods. + */ + getVaultTimeout: (userId?: string) => Promise; + + /** + * Observe the vault timeout action for the user. This is calculated based on users preferred lock action saved in the state, + * the user's policy, and the user's available unlock methods. + * + * **NOTE:** This observable is not yet connected to the state service, so it will not update when the state changes + * @param userId The user id to check. If not provided, the current user is used + */ + vaultTimeoutAction$: (userId?: string) => Observable; + + /** + * Has the user enabled unlock with Pin. + * @param userId The user id to check. If not provided, the current user is used + * @returns PinLockType + */ + isPinLockSet: (userId?: string) => Promise; + + /** + * Has the user enabled unlock with Biometric. + * @param userId The user id to check. If not provided, the current user is used + * @returns boolean true if biometric lock is set + */ + isBiometricLockSet: (userId?: string) => Promise; + + clear: (userId?: string) => Promise; +} diff --git a/libs/common/src/abstractions/vaultTimeout/vaultTimeout.service.ts b/libs/common/src/abstractions/vault-timeout/vault-timeout.service.ts similarity index 100% rename from libs/common/src/abstractions/vaultTimeout/vaultTimeout.service.ts rename to libs/common/src/abstractions/vault-timeout/vault-timeout.service.ts diff --git a/libs/common/src/abstractions/vaultTimeout/vaultTimeoutSettings.service.ts b/libs/common/src/abstractions/vaultTimeout/vaultTimeoutSettings.service.ts deleted file mode 100644 index 03f89b0476f..00000000000 --- a/libs/common/src/abstractions/vaultTimeout/vaultTimeoutSettings.service.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; - -export abstract class VaultTimeoutSettingsService { - setVaultTimeoutOptions: ( - vaultTimeout: number, - vaultTimeoutAction: VaultTimeoutAction - ) => Promise; - getVaultTimeout: (userId?: string) => Promise; - getVaultTimeoutAction: (userId?: string) => Promise; - isPinLockSet: () => Promise<[boolean, boolean]>; - isBiometricLockSet: () => Promise; - clear: (userId?: string) => Promise; -} diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index 93f4de2ffdf..a1792b1fe75 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -3,9 +3,11 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; +import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/request/organization-tax-info-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; +import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingResponse } from "../../../billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response"; import { PaymentResponse } from "../../../billing/models/response/payment.response"; @@ -16,7 +18,6 @@ import { StorageRequest } from "../../../models/request/storage.request"; import { VerifyBankRequest } from "../../../models/request/verify-bank.request"; import { ListResponse } from "../../../models/response/list.response"; import { OrganizationApiKeyType } from "../../enums"; -import { OrganizationEnrollSecretsManagerRequest } from "../../models/request/organization/organization-enroll-secrets-manager.request"; import { OrganizationCreateRequest } from "../../models/request/organization-create.request"; import { OrganizationKeysRequest } from "../../models/request/organization-keys.request"; import { OrganizationUpdateRequest } from "../../models/request/organization-update.request"; @@ -25,6 +26,7 @@ import { OrganizationApiKeyInformationResponse } from "../../models/response/org import { OrganizationAutoEnrollStatusResponse } from "../../models/response/organization-auto-enroll-status.response"; import { OrganizationKeysResponse } from "../../models/response/organization-keys.response"; import { OrganizationResponse } from "../../models/response/organization.response"; +import { ProfileOrganizationResponse } from "../../models/response/profile-organization.response"; export class OrganizationApiServiceAbstraction { get: (id: string) => Promise; @@ -37,7 +39,14 @@ export class OrganizationApiServiceAbstraction { save: (id: string, request: OrganizationUpdateRequest) => Promise; updatePayment: (id: string, request: PaymentRequest) => Promise; upgrade: (id: string, request: OrganizationUpgradeRequest) => Promise; - updateSubscription: (id: string, request: OrganizationSubscriptionUpdateRequest) => Promise; + updatePasswordManagerSeats: ( + id: string, + request: OrganizationSubscriptionUpdateRequest + ) => Promise; + updateSecretsManagerSubscription: ( + id: string, + request: OrganizationSmSubscriptionUpdateRequest + ) => Promise; updateSeats: (id: string, request: SeatRequest) => Promise; updateStorage: (id: string, request: StorageRequest) => Promise; verifyBank: (id: string, request: VerifyBankRequest) => Promise; @@ -60,8 +69,8 @@ export class OrganizationApiServiceAbstraction { getSso: (id: string) => Promise; updateSso: (id: string, request: OrganizationSsoRequest) => Promise; selfHostedSyncLicense: (id: string) => Promise; - updateEnrollSecretsManager: ( + subscribeToSecretsManager: ( id: string, - request: OrganizationEnrollSecretsManagerRequest - ) => Promise; + request: SecretsManagerSubscribeRequest + ) => Promise; } diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 1fdfb260d25..25247547be8 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -1,12 +1,12 @@ import { map, Observable } from "rxjs"; -import { I18nService } from "../../../abstractions/i18n.service"; -import { Utils } from "../../../misc/utils"; +import { I18nService } from "../../../platform/abstractions/i18n.service"; +import { Utils } from "../../../platform/misc/utils"; import { OrganizationData } from "../../models/data/organization.data"; import { Organization } from "../../models/domain/organization"; export function canAccessVaultTab(org: Organization): boolean { - return org.canViewAssignedCollections || org.canViewAllCollections || org.canManageGroups; + return org.canViewAssignedCollections || org.canViewAllCollections; } export function canAccessSettingsTab(org: Organization): boolean { @@ -15,7 +15,8 @@ export function canAccessSettingsTab(org: Organization): boolean { org.canManagePolicies || org.canManageSso || org.canManageScim || - org.canAccessImportExport + org.canAccessImportExport || + org.canManageDeviceApprovals ); } @@ -56,6 +57,12 @@ export function canAccessAdmin(i18nService: I18nService) { ); } +export function canAccessImportExport(i18nService: I18nService) { + return map((orgs) => + orgs.filter((org) => org.canAccessImportExport).sort(Utils.getSortFunction(i18nService, "name")) + ); +} + /** * Returns `true` if a user is a member of an organization (rather than only being a ProviderUser) * @deprecated Use organizationService.memberOrganizations$ instead @@ -85,6 +92,7 @@ export abstract class OrganizationService { hasOrganizations: () => boolean; } -export abstract class InternalOrganizationService extends OrganizationService { +export abstract class InternalOrganizationServiceAbstraction extends OrganizationService { replace: (organizations: { [id: string]: OrganizationData }) => Promise; + upsert: (OrganizationData: OrganizationData | OrganizationData[]) => Promise; } diff --git a/libs/common/src/admin-console/models/data/organization.data.ts b/libs/common/src/admin-console/models/data/organization.data.ts index 36ebb0f3df8..10f5e9f2625 100644 --- a/libs/common/src/admin-console/models/data/organization.data.ts +++ b/libs/common/src/admin-console/models/data/organization.data.ts @@ -22,6 +22,7 @@ export class OrganizationData { useCustomPermissions: boolean; useResetPassword: boolean; useSecretsManager: boolean; + usePasswordManager: boolean; useActivateAutofillPolicy: boolean; selfHost: boolean; usersGetPremium: boolean; @@ -74,6 +75,7 @@ export class OrganizationData { this.useCustomPermissions = response.useCustomPermissions; this.useResetPassword = response.useResetPassword; this.useSecretsManager = response.useSecretsManager; + this.usePasswordManager = response.usePasswordManager; this.useActivateAutofillPolicy = response.useActivateAutofillPolicy; this.selfHost = response.selfHost; this.usersGetPremium = response.usersGetPremium; diff --git a/libs/common/src/admin-console/models/domain/encrypted-organization-key.ts b/libs/common/src/admin-console/models/domain/encrypted-organization-key.ts index 4e7dd9ef2bb..7b35235c0c8 100644 --- a/libs/common/src/admin-console/models/domain/encrypted-organization-key.ts +++ b/libs/common/src/admin-console/models/domain/encrypted-organization-key.ts @@ -1,6 +1,6 @@ -import { CryptoService } from "../../../abstractions/crypto.service"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { EncryptedOrganizationKeyData } from "../data/encrypted-organization-key.data"; export abstract class BaseEncryptedOrganizationKey { diff --git a/libs/common/src/admin-console/models/domain/master-password-policy-options.ts b/libs/common/src/admin-console/models/domain/master-password-policy-options.ts index 8acce2c0488..889a84a4fc9 100644 --- a/libs/common/src/admin-console/models/domain/master-password-policy-options.ts +++ b/libs/common/src/admin-console/models/domain/master-password-policy-options.ts @@ -1,5 +1,5 @@ import { MasterPasswordPolicyResponse } from "../../../auth/models/response/master-password-policy.response"; -import Domain from "../../../models/domain/domain-base"; +import Domain from "../../../platform/models/domain/domain-base"; export class MasterPasswordPolicyOptions extends Domain { minComplexity = 0; diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 11e0ccc0349..bd3c9036367 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -31,6 +31,7 @@ export class Organization { useCustomPermissions: boolean; useResetPassword: boolean; useSecretsManager: boolean; + usePasswordManager: boolean; useActivateAutofillPolicy: boolean; selfHost: boolean; usersGetPremium: boolean; @@ -87,6 +88,7 @@ export class Organization { this.useCustomPermissions = obj.useCustomPermissions; this.useResetPassword = obj.useResetPassword; this.useSecretsManager = obj.useSecretsManager; + this.usePasswordManager = obj.usePasswordManager; this.useActivateAutofillPolicy = obj.useActivateAutofillPolicy; this.selfHost = obj.selfHost; this.usersGetPremium = obj.usersGetPremium; @@ -172,7 +174,7 @@ export class Organization { } get canViewAllCollections() { - return this.canCreateNewCollections || this.canEditAnyCollection || this.canDeleteAnyCollection; + return this.canEditAnyCollection || this.canDeleteAnyCollection; } get canEditAssignedCollections() { @@ -215,6 +217,10 @@ export class Organization { return this.isAdmin || this.permissions.manageResetPassword; } + get canManageDeviceApprovals() { + return (this.isAdmin || this.permissions.manageResetPassword) && this.useSso; + } + get isExemptFromPolicies() { return this.canManagePolicies; } diff --git a/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts b/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts index eba15fae263..858b9fd8829 100644 --- a/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts +++ b/libs/common/src/admin-console/models/domain/password-generator-policy-options.ts @@ -1,4 +1,4 @@ -import Domain from "../../../models/domain/domain-base"; +import Domain from "../../../platform/models/domain/domain-base"; export class PasswordGeneratorPolicyOptions extends Domain { defaultType = ""; diff --git a/libs/common/src/admin-console/models/domain/policy.ts b/libs/common/src/admin-console/models/domain/policy.ts index a179c6d236f..223066a2534 100644 --- a/libs/common/src/admin-console/models/domain/policy.ts +++ b/libs/common/src/admin-console/models/domain/policy.ts @@ -1,4 +1,4 @@ -import Domain from "../../../models/domain/domain-base"; +import Domain from "../../../platform/models/domain/domain-base"; import { PolicyType } from "../../enums"; import { PolicyData } from "../data/policy.data"; diff --git a/libs/common/src/admin-console/models/domain/reset-password-policy-options.ts b/libs/common/src/admin-console/models/domain/reset-password-policy-options.ts index efa8ecdeca5..a637c6c6ecb 100644 --- a/libs/common/src/admin-console/models/domain/reset-password-policy-options.ts +++ b/libs/common/src/admin-console/models/domain/reset-password-policy-options.ts @@ -1,4 +1,4 @@ -import Domain from "../../../models/domain/domain-base"; +import Domain from "../../../platform/models/domain/domain-base"; export class ResetPasswordPolicyOptions extends Domain { autoEnrollEnabled = false; diff --git a/libs/common/src/admin-console/models/request/organization-create.request.ts b/libs/common/src/admin-console/models/request/organization-create.request.ts index 616c37c00ca..729cf453653 100644 --- a/libs/common/src/admin-console/models/request/organization-create.request.ts +++ b/libs/common/src/admin-console/models/request/organization-create.request.ts @@ -23,4 +23,8 @@ export class OrganizationCreateRequest { billingAddressState: string; billingAddressPostalCode: string; billingAddressCountry: string; + + useSecretsManager: boolean; + additionalSmSeats: number; + additionalServiceAccounts: number; } diff --git a/libs/common/src/admin-console/models/request/organization-upgrade.request.ts b/libs/common/src/admin-console/models/request/organization-upgrade.request.ts index bf0eb5f47f8..eba897f31b6 100644 --- a/libs/common/src/admin-console/models/request/organization-upgrade.request.ts +++ b/libs/common/src/admin-console/models/request/organization-upgrade.request.ts @@ -11,4 +11,8 @@ export class OrganizationUpgradeRequest { billingAddressCountry: string; billingAddressPostalCode: string; keys: OrganizationKeysRequest; + + useSecretsManager: boolean; + additionalSmSeats: number; + additionalServiceAccounts: number; } diff --git a/libs/common/src/admin-console/models/request/organization/organization-enroll-secrets-manager.request.ts b/libs/common/src/admin-console/models/request/organization/organization-enroll-secrets-manager.request.ts deleted file mode 100644 index a213b07bba7..00000000000 --- a/libs/common/src/admin-console/models/request/organization/organization-enroll-secrets-manager.request.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class OrganizationEnrollSecretsManagerRequest { - enabled: boolean; -} diff --git a/libs/common/src/admin-console/models/response/organization-export.response.ts b/libs/common/src/admin-console/models/response/organization-export.response.ts index 9be06074730..b25b3cbaaea 100644 --- a/libs/common/src/admin-console/models/response/organization-export.response.ts +++ b/libs/common/src/admin-console/models/response/organization-export.response.ts @@ -1,7 +1,6 @@ import { BaseResponse } from "../../../models/response/base.response"; import { CipherResponse } from "../../../vault/models/response/cipher.response"; - -import { CollectionResponse } from "./collection.response"; +import { CollectionResponse } from "../../../vault/models/response/collection.response"; export class OrganizationExportResponse extends BaseResponse { collections: CollectionResponse[]; diff --git a/libs/common/src/admin-console/models/response/organization.response.ts b/libs/common/src/admin-console/models/response/organization.response.ts index 2c056ee2875..b248c6d0df9 100644 --- a/libs/common/src/admin-console/models/response/organization.response.ts +++ b/libs/common/src/admin-console/models/response/organization.response.ts @@ -13,6 +13,7 @@ export class OrganizationResponse extends BaseResponse { businessTaxNumber: string; billingEmail: string; plan: PlanResponse; + secretsManagerPlan: PlanResponse; planType: PlanType; seats: number; maxAutoscaleSeats: number; @@ -27,6 +28,11 @@ export class OrganizationResponse extends BaseResponse { useResetPassword: boolean; useSecretsManager: boolean; hasPublicAndPrivateKeys: boolean; + usePasswordManager: boolean; + smSeats?: number; + smServiceAccounts?: number; + maxAutoscaleSmSeats?: number; + maxAutoscaleSmServiceAccounts?: number; constructor(response: any) { super(response); @@ -39,8 +45,14 @@ export class OrganizationResponse extends BaseResponse { this.businessCountry = this.getResponseProperty("BusinessCountry"); this.businessTaxNumber = this.getResponseProperty("BusinessTaxNumber"); this.billingEmail = this.getResponseProperty("BillingEmail"); + const plan = this.getResponseProperty("Plan"); this.plan = plan == null ? null : new PlanResponse(plan); + + const secretsManagerPlan = this.getResponseProperty("SecretsManagerPlan"); + this.secretsManagerPlan = + secretsManagerPlan == null ? null : new PlanResponse(secretsManagerPlan); + this.planType = this.getResponseProperty("PlanType"); this.seats = this.getResponseProperty("Seats"); this.maxAutoscaleSeats = this.getResponseProperty("MaxAutoscaleSeats"); @@ -55,5 +67,10 @@ export class OrganizationResponse extends BaseResponse { this.useResetPassword = this.getResponseProperty("UseResetPassword"); this.useSecretsManager = this.getResponseProperty("UseSecretsManager"); this.hasPublicAndPrivateKeys = this.getResponseProperty("HasPublicAndPrivateKeys"); + this.usePasswordManager = this.getResponseProperty("UsePasswordManager"); + this.smSeats = this.getResponseProperty("SmSeats"); + this.smServiceAccounts = this.getResponseProperty("SmServiceAccounts"); + this.maxAutoscaleSmSeats = this.getResponseProperty("MaxAutoscaleSmSeats"); + this.maxAutoscaleSmServiceAccounts = this.getResponseProperty("MaxAutoscaleSmServiceAccounts"); } } diff --git a/libs/common/src/admin-console/models/response/profile-organization.response.ts b/libs/common/src/admin-console/models/response/profile-organization.response.ts index 18bf4d45e8a..e042bf145f8 100644 --- a/libs/common/src/admin-console/models/response/profile-organization.response.ts +++ b/libs/common/src/admin-console/models/response/profile-organization.response.ts @@ -19,6 +19,7 @@ export class ProfileOrganizationResponse extends BaseResponse { useCustomPermissions: boolean; useResetPassword: boolean; useSecretsManager: boolean; + usePasswordManager: boolean; useActivateAutofillPolicy: boolean; selfHost: boolean; usersGetPremium: boolean; @@ -65,6 +66,7 @@ export class ProfileOrganizationResponse extends BaseResponse { this.useCustomPermissions = this.getResponseProperty("UseCustomPermissions") ?? false; this.useResetPassword = this.getResponseProperty("UseResetPassword"); this.useSecretsManager = this.getResponseProperty("UseSecretsManager"); + this.usePasswordManager = this.getResponseProperty("UsePasswordManager"); this.useActivateAutofillPolicy = this.getResponseProperty("UseActivateAutofillPolicy"); this.selfHost = this.getResponseProperty("SelfHost"); this.usersGetPremium = this.getResponseProperty("UsersGetPremium"); diff --git a/libs/common/src/admin-console/services/organization/organization-api.service.ts b/libs/common/src/admin-console/services/organization/organization-api.service.ts index 3a1d3555242..76b5fb0eca0 100644 --- a/libs/common/src/admin-console/services/organization/organization-api.service.ts +++ b/libs/common/src/admin-console/services/organization/organization-api.service.ts @@ -4,9 +4,11 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; +import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/request/organization-tax-info-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; +import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingResponse } from "../../../billing/models/response/billing.response"; import { OrganizationSubscriptionResponse } from "../../../billing/models/response/organization-subscription.response"; import { PaymentResponse } from "../../../billing/models/response/payment.response"; @@ -19,7 +21,6 @@ import { ListResponse } from "../../../models/response/list.response"; import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction"; import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction"; import { OrganizationApiKeyType } from "../../enums"; -import { OrganizationEnrollSecretsManagerRequest } from "../../models/request/organization/organization-enroll-secrets-manager.request"; import { OrganizationCreateRequest } from "../../models/request/organization-create.request"; import { OrganizationKeysRequest } from "../../models/request/organization-keys.request"; import { OrganizationUpdateRequest } from "../../models/request/organization-update.request"; @@ -28,6 +29,7 @@ import { OrganizationApiKeyInformationResponse } from "../../models/response/org import { OrganizationAutoEnrollStatusResponse } from "../../models/response/organization-auto-enroll-status.response"; import { OrganizationKeysResponse } from "../../models/response/organization-keys.response"; import { OrganizationResponse } from "../../models/response/organization.response"; +import { ProfileOrganizationResponse } from "../../models/response/profile-organization.response"; export class OrganizationApiService implements OrganizationApiServiceAbstraction { constructor(private apiService: ApiService, private syncService: SyncService) {} @@ -120,7 +122,7 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new PaymentResponse(r); } - async updateSubscription( + async updatePasswordManagerSeats( id: string, request: OrganizationSubscriptionUpdateRequest ): Promise { @@ -133,6 +135,19 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction ); } + async updateSecretsManagerSubscription( + id: string, + request: OrganizationSmSubscriptionUpdateRequest + ): Promise { + return this.apiService.send( + "POST", + "/organizations/" + id + "/sm-subscription", + request, + true, + false + ); + } + async updateSeats(id: string, request: SeatRequest): Promise { const r = await this.apiService.send( "POST", @@ -294,13 +309,17 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction ); } - async updateEnrollSecretsManager(id: string, request: OrganizationEnrollSecretsManagerRequest) { - await this.apiService.send( + async subscribeToSecretsManager( + id: string, + request: SecretsManagerSubscribeRequest + ): Promise { + const r = await this.apiService.send( "POST", - "/organizations/" + id + "/enroll-secrets-manager", + "/organizations/" + id + "/subscribe-secrets-manager", request, true, true ); + return new ProfileOrganizationResponse(r); } } diff --git a/libs/common/src/admin-console/services/organization/organization.service.spec.ts b/libs/common/src/admin-console/services/organization/organization.service.spec.ts index 3947df97071..64f6697f4c2 100644 --- a/libs/common/src/admin-console/services/organization/organization.service.spec.ts +++ b/libs/common/src/admin-console/services/organization/organization.service.spec.ts @@ -1,7 +1,7 @@ import { MockProxy, mock, any, mockClear } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom } from "rxjs"; -import { StateService } from "../../../abstractions/state.service"; +import { StateService } from "../../../platform/abstractions/state.service"; import { OrganizationData } from "../../models/data/organization.data"; import { OrganizationService } from "./organization.service"; diff --git a/libs/common/src/admin-console/services/organization/organization.service.ts b/libs/common/src/admin-console/services/organization/organization.service.ts index 8d33684c20d..c8641b41f25 100644 --- a/libs/common/src/admin-console/services/organization/organization.service.ts +++ b/libs/common/src/admin-console/services/organization/organization.service.ts @@ -1,8 +1,8 @@ import { BehaviorSubject, concatMap, map, Observable } from "rxjs"; -import { StateService } from "../../../abstractions/state.service"; +import { StateService } from "../../../platform/abstractions/state.service"; import { - InternalOrganizationService as InternalOrganizationServiceAbstraction, + InternalOrganizationServiceAbstraction, isMember, } from "../../abstractions/organization/organization.service.abstraction"; import { OrganizationData } from "../../models/data/organization.data"; diff --git a/libs/common/src/admin-console/services/policy/policy-api.service.ts b/libs/common/src/admin-console/services/policy/policy-api.service.ts index 02ff0150498..c0a572ae8d3 100644 --- a/libs/common/src/admin-console/services/policy/policy-api.service.ts +++ b/libs/common/src/admin-console/services/policy/policy-api.service.ts @@ -1,9 +1,9 @@ import { firstValueFrom } from "rxjs"; import { ApiService } from "../../../abstractions/api.service"; -import { StateService } from "../../../abstractions/state.service"; -import { Utils } from "../../../misc/utils"; import { ListResponse } from "../../../models/response/list.response"; +import { StateService } from "../../../platform/abstractions/state.service"; +import { Utils } from "../../../platform/misc/utils"; import { PolicyApiServiceAbstraction } from "../../abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "../../abstractions/policy/policy.service.abstraction"; import { PolicyType } from "../../enums"; diff --git a/libs/common/src/admin-console/services/policy/policy.service.ts b/libs/common/src/admin-console/services/policy/policy.service.ts index b65aa5dc850..4ef3356c18e 100644 --- a/libs/common/src/admin-console/services/policy/policy.service.ts +++ b/libs/common/src/admin-console/services/policy/policy.service.ts @@ -1,8 +1,8 @@ import { BehaviorSubject, concatMap, map, Observable, of } from "rxjs"; -import { StateService } from "../../../abstractions/state.service"; -import { Utils } from "../../../misc/utils"; import { ListResponse } from "../../../models/response/list.response"; +import { StateService } from "../../../platform/abstractions/state.service"; +import { Utils } from "../../../platform/misc/utils"; import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction"; import { InternalPolicyService as InternalPolicyServiceAbstraction } from "../../abstractions/policy/policy.service.abstraction"; import { OrganizationUserStatusType, OrganizationUserType, PolicyType } from "../../enums"; diff --git a/libs/common/src/admin-console/services/provider.service.ts b/libs/common/src/admin-console/services/provider.service.ts index 31934260ee4..99a2d463d8f 100644 --- a/libs/common/src/admin-console/services/provider.service.ts +++ b/libs/common/src/admin-console/services/provider.service.ts @@ -1,5 +1,5 @@ -import { StateService } from "../../abstractions/state.service"; import { Provider } from "../../models/domain/provider"; +import { StateService } from "../../platform/abstractions/state.service"; import { ProviderService as ProviderServiceAbstraction } from "../abstractions/provider.service"; import { ProviderData } from "../models/data/provider.data"; diff --git a/libs/common/src/auth/abstractions/auth-request-crypto.service.abstraction.ts b/libs/common/src/auth/abstractions/auth-request-crypto.service.abstraction.ts new file mode 100644 index 00000000000..f18c8d45ba6 --- /dev/null +++ b/libs/common/src/auth/abstractions/auth-request-crypto.service.abstraction.ts @@ -0,0 +1,24 @@ +import { UserKey, MasterKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { AuthRequestResponse } from "../models/response/auth-request.response"; + +export abstract class AuthRequestCryptoServiceAbstraction { + setUserKeyAfterDecryptingSharedUserKey: ( + authReqResponse: AuthRequestResponse, + authReqPrivateKey: ArrayBuffer + ) => Promise; + setKeysAfterDecryptingSharedMasterKeyAndHash: ( + authReqResponse: AuthRequestResponse, + authReqPrivateKey: ArrayBuffer + ) => Promise; + + decryptPubKeyEncryptedUserKey: ( + pubKeyEncryptedUserKey: string, + privateKey: ArrayBuffer + ) => Promise; + + decryptPubKeyEncryptedMasterKeyAndHash: ( + pubKeyEncryptedMasterKey: string, + pubKeyEncryptedMasterKeyHash: string, + privateKey: ArrayBuffer + ) => Promise<{ masterKey: MasterKey; masterKeyHash: string }>; +} diff --git a/libs/common/src/auth/abstractions/auth.service.ts b/libs/common/src/auth/abstractions/auth.service.ts index 4a59f0ac4ee..b6978c54c5b 100644 --- a/libs/common/src/auth/abstractions/auth.service.ts +++ b/libs/common/src/auth/abstractions/auth.service.ts @@ -1,7 +1,7 @@ import { Observable } from "rxjs"; -import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { AuthRequestPushNotification } from "../../models/response/notification.response"; +import { MasterKey } from "../../platform/models/domain/symmetric-crypto-key"; import { AuthenticationStatus } from "../enums/authentication-status"; import { AuthResult } from "../models/domain/auth-result"; import { @@ -32,7 +32,7 @@ export abstract class AuthService { captchaResponse: string ) => Promise; logOut: (callback: () => void) => void; - makePreloginKey: (masterPassword: string, email: string) => Promise; + makePreloginKey: (masterPassword: string, email: string) => Promise; authingWithUserApiKey: () => boolean; authingWithSso: () => boolean; authingWithPassword: () => boolean; diff --git a/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts new file mode 100644 index 00000000000..c30a567681b --- /dev/null +++ b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts @@ -0,0 +1,25 @@ +import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { DeviceKey, UserKey } from "../../platform/models/domain/symmetric-crypto-key"; + +export abstract class DeviceTrustCryptoServiceAbstraction { + /** + * @description Retrieves the users choice to trust the device which can only happen after decryption + * Note: this value should only be used once and then reset + */ + getShouldTrustDevice: () => Promise; + setShouldTrustDevice: (value: boolean) => Promise; + + trustDeviceIfRequired: () => Promise; + + trustDevice: () => Promise; + getDeviceKey: () => Promise; + decryptUserKeyWithDeviceKey: ( + encryptedDevicePrivateKey: EncString, + encryptedUserKey: EncString, + deviceKey?: DeviceKey + ) => Promise; + rotateDevicesTrust: (newUserKey: UserKey, masterPasswordHash: string) => Promise; + + supportsDeviceTrust: () => Promise; +} diff --git a/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts b/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts new file mode 100644 index 00000000000..1bf0385ba1d --- /dev/null +++ b/libs/common/src/auth/abstractions/devices-api.service.abstraction.ts @@ -0,0 +1,27 @@ +import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; +import { ListResponse } from "../../models/response/list.response"; +import { SecretVerificationRequest } from "../models/request/secret-verification.request"; +import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request"; +import { ProtectedDeviceResponse } from "../models/response/protected-device.response"; + +export abstract class DevicesApiServiceAbstraction { + getKnownDevice: (email: string, deviceIdentifier: string) => Promise; + + getDeviceByIdentifier: (deviceIdentifier: string) => Promise; + + getDevices: () => Promise>; + + updateTrustedDeviceKeys: ( + deviceIdentifier: string, + devicePublicKeyEncryptedUserKey: string, + userKeyEncryptedDevicePublicKey: string, + deviceKeyEncryptedDevicePrivateKey: string + ) => Promise; + + updateTrust: (updateDevicesTrustRequestModel: UpdateDevicesTrustRequest) => Promise; + + getDeviceKeys: ( + deviceIdentifier: string, + secretVerificationRequest: SecretVerificationRequest + ) => Promise; +} diff --git a/libs/common/src/auth/abstractions/key-connector.service.ts b/libs/common/src/auth/abstractions/key-connector.service.ts index 0c4426a6838..0722c89db95 100644 --- a/libs/common/src/auth/abstractions/key-connector.service.ts +++ b/libs/common/src/auth/abstractions/key-connector.service.ts @@ -2,7 +2,7 @@ import { Organization } from "../../admin-console/models/domain/organization"; import { IdentityTokenResponse } from "../models/response/identity-token.response"; export abstract class KeyConnectorService { - getAndSetKey: (url?: string) => Promise; + setMasterKeyFromUrl: (url?: string) => Promise; getManagingOrganization: () => Promise; getUsesKeyConnector: () => Promise; migrateUser: () => Promise; diff --git a/libs/common/src/auth/abstractions/password-reset-enrollment.service.abstraction.ts b/libs/common/src/auth/abstractions/password-reset-enrollment.service.abstraction.ts new file mode 100644 index 00000000000..ccfe0d645c7 --- /dev/null +++ b/libs/common/src/auth/abstractions/password-reset-enrollment.service.abstraction.ts @@ -0,0 +1,26 @@ +import { UserKey } from "../../platform/models/domain/symmetric-crypto-key"; + +export abstract class PasswordResetEnrollmentServiceAbstraction { + /* + * Checks the user's enrollment status and enrolls them if required + */ + abstract enrollIfRequired(organizationSsoIdentifier: string): Promise; + + /** + * Enroll current user in password reset + * @param organizationId - Organization in which to enroll the user + * @returns Promise that resolves when the user is enrolled + * @throws Error if the action fails + */ + abstract enroll(organizationId: string): Promise; + + /** + * Enroll user in password reset + * @param organizationId - Organization in which to enroll the user + * @param userId - User to enroll + * @param userKey - User's symmetric key + * @returns Promise that resolves when the user is enrolled + * @throws Error if the action fails + */ + abstract enroll(organizationId: string, userId: string, userKey: UserKey): Promise; +} diff --git a/libs/common/src/abstractions/userVerification/userVerification-api.service.abstraction.ts b/libs/common/src/auth/abstractions/user-verification/user-verification-api.service.abstraction.ts similarity index 69% rename from libs/common/src/abstractions/userVerification/userVerification-api.service.abstraction.ts rename to libs/common/src/auth/abstractions/user-verification/user-verification-api.service.abstraction.ts index 78e4099eeec..b861ce44712 100644 --- a/libs/common/src/abstractions/userVerification/userVerification-api.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/user-verification/user-verification-api.service.abstraction.ts @@ -1,4 +1,4 @@ -import { VerifyOTPRequest } from "../../auth/models/request/verify-otp.request"; +import { VerifyOTPRequest } from "../../models/request/verify-otp.request"; export abstract class UserVerificationApiServiceAbstraction { postAccountVerifyOTP: (request: VerifyOTPRequest) => Promise; diff --git a/libs/common/src/auth/abstractions/user-verification/user-verification.service.abstraction.ts b/libs/common/src/auth/abstractions/user-verification/user-verification.service.abstraction.ts new file mode 100644 index 00000000000..46b8d753c97 --- /dev/null +++ b/libs/common/src/auth/abstractions/user-verification/user-verification.service.abstraction.ts @@ -0,0 +1,24 @@ +import { Verification } from "../../../types/verification"; +import { SecretVerificationRequest } from "../../models/request/secret-verification.request"; + +export abstract class UserVerificationService { + buildRequest: ( + verification: Verification, + requestClass?: new () => T, + alreadyHashed?: boolean + ) => Promise; + verifyUser: (verification: Verification) => Promise; + requestOTP: () => Promise; + /** + * Check if user has master password or only uses passwordless technologies to log in + * @param userId The user id to check. If not provided, the current user is used + * @returns True if the user has a master password + */ + hasMasterPassword: (userId?: string) => Promise; + /** + * Check if the user has a master password and has used it during their current session + * @param userId The user id to check. If not provided, the current user id used + * @returns True if the user has a master password and has used it in the current session + */ + hasMasterPasswordAndMasterKeyHash: (userId?: string) => Promise; +} diff --git a/libs/common/src/auth/captcha-iframe.ts b/libs/common/src/auth/captcha-iframe.ts index 8559ef40501..6fb0f5a43a4 100644 --- a/libs/common/src/auth/captcha-iframe.ts +++ b/libs/common/src/auth/captcha-iframe.ts @@ -1,5 +1,5 @@ -import { I18nService } from "../abstractions/i18n.service"; import { IFrameComponent } from "../misc/iframe_component"; +import { I18nService } from "../platform/abstractions/i18n.service"; export class CaptchaIFrame extends IFrameComponent { constructor( diff --git a/libs/common/src/auth/enums/auth-request-type.ts b/libs/common/src/auth/enums/auth-request-type.ts index 4edfa5b8889..31db2467861 100644 --- a/libs/common/src/auth/enums/auth-request-type.ts +++ b/libs/common/src/auth/enums/auth-request-type.ts @@ -1,4 +1,5 @@ export enum AuthRequestType { AuthenticateAndUnlock = 0, Unlock = 1, + AdminApproval = 2, } diff --git a/libs/common/src/auth/login-strategies/login.strategy.spec.ts b/libs/common/src/auth/login-strategies/login.strategy.spec.ts index 3f7e8fb8c13..735135f4061 100644 --- a/libs/common/src/auth/login-strategies/login.strategy.spec.ts +++ b/libs/common/src/auth/login-strategies/login.strategy.spec.ts @@ -1,17 +1,33 @@ import { mock, MockProxy } from "jest-mock-extended"; import { ApiService } from "../../abstractions/api.service"; -import { AppIdService } from "../../abstractions/appId.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { LogService } from "../../abstractions/log.service"; -import { MessagingService } from "../../abstractions/messaging.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; -import { StateService } from "../../abstractions/state.service"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; -import { Utils } from "../../misc/utils"; -import { Account, AccountProfile, AccountTokens } from "../../models/domain/account"; -import { EncString } from "../../models/domain/enc-string"; -import { PasswordGenerationService } from "../../tools/generator/password"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; +import { + Account, + AccountDecryptionOptions, + AccountKeys, + AccountProfile, + AccountTokens, +} from "../../platform/models/domain/account"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { + DeviceKey, + MasterKey, + SymmetricCryptoKey, + UserKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { + PasswordStrengthService, + PasswordStrengthServiceAbstraction, +} from "../../tools/password-strength"; +import { CsprngArray } from "../../types/csprng"; import { AuthService } from "../abstractions/auth.service"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; @@ -25,6 +41,7 @@ import { IdentityCaptchaResponse } from "../models/response/identity-captcha.res import { IdentityTokenResponse } from "../models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response"; +import { IUserDecryptionOptionsServerResponse } from "../models/response/user-decryption-options/user-decryption-options.response"; import { PasswordLogInStrategy } from "./password-login.strategy"; @@ -34,7 +51,7 @@ const masterPassword = "password"; const deviceId = Utils.newGuid(); const accessToken = "ACCESS_TOKEN"; const refreshToken = "REFRESH_TOKEN"; -const encKey = "ENC_KEY"; +const userKey = "USER_KEY"; const privateKey = "PRIVATE_KEY"; const captchaSiteKey = "CAPTCHA_SITE_KEY"; const kdf = 0; @@ -42,6 +59,9 @@ const kdfIterations = 10000; const userId = Utils.newGuid(); const masterPasswordHash = "MASTER_PASSWORD_HASH"; const name = "NAME"; +const defaultUserDecryptionOptionsServerResponse: IUserDecryptionOptionsServerResponse = { + HasMasterPassword: true, +}; const decodedToken = { sub: userId, @@ -55,13 +75,14 @@ const twoFactorToken = "TWO_FACTOR_TOKEN"; const twoFactorRemember = true; export function identityTokenResponseFactory( - masterPasswordPolicyResponse: MasterPasswordPolicyResponse = null + masterPasswordPolicyResponse: MasterPasswordPolicyResponse = null, + userDecryptionOptions: IUserDecryptionOptionsServerResponse = null ) { return new IdentityTokenResponse({ ForcePasswordReset: false, Kdf: kdf, KdfIterations: kdfIterations, - Key: encKey, + Key: userKey, PrivateKey: privateKey, ResetMasterPassword: false, access_token: accessToken, @@ -70,9 +91,11 @@ export function identityTokenResponseFactory( scope: "api offline_access", token_type: "Bearer", MasterPasswordPolicy: masterPasswordPolicyResponse, + UserDecryptionOptions: userDecryptionOptions || defaultUserDecryptionOptionsServerResponse, }); } +// TODO: add tests for latest changes to base class for TDE describe("LogInStrategy", () => { let cryptoService: MockProxy; let apiService: MockProxy; @@ -85,7 +108,7 @@ describe("LogInStrategy", () => { let twoFactorService: MockProxy; let authService: MockProxy; let policyService: MockProxy; - let passwordGenerationService: MockProxy; + let passwordStrengthService: MockProxy; let passwordLogInStrategy: PasswordLogInStrategy; let credentials: PasswordLogInCredentials; @@ -102,7 +125,7 @@ describe("LogInStrategy", () => { twoFactorService = mock(); authService = mock(); policyService = mock(); - passwordGenerationService = mock(); + passwordStrengthService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeToken.calledWith(accessToken).mockResolvedValue(decodedToken); @@ -118,7 +141,7 @@ describe("LogInStrategy", () => { logService, stateService, twoFactorService, - passwordGenerationService, + passwordStrengthService, policyService, authService ); @@ -126,8 +149,23 @@ describe("LogInStrategy", () => { }); describe("base class", () => { - it("sets the local environment after a successful login", async () => { - apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory()); + const userKeyBytesLength = 64; + const masterKeyBytesLength = 64; + let userKey: UserKey; + let masterKey: MasterKey; + + beforeEach(() => { + userKey = new SymmetricCryptoKey( + new Uint8Array(userKeyBytesLength).buffer as CsprngArray + ) as UserKey; + masterKey = new SymmetricCryptoKey( + new Uint8Array(masterKeyBytesLength).buffer as CsprngArray + ) as MasterKey; + }); + + it("sets the local environment after a successful login with master password", async () => { + const idTokenResponse = identityTokenResponseFactory(); + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); await passwordLogInStrategy.logIn(credentials); @@ -151,13 +189,36 @@ describe("LogInStrategy", () => { refreshToken: refreshToken, }, }, + keys: new AccountKeys(), + decryptionOptions: AccountDecryptionOptions.fromResponse(idTokenResponse), }) ); - expect(cryptoService.setEncKey).toHaveBeenCalledWith(encKey); - expect(cryptoService.setEncPrivateKey).toHaveBeenCalledWith(privateKey); expect(messagingService.send).toHaveBeenCalledWith("loggedIn"); }); + it("persists a device key for trusted device encryption when it exists on login", async () => { + // Arrange + const idTokenResponse = identityTokenResponseFactory(); + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + + const deviceKey = new SymmetricCryptoKey( + new Uint8Array(userKeyBytesLength).buffer as CsprngArray + ) as DeviceKey; + + stateService.getDeviceKey.mockResolvedValue(deviceKey); + + const accountKeys = new AccountKeys(); + accountKeys.deviceKey = deviceKey; + + // Act + await passwordLogInStrategy.logIn(credentials); + + // Assert + expect(stateService.addAccount).toHaveBeenCalledWith( + expect.objectContaining({ keys: accountKeys }) + ); + }); + it("builds AuthResult", async () => { const tokenResponse = identityTokenResponseFactory(); tokenResponse.forcePasswordReset = true; @@ -184,6 +245,8 @@ describe("LogInStrategy", () => { }); apiService.postIdentityToken.mockResolvedValue(tokenResponse); + cryptoService.getMasterKey.mockResolvedValue(masterKey); + cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); const result = await passwordLogInStrategy.logIn(credentials); @@ -201,13 +264,15 @@ describe("LogInStrategy", () => { cryptoService.makeKeyPair.mockResolvedValue(["PUBLIC_KEY", new EncString("PRIVATE_KEY")]); apiService.postIdentityToken.mockResolvedValue(tokenResponse); + cryptoService.getMasterKey.mockResolvedValue(masterKey); + cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await passwordLogInStrategy.logIn(credentials); - // User key must be set before the new RSA keypair is generated, otherwise we can't decrypt the EncKey - expect(cryptoService.setKey).toHaveBeenCalled(); + // User symmetric key must be set before the new RSA keypair is generated + expect(cryptoService.setUserKey).toHaveBeenCalled(); expect(cryptoService.makeKeyPair).toHaveBeenCalled(); - expect(cryptoService.setKey.mock.invocationCallOrder[0]).toBeLessThan( + expect(cryptoService.setUserKey.mock.invocationCallOrder[0]).toBeLessThan( cryptoService.makeKeyPair.mock.invocationCallOrder[0] ); diff --git a/libs/common/src/auth/login-strategies/login.strategy.ts b/libs/common/src/auth/login-strategies/login.strategy.ts index 2bcbee88e00..d8b0f5ca89d 100644 --- a/libs/common/src/auth/login-strategies/login.strategy.ts +++ b/libs/common/src/auth/login-strategies/login.strategy.ts @@ -1,12 +1,19 @@ import { ApiService } from "../../abstractions/api.service"; -import { AppIdService } from "../../abstractions/appId.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { LogService } from "../../abstractions/log.service"; -import { MessagingService } from "../../abstractions/messaging.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; -import { StateService } from "../../abstractions/state.service"; -import { Account, AccountProfile, AccountTokens } from "../../models/domain/account"; +import { ClientType } from "../../enums"; import { KeysRequest } from "../../models/request/keys.request"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { + Account, + AccountDecryptionOptions, + AccountKeys, + AccountProfile, + AccountTokens, +} from "../../platform/models/domain/account"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; import { TwoFactorProviderType } from "../enums/two-factor-provider-type"; @@ -53,9 +60,6 @@ export abstract class LogInStrategy { | PasswordlessLogInCredentials ): Promise; - // The user key comes from different sources depending on the login strategy - protected abstract setUserKey(response: IdentityTokenResponse): Promise; - async logInTwoFactor( twoFactor: TokenTwoFactorRequest, captchaResponse: string = null @@ -101,12 +105,28 @@ export abstract class LogInStrategy { protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) { const accountInformation = await this.tokenService.decodeToken(tokenResponse.accessToken); + + // Must persist existing device key if it exists for trusted device decryption to work + // However, we must provide a user id so that the device key can be retrieved + // as the state service won't have an active account at this point in time + // even though the data exists in local storage. + const userId = accountInformation.sub; + + const deviceKey = await this.stateService.getDeviceKey({ userId }); + const accountKeys = new AccountKeys(); + if (deviceKey) { + accountKeys.deviceKey = deviceKey; + } + + // If you don't persist existing admin auth requests on login, they will get deleted. + const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId }); + await this.stateService.addAccount( new Account({ profile: { ...new AccountProfile(), ...{ - userId: accountInformation.sub, + userId, name: accountInformation.name, email: accountInformation.email, hasPremiumPersonally: accountInformation.premium, @@ -123,40 +143,71 @@ export abstract class LogInStrategy { refreshToken: tokenResponse.refreshToken, }, }, + keys: accountKeys, + decryptionOptions: AccountDecryptionOptions.fromResponse(tokenResponse), + adminAuthRequest: adminAuthRequest?.toJSON(), }) ); } protected async processTokenResponse(response: IdentityTokenResponse): Promise { const result = new AuthResult(); + + // Old encryption keys must be migrated, but is currently only available on web. + // Other clients shouldn't continue the login process. + if (this.encryptionKeyMigrationRequired(response)) { + result.requiresEncryptionKeyMigration = true; + if (this.platformUtilsService.getClientType() !== ClientType.Web) { + return result; + } + } + result.resetMasterPassword = response.resetMasterPassword; + // Convert boolean to enum if (response.forcePasswordReset) { result.forcePasswordReset = ForceResetPasswordReason.AdminForcePasswordReset; } + // Must come before setting keys, user key needs email to update additional keys await this.saveAccountInformation(response); if (response.twoFactorToken != null) { await this.tokenService.setTwoFactorToken(response); } + await this.setMasterKey(response); await this.setUserKey(response); - - // Must come after the user Key is set, otherwise createKeyPairForOldAccount will fail - const newSsoUser = response.key == null; - if (!newSsoUser) { - await this.cryptoService.setEncKey(response.key); - await this.cryptoService.setEncPrivateKey( - response.privateKey ?? (await this.createKeyPairForOldAccount()) - ); - } + await this.setPrivateKey(response); this.messagingService.send("loggedIn"); return result; } + // The keys comes from different sources depending on the login strategy + protected abstract setMasterKey(response: IdentityTokenResponse): Promise; + + protected abstract setUserKey(response: IdentityTokenResponse): Promise; + + protected abstract setPrivateKey(response: IdentityTokenResponse): Promise; + + // Old accounts used master key for encryption. We are forcing migrations but only need to + // check on password logins + protected encryptionKeyMigrationRequired(response: IdentityTokenResponse): boolean { + return false; + } + + protected async createKeyPairForOldAccount() { + try { + const [publicKey, privateKey] = await this.cryptoService.makeKeyPair(); + await this.apiService.postAccountKeys(new KeysRequest(publicKey, privateKey.encryptedString)); + return privateKey.encryptedString; + } catch (e) { + this.logService.error(e); + } + } + private async processTwoFactorResponse(response: IdentityTwoFactorResponse): Promise { const result = new AuthResult(); result.twoFactorProviders = response.twoFactorProviders2; @@ -173,14 +224,4 @@ export abstract class LogInStrategy { result.captchaSiteKey = response.siteKey; return result; } - - private async createKeyPairForOldAccount() { - try { - const [publicKey, privateKey] = await this.cryptoService.makeKeyPair(); - await this.apiService.postAccountKeys(new KeysRequest(publicKey, privateKey.encryptedString)); - return privateKey.encryptedString; - } catch (e) { - this.logService.error(e); - } - } } diff --git a/libs/common/src/auth/login-strategies/password-login.strategy.spec.ts b/libs/common/src/auth/login-strategies/password-login.strategy.spec.ts index f13c87bdc5c..64cbc23d6ae 100644 --- a/libs/common/src/auth/login-strategies/password-login.strategy.spec.ts +++ b/libs/common/src/auth/login-strategies/password-login.strategy.spec.ts @@ -1,23 +1,32 @@ import { mock, MockProxy } from "jest-mock-extended"; import { ApiService } from "../../abstractions/api.service"; -import { AppIdService } from "../../abstractions/appId.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { LogService } from "../../abstractions/log.service"; -import { MessagingService } from "../../abstractions/messaging.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; -import { StateService } from "../../abstractions/state.service"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; import { HashPurpose } from "../../enums"; -import { Utils } from "../../misc/utils"; -import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { PasswordGenerationService } from "../../tools/generator/password"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; +import { + MasterKey, + SymmetricCryptoKey, + UserKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { + PasswordStrengthService, + PasswordStrengthServiceAbstraction, +} from "../../tools/password-strength"; +import { CsprngArray } from "../../types/csprng"; import { AuthService } from "../abstractions/auth.service"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; import { TwoFactorProviderType } from "../enums/two-factor-provider-type"; import { ForceResetPasswordReason } from "../models/domain/force-reset-password-reason"; import { PasswordLogInCredentials } from "../models/domain/log-in-credentials"; +import { IdentityTokenResponse } from "../models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response"; @@ -28,11 +37,11 @@ const email = "hello@world.com"; const masterPassword = "password"; const hashedPassword = "HASHED_PASSWORD"; const localHashedPassword = "LOCAL_HASHED_PASSWORD"; -const preloginKey = new SymmetricCryptoKey( +const masterKey = new SymmetricCryptoKey( Utils.fromB64ToArray( "N2KWjlLpfi5uHjv+YcfUKIpZ1l+W+6HRensmIqD+BFYBf6N/dvFpJfWwYnVBdgFCK2tJTAIMLhqzIQQEUmGFgg==" ) -); +) as MasterKey; const deviceId = Utils.newGuid(); const masterPasswordPolicy = new MasterPasswordPolicyResponse({ EnforceOnLogin: true, @@ -51,10 +60,11 @@ describe("PasswordLogInStrategy", () => { let twoFactorService: MockProxy; let authService: MockProxy; let policyService: MockProxy; - let passwordGenerationService: MockProxy; + let passwordStrengthService: MockProxy; let passwordLogInStrategy: PasswordLogInStrategy; let credentials: PasswordLogInCredentials; + let tokenResponse: IdentityTokenResponse; beforeEach(async () => { cryptoService = mock(); @@ -68,17 +78,17 @@ describe("PasswordLogInStrategy", () => { twoFactorService = mock(); authService = mock(); policyService = mock(); - passwordGenerationService = mock(); + passwordStrengthService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeToken.mockResolvedValue({}); - authService.makePreloginKey.mockResolvedValue(preloginKey); + authService.makePreloginKey.mockResolvedValue(masterKey); - cryptoService.hashPassword + cryptoService.hashMasterKey .calledWith(masterPassword, expect.anything(), undefined) .mockResolvedValue(hashedPassword); - cryptoService.hashPassword + cryptoService.hashMasterKey .calledWith(masterPassword, expect.anything(), HashPurpose.LocalAuthorization) .mockResolvedValue(localHashedPassword); @@ -94,15 +104,14 @@ describe("PasswordLogInStrategy", () => { logService, stateService, twoFactorService, - passwordGenerationService, + passwordStrengthService, policyService, authService ); credentials = new PasswordLogInCredentials(email, masterPassword); + tokenResponse = identityTokenResponseFactory(masterPasswordPolicy); - apiService.postIdentityToken.mockResolvedValue( - identityTokenResponseFactory(masterPasswordPolicy) - ); + apiService.postIdentityToken.mockResolvedValue(tokenResponse); }); it("sends master password credentials to the server", async () => { @@ -124,15 +133,23 @@ describe("PasswordLogInStrategy", () => { ); }); - it("sets the local environment after a successful login", async () => { + it("sets keys after a successful authentication", async () => { + const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + + cryptoService.getMasterKey.mockResolvedValue(masterKey); + cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); + await passwordLogInStrategy.logIn(credentials); - expect(cryptoService.setKey).toHaveBeenCalledWith(preloginKey); - expect(cryptoService.setKeyHash).toHaveBeenCalledWith(localHashedPassword); + expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey); + expect(cryptoService.setMasterKeyHash).toHaveBeenCalledWith(localHashedPassword); + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); + expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); + expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey); }); it("does not force the user to update their master password when there are no requirements", async () => { - apiService.postIdentityToken.mockResolvedValueOnce(identityTokenResponseFactory(null)); + apiService.postIdentityToken.mockResolvedValueOnce(identityTokenResponseFactory()); const result = await passwordLogInStrategy.logIn(credentials); @@ -141,7 +158,7 @@ describe("PasswordLogInStrategy", () => { }); it("does not force the user to update their master password when it meets requirements", async () => { - passwordGenerationService.passwordStrength.mockReturnValue({ score: 5 } as any); + passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 5 } as any); policyService.evaluateMasterPassword.mockReturnValue(true); const result = await passwordLogInStrategy.logIn(credentials); @@ -151,7 +168,7 @@ describe("PasswordLogInStrategy", () => { }); it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => { - passwordGenerationService.passwordStrength.mockReturnValue({ score: 0 } as any); + passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any); policyService.evaluateMasterPassword.mockReturnValue(false); const result = await passwordLogInStrategy.logIn(credentials); @@ -164,7 +181,7 @@ describe("PasswordLogInStrategy", () => { }); it("forces the user to update their master password on successful 2FA login when it does not meet master password policy requirements", async () => { - passwordGenerationService.passwordStrength.mockReturnValue({ score: 0 } as any); + passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any); policyService.evaluateMasterPassword.mockReturnValue(false); const token2FAResponse = new IdentityTwoFactorResponse({ diff --git a/libs/common/src/auth/login-strategies/password-login.strategy.ts b/libs/common/src/auth/login-strategies/password-login.strategy.ts index 272387940e5..0bcc679ae9a 100644 --- a/libs/common/src/auth/login-strategies/password-login.strategy.ts +++ b/libs/common/src/auth/login-strategies/password-login.strategy.ts @@ -1,15 +1,15 @@ import { ApiService } from "../../abstractions/api.service"; -import { AppIdService } from "../../abstractions/appId.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { LogService } from "../../abstractions/log.service"; -import { MessagingService } from "../../abstractions/messaging.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; -import { StateService } from "../../abstractions/state.service"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "../../admin-console/models/domain/master-password-policy-options"; import { HashPurpose } from "../../enums"; -import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { PasswordGenerationServiceAbstraction } from "../../tools/generator/password"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { MasterKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { PasswordStrengthServiceAbstraction } from "../../tools/password-strength"; import { AuthService } from "../abstractions/auth.service"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; @@ -35,8 +35,8 @@ export class PasswordLogInStrategy extends LogInStrategy { tokenRequest: PasswordTokenRequest; - private localHashedPassword: string; - private key: SymmetricCryptoKey; + private localMasterKeyHash: string; + private masterKey: MasterKey; /** * Options to track if the user needs to update their password due to a password that does not meet an organization's @@ -54,7 +54,7 @@ export class PasswordLogInStrategy extends LogInStrategy { logService: LogService, protected stateService: StateService, twoFactorService: TwoFactorService, - private passwordGenerationService: PasswordGenerationServiceAbstraction, + private passwordStrengthService: PasswordStrengthServiceAbstraction, private policyService: PolicyService, private authService: AuthService ) { @@ -71,12 +71,7 @@ export class PasswordLogInStrategy extends LogInStrategy { ); } - async setUserKey() { - await this.cryptoService.setKey(this.key); - await this.cryptoService.setKeyHash(this.localHashedPassword); - } - - async logInTwoFactor( + override async logInTwoFactor( twoFactor: TokenTwoFactorRequest, captchaResponse: string ): Promise { @@ -96,28 +91,29 @@ export class PasswordLogInStrategy extends LogInStrategy { return result; } - async logIn(credentials: PasswordLogInCredentials) { + override async logIn(credentials: PasswordLogInCredentials) { const { email, masterPassword, captchaToken, twoFactor } = credentials; - this.key = await this.authService.makePreloginKey(masterPassword, email); + this.masterKey = await this.authService.makePreloginKey(masterPassword, email); // Hash the password early (before authentication) so we don't persist it in memory in plaintext - this.localHashedPassword = await this.cryptoService.hashPassword( + this.localMasterKeyHash = await this.cryptoService.hashMasterKey( masterPassword, - this.key, + this.masterKey, HashPurpose.LocalAuthorization ); - const hashedPassword = await this.cryptoService.hashPassword(masterPassword, this.key); + const masterKeyHash = await this.cryptoService.hashMasterKey(masterPassword, this.masterKey); this.tokenRequest = new PasswordTokenRequest( email, - hashedPassword, + masterKeyHash, captchaToken, await this.buildTwoFactor(twoFactor), await this.buildDeviceRequest() ); const [authResult, identityResponse] = await this.startLogIn(); + const masterPasswordPolicyOptions = this.getMasterPasswordPolicyOptionsFromResponse(identityResponse); @@ -145,6 +141,35 @@ export class PasswordLogInStrategy extends LogInStrategy { return authResult; } + protected override async setMasterKey(response: IdentityTokenResponse) { + await this.cryptoService.setMasterKey(this.masterKey); + await this.cryptoService.setMasterKeyHash(this.localMasterKeyHash); + } + + protected override async setUserKey(response: IdentityTokenResponse): Promise { + // If migration is required, we won't have a user key to set yet. + if (this.encryptionKeyMigrationRequired(response)) { + return; + } + await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); + + const masterKey = await this.cryptoService.getMasterKey(); + if (masterKey) { + const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); + await this.cryptoService.setUserKey(userKey); + } + } + + protected override async setPrivateKey(response: IdentityTokenResponse): Promise { + await this.cryptoService.setPrivateKey( + response.privateKey ?? (await this.createKeyPairForOldAccount()) + ); + } + + protected override encryptionKeyMigrationRequired(response: IdentityTokenResponse): boolean { + return !response.key; + } + private getMasterPasswordPolicyOptionsFromResponse( response: IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse ): MasterPasswordPolicyOptions { @@ -158,7 +183,7 @@ export class PasswordLogInStrategy extends LogInStrategy { { masterPassword, email }: PasswordLogInCredentials, options: MasterPasswordPolicyOptions ): boolean { - const passwordStrength = this.passwordGenerationService.passwordStrength( + const passwordStrength = this.passwordStrengthService.getPasswordStrength( masterPassword, email )?.score; diff --git a/libs/common/src/auth/login-strategies/passwordless-login.strategy.spec.ts b/libs/common/src/auth/login-strategies/passwordless-login.strategy.spec.ts new file mode 100644 index 00000000000..8bbd36436ca --- /dev/null +++ b/libs/common/src/auth/login-strategies/passwordless-login.strategy.spec.ts @@ -0,0 +1,138 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "../../abstractions/api.service"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; +import { + MasterKey, + SymmetricCryptoKey, + UserKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../types/csprng"; +import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; +import { TokenService } from "../abstractions/token.service"; +import { TwoFactorService } from "../abstractions/two-factor.service"; +import { PasswordlessLogInCredentials } from "../models/domain/log-in-credentials"; +import { IdentityTokenResponse } from "../models/response/identity-token.response"; + +import { identityTokenResponseFactory } from "./login.strategy.spec"; +import { PasswordlessLogInStrategy } from "./passwordless-login.strategy"; + +describe("PasswordlessLogInStrategy", () => { + let cryptoService: MockProxy; + let apiService: MockProxy; + let tokenService: MockProxy; + let appIdService: MockProxy; + let platformUtilsService: MockProxy; + let messagingService: MockProxy; + let logService: MockProxy; + let stateService: MockProxy; + let twoFactorService: MockProxy; + let deviceTrustCryptoService: MockProxy; + + let passwordlessLoginStrategy: PasswordlessLogInStrategy; + let credentials: PasswordlessLogInCredentials; + let tokenResponse: IdentityTokenResponse; + + const deviceId = Utils.newGuid(); + + const email = "EMAIL"; + const accessCode = "ACCESS_CODE"; + const authRequestId = "AUTH_REQUEST_ID"; + const decMasterKey = new SymmetricCryptoKey( + new Uint8Array(64).buffer as CsprngArray + ) as MasterKey; + const decUserKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + const decMasterKeyHash = "LOCAL_PASSWORD_HASH"; + + beforeEach(async () => { + cryptoService = mock(); + apiService = mock(); + tokenService = mock(); + appIdService = mock(); + platformUtilsService = mock(); + messagingService = mock(); + logService = mock(); + stateService = mock(); + twoFactorService = mock(); + deviceTrustCryptoService = mock(); + + tokenService.getTwoFactorToken.mockResolvedValue(null); + appIdService.getAppId.mockResolvedValue(deviceId); + tokenService.decodeToken.mockResolvedValue({}); + + passwordlessLoginStrategy = new PasswordlessLogInStrategy( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService, + deviceTrustCryptoService + ); + + tokenResponse = identityTokenResponseFactory(); + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + }); + + it("sets keys after a successful authentication when masterKey and masterKeyHash provided in login credentials", async () => { + credentials = new PasswordlessLogInCredentials( + email, + accessCode, + authRequestId, + null, + decMasterKey, + decMasterKeyHash + ); + + const masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; + const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + + cryptoService.getMasterKey.mockResolvedValue(masterKey); + cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); + + await passwordlessLoginStrategy.logIn(credentials); + + expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey); + expect(cryptoService.setMasterKeyHash).toHaveBeenCalledWith(decMasterKeyHash); + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); + expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); + expect(deviceTrustCryptoService.trustDeviceIfRequired).toHaveBeenCalled(); + expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey); + }); + + it("sets keys after a successful authentication when only userKey provided in login credentials", async () => { + // Initialize credentials with only userKey + credentials = new PasswordlessLogInCredentials( + email, + accessCode, + authRequestId, + decUserKey, // Pass userKey + null, // No masterKey + null // No masterKeyHash + ); + + // Call logIn + await passwordlessLoginStrategy.logIn(credentials); + + // setMasterKey and setMasterKeyHash should not be called + expect(cryptoService.setMasterKey).not.toHaveBeenCalled(); + expect(cryptoService.setMasterKeyHash).not.toHaveBeenCalled(); + + // setMasterKeyEncryptedUserKey, setUserKey, and setPrivateKey should still be called + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); + expect(cryptoService.setUserKey).toHaveBeenCalledWith(decUserKey); + expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey); + + // trustDeviceIfRequired should be called + expect(deviceTrustCryptoService.trustDeviceIfRequired).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/common/src/auth/login-strategies/passwordless-login.strategy.ts b/libs/common/src/auth/login-strategies/passwordless-login.strategy.ts index b42d200f405..bcaacb69f18 100644 --- a/libs/common/src/auth/login-strategies/passwordless-login.strategy.ts +++ b/libs/common/src/auth/login-strategies/passwordless-login.strategy.ts @@ -1,17 +1,18 @@ import { ApiService } from "../../abstractions/api.service"; -import { AppIdService } from "../../abstractions/appId.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { LogService } from "../../abstractions/log.service"; -import { MessagingService } from "../../abstractions/messaging.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; -import { StateService } from "../../abstractions/state.service"; -import { AuthService } from "../abstractions/auth.service"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; import { AuthResult } from "../models/domain/auth-result"; import { PasswordlessLogInCredentials } from "../models/domain/log-in-credentials"; import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request"; import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request"; +import { IdentityTokenResponse } from "../models/response/identity-token.response"; import { LogInStrategy } from "./login.strategy"; @@ -41,7 +42,7 @@ export class PasswordlessLogInStrategy extends LogInStrategy { logService: LogService, stateService: StateService, twoFactorService: TwoFactorService, - private authService: AuthService + private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction ) { super( cryptoService, @@ -56,20 +57,7 @@ export class PasswordlessLogInStrategy extends LogInStrategy { ); } - async setUserKey() { - await this.cryptoService.setKey(this.passwordlessCredentials.decKey); - await this.cryptoService.setKeyHash(this.passwordlessCredentials.localPasswordHash); - } - - async logInTwoFactor( - twoFactor: TokenTwoFactorRequest, - captchaResponse: string - ): Promise { - this.tokenRequest.captchaResponse = captchaResponse ?? this.captchaBypassToken; - return super.logInTwoFactor(twoFactor); - } - - async logIn(credentials: PasswordlessLogInCredentials) { + override async logIn(credentials: PasswordlessLogInCredentials) { this.passwordlessCredentials = credentials; this.tokenRequest = new PasswordTokenRequest( @@ -84,4 +72,52 @@ export class PasswordlessLogInStrategy extends LogInStrategy { const [authResult] = await this.startLogIn(); return authResult; } + + override async logInTwoFactor( + twoFactor: TokenTwoFactorRequest, + captchaResponse: string + ): Promise { + this.tokenRequest.captchaResponse = captchaResponse ?? this.captchaBypassToken; + return super.logInTwoFactor(twoFactor); + } + + protected override async setMasterKey(response: IdentityTokenResponse) { + if ( + this.passwordlessCredentials.decryptedMasterKey && + this.passwordlessCredentials.decryptedMasterKeyHash + ) { + await this.cryptoService.setMasterKey(this.passwordlessCredentials.decryptedMasterKey); + await this.cryptoService.setMasterKeyHash( + this.passwordlessCredentials.decryptedMasterKeyHash + ); + } + } + + protected override async setUserKey(response: IdentityTokenResponse): Promise { + // User now may or may not have a master password + // but set the master key encrypted user key if it exists regardless + await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); + + if (this.passwordlessCredentials.decryptedUserKey) { + await this.cryptoService.setUserKey(this.passwordlessCredentials.decryptedUserKey); + } else { + await this.trySetUserKeyWithMasterKey(); + // Establish trust if required after setting user key + await this.deviceTrustCryptoService.trustDeviceIfRequired(); + } + } + + private async trySetUserKeyWithMasterKey(): Promise { + const masterKey = await this.cryptoService.getMasterKey(); + if (masterKey) { + const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); + await this.cryptoService.setUserKey(userKey); + } + } + + protected override async setPrivateKey(response: IdentityTokenResponse): Promise { + await this.cryptoService.setPrivateKey( + response.privateKey ?? (await this.createKeyPairForOldAccount()) + ); + } } diff --git a/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts b/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts index d1d34aa2568..f078a7b86b1 100644 --- a/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts +++ b/libs/common/src/auth/login-strategies/sso-login.strategy.spec.ts @@ -1,21 +1,36 @@ import { mock, MockProxy } from "jest-mock-extended"; import { ApiService } from "../../abstractions/api.service"; -import { AppIdService } from "../../abstractions/appId.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { LogService } from "../../abstractions/log.service"; -import { MessagingService } from "../../abstractions/messaging.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; -import { StateService } from "../../abstractions/state.service"; -import { Utils } from "../../misc/utils"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; +import { + DeviceKey, + MasterKey, + SymmetricCryptoKey, + UserKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../types/csprng"; +import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction"; +import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; import { SsoLogInCredentials } from "../models/domain/log-in-credentials"; +import { IdentityTokenResponse } from "../models/response/identity-token.response"; +import { IUserDecryptionOptionsServerResponse } from "../models/response/user-decryption-options/user-decryption-options.response"; import { identityTokenResponseFactory } from "./login.strategy.spec"; import { SsoLogInStrategy } from "./sso-login.strategy"; +// TODO: Add tests for new trySetUserKeyWithApprovedAdminRequestIfExists logic +// https://bitwarden.atlassian.net/browse/PM-3339 + describe("SsoLogInStrategy", () => { let cryptoService: MockProxy; let apiService: MockProxy; @@ -27,6 +42,9 @@ describe("SsoLogInStrategy", () => { let stateService: MockProxy; let twoFactorService: MockProxy; let keyConnectorService: MockProxy; + let deviceTrustCryptoService: MockProxy; + let authRequestCryptoService: MockProxy; + let i18nService: MockProxy; let ssoLogInStrategy: SsoLogInStrategy; let credentials: SsoLogInCredentials; @@ -50,6 +68,9 @@ describe("SsoLogInStrategy", () => { stateService = mock(); twoFactorService = mock(); keyConnectorService = mock(); + deviceTrustCryptoService = mock(); + authRequestCryptoService = mock(); + i18nService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -65,7 +86,10 @@ describe("SsoLogInStrategy", () => { logService, stateService, twoFactorService, - keyConnectorService + keyConnectorService, + deviceTrustCryptoService, + authRequestCryptoService, + i18nService ); credentials = new SsoLogInCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId); }); @@ -98,33 +122,248 @@ describe("SsoLogInStrategy", () => { await ssoLogInStrategy.logIn(credentials); - expect(cryptoService.setEncPrivateKey).not.toHaveBeenCalled(); - expect(cryptoService.setEncKey).not.toHaveBeenCalled(); + expect(cryptoService.setMasterKey).not.toHaveBeenCalled(); + expect(cryptoService.setUserKey).not.toHaveBeenCalled(); + expect(cryptoService.setPrivateKey).not.toHaveBeenCalled(); }); - it("gets and sets KeyConnector key for enrolled user", async () => { + it("sets master key encrypted user key for existing SSO users", async () => { + // Arrange const tokenResponse = identityTokenResponseFactory(); - tokenResponse.keyConnectorUrl = keyConnectorUrl; - apiService.postIdentityToken.mockResolvedValue(tokenResponse); + // Act await ssoLogInStrategy.logIn(credentials); - expect(keyConnectorService.getAndSetKey).toHaveBeenCalledWith(keyConnectorUrl); + // Assert + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledTimes(1); + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); }); - it("converts new SSO user to Key Connector on first login", async () => { - const tokenResponse = identityTokenResponseFactory(); - tokenResponse.keyConnectorUrl = keyConnectorUrl; - tokenResponse.key = null; + describe("Trusted Device Decryption", () => { + const deviceKeyBytesLength = 64; + const mockDeviceKeyRandomBytes = new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray; + const mockDeviceKey: DeviceKey = new SymmetricCryptoKey(mockDeviceKeyRandomBytes) as DeviceKey; - apiService.postIdentityToken.mockResolvedValue(tokenResponse); + const userKeyBytesLength = 64; + const mockUserKeyRandomBytes = new Uint8Array(userKeyBytesLength).buffer as CsprngArray; + const mockUserKey: UserKey = new SymmetricCryptoKey(mockUserKeyRandomBytes) as UserKey; - await ssoLogInStrategy.logIn(credentials); + const mockEncDevicePrivateKey = + "2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc="; - expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith( - tokenResponse, - ssoOrgId - ); + const mockEncUserKey = + "4.Xht6K9GA9jKcSNy4TaIvdj7f9+WsgQycs/HdkrJi33aC//roKkjf3UTGpdzFLxVP3WhyOVGyo9f2Jymf1MFPdpg7AuMnpGJlcrWLDbnPjOJo4x5gUwwBUmy3nFw6+wamyS1LRmrBPcv56yKpf80k5Q3hUrum8q9YS9m2I10vklX/TaB1YML0yo+K1feWUxg8vIx+vloxhUdkkysvcV5xU3R+AgYLrwvJS8TLL7Ug/P5HxinCaIroRrNe8xcv84vyVnzPFdXe0cfZ0cpcrm586LwfEXP2seeldO/bC51Uk/mudeSALJURPC64f5ch2cOvk48GOTapGnssCqr6ky5yFw=="; + + const userDecryptionOptsServerResponseWithTdeOption: IUserDecryptionOptionsServerResponse = { + HasMasterPassword: true, + TrustedDeviceOption: { + HasAdminApproval: true, + HasLoginApprovingDevice: true, + HasManageResetPasswordPermission: false, + EncryptedPrivateKey: mockEncDevicePrivateKey, + EncryptedUserKey: mockEncUserKey, + }, + }; + + const mockIdTokenResponseWithModifiedTrustedDeviceOption = (key: string, value: any) => { + const userDecryptionOpts: IUserDecryptionOptionsServerResponse = { + ...userDecryptionOptsServerResponseWithTdeOption, + TrustedDeviceOption: { + ...userDecryptionOptsServerResponseWithTdeOption.TrustedDeviceOption, + [key]: value, + }, + }; + return identityTokenResponseFactory(null, userDecryptionOpts); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("decrypts and sets user key when trusted device decryption option exists with valid device key and enc key data", async () => { + // Arrange + const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( + null, + userDecryptionOptsServerResponseWithTdeOption + ); + + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + deviceTrustCryptoService.getDeviceKey.mockResolvedValue(mockDeviceKey); + deviceTrustCryptoService.decryptUserKeyWithDeviceKey.mockResolvedValue(mockUserKey); + + const cryptoSvcSetUserKeySpy = jest.spyOn(cryptoService, "setUserKey"); + + // Act + await ssoLogInStrategy.logIn(credentials); + + // Assert + expect(deviceTrustCryptoService.getDeviceKey).toHaveBeenCalledTimes(1); + expect(deviceTrustCryptoService.decryptUserKeyWithDeviceKey).toHaveBeenCalledTimes(1); + expect(cryptoSvcSetUserKeySpy).toHaveBeenCalledTimes(1); + expect(cryptoSvcSetUserKeySpy).toHaveBeenCalledWith(mockUserKey); + }); + + it("does not set the user key when deviceKey is missing", async () => { + // Arrange + const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( + null, + userDecryptionOptsServerResponseWithTdeOption + ); + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + // Set deviceKey to be null + deviceTrustCryptoService.getDeviceKey.mockResolvedValue(null); + deviceTrustCryptoService.decryptUserKeyWithDeviceKey.mockResolvedValue(mockUserKey); + + // Act + await ssoLogInStrategy.logIn(credentials); + + // Assert + expect(cryptoService.setUserKey).not.toHaveBeenCalled(); + }); + + describe.each([ + { + valueName: "encDevicePrivateKey", + }, + { + valueName: "encUserKey", + }, + ])("given trusted device decryption option has missing encrypted key data", ({ valueName }) => { + it(`does not set the user key when ${valueName} is missing`, async () => { + // Arrange + const idTokenResponse = mockIdTokenResponseWithModifiedTrustedDeviceOption(valueName, null); + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + deviceTrustCryptoService.getDeviceKey.mockResolvedValue(mockDeviceKey); + + // Act + await ssoLogInStrategy.logIn(credentials); + + // Assert + expect(cryptoService.setUserKey).not.toHaveBeenCalled(); + }); + }); + + it("does not set user key when decrypted user key is null", async () => { + // Arrange + const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( + null, + userDecryptionOptsServerResponseWithTdeOption + ); + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + deviceTrustCryptoService.getDeviceKey.mockResolvedValue(mockDeviceKey); + // Set userKey to be null + deviceTrustCryptoService.decryptUserKeyWithDeviceKey.mockResolvedValue(null); + + // Act + await ssoLogInStrategy.logIn(credentials); + + // Assert + expect(cryptoService.setUserKey).not.toHaveBeenCalled(); + }); + }); + + describe("Key Connector", () => { + let tokenResponse: IdentityTokenResponse; + beforeEach(() => { + tokenResponse = identityTokenResponseFactory(null, { + HasMasterPassword: false, + KeyConnectorOption: { KeyConnectorUrl: keyConnectorUrl }, + }); + tokenResponse.keyConnectorUrl = keyConnectorUrl; + }); + + it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => { + const masterKey = new SymmetricCryptoKey( + new Uint8Array(64).buffer as CsprngArray + ) as MasterKey; + + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + cryptoService.getMasterKey.mockResolvedValue(masterKey); + + await ssoLogInStrategy.logIn(credentials); + + expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl); + }); + + it("converts new SSO user with no master password to Key Connector on first login", async () => { + tokenResponse.key = null; + + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + + await ssoLogInStrategy.logIn(credentials); + + expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith( + tokenResponse, + ssoOrgId + ); + }); + + it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => { + const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + const masterKey = new SymmetricCryptoKey( + new Uint8Array(64).buffer as CsprngArray + ) as MasterKey; + + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + cryptoService.getMasterKey.mockResolvedValue(masterKey); + cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); + + await ssoLogInStrategy.logIn(credentials); + + expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(masterKey); + expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); + }); + }); + + describe("Key Connector Pre-TDE", () => { + let tokenResponse: IdentityTokenResponse; + beforeEach(() => { + tokenResponse = identityTokenResponseFactory(); + tokenResponse.userDecryptionOptions = null; + tokenResponse.keyConnectorUrl = keyConnectorUrl; + }); + + it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => { + const masterKey = new SymmetricCryptoKey( + new Uint8Array(64).buffer as CsprngArray + ) as MasterKey; + + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + cryptoService.getMasterKey.mockResolvedValue(masterKey); + + await ssoLogInStrategy.logIn(credentials); + + expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl); + }); + + it("converts new SSO user with no master password to Key Connector on first login", async () => { + tokenResponse.key = null; + + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + + await ssoLogInStrategy.logIn(credentials); + + expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith( + tokenResponse, + ssoOrgId + ); + }); + + it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => { + const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + const masterKey = new SymmetricCryptoKey( + new Uint8Array(64).buffer as CsprngArray + ) as MasterKey; + + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + cryptoService.getMasterKey.mockResolvedValue(masterKey); + cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); + + await ssoLogInStrategy.logIn(credentials); + + expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(masterKey); + expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); + }); }); }); diff --git a/libs/common/src/auth/login-strategies/sso-login.strategy.ts b/libs/common/src/auth/login-strategies/sso-login.strategy.ts index 2285d1b6b4e..328dda527ae 100644 --- a/libs/common/src/auth/login-strategies/sso-login.strategy.ts +++ b/libs/common/src/auth/login-strategies/sso-login.strategy.ts @@ -1,13 +1,20 @@ import { ApiService } from "../../abstractions/api.service"; -import { AppIdService } from "../../abstractions/appId.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { LogService } from "../../abstractions/log.service"; -import { MessagingService } from "../../abstractions/messaging.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; -import { StateService } from "../../abstractions/state.service"; +import { AuthRequestResponse } from "../../auth/models/response/auth-request.response"; +import { HttpStatusCode } from "../../enums"; +import { ErrorResponse } from "../../models/response/error.response"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction"; +import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; +import { ForceResetPasswordReason } from "../models/domain/force-reset-password-reason"; import { SsoLogInCredentials } from "../models/domain/log-in-credentials"; import { SsoTokenRequest } from "../models/request/identity-token/sso-token.request"; import { IdentityTokenResponse } from "../models/response/identity-token.response"; @@ -34,7 +41,10 @@ export class SsoLogInStrategy extends LogInStrategy { logService: LogService, stateService: StateService, twoFactorService: TwoFactorService, - private keyConnectorService: KeyConnectorService + private keyConnectorService: KeyConnectorService, + private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + private authReqCryptoService: AuthRequestCryptoServiceAbstraction, + private i18nService: I18nService ) { super( cryptoService, @@ -49,18 +59,6 @@ export class SsoLogInStrategy extends LogInStrategy { ); } - async setUserKey(tokenResponse: IdentityTokenResponse) { - const newSsoUser = tokenResponse.key == null; - - if (tokenResponse.keyConnectorUrl != null) { - if (!newSsoUser) { - await this.keyConnectorService.getAndSetKey(tokenResponse.keyConnectorUrl); - } else { - await this.keyConnectorService.convertNewSsoUserToKeyConnector(tokenResponse, this.orgId); - } - } - } - async logIn(credentials: SsoLogInCredentials) { this.orgId = credentials.orgId; this.tokenRequest = new SsoTokenRequest( @@ -76,6 +74,201 @@ export class SsoLogInStrategy extends LogInStrategy { this.email = ssoAuthResult.email; this.ssoEmail2FaSessionToken = ssoAuthResult.ssoEmail2FaSessionToken; + // Auth guard currently handles redirects for this. + if (ssoAuthResult.forcePasswordReset == ForceResetPasswordReason.AdminForcePasswordReset) { + await this.stateService.setForcePasswordResetReason(ssoAuthResult.forcePasswordReset); + } + return ssoAuthResult; } + + protected override async setMasterKey(tokenResponse: IdentityTokenResponse) { + // The only way we can be setting a master key at this point is if we are using Key Connector. + // First, check to make sure that we should do so based on the token response. + if (this.shouldSetMasterKeyFromKeyConnector(tokenResponse)) { + // If we're here, we know that the user should use Key Connector (they have a KeyConnectorUrl) and does not have a master password. + // We can now check the key on the token response to see whether they are a brand new user or an existing user. + // The presence of a masterKeyEncryptedUserKey indicates that the user has already been provisioned in Key Connector. + const newSsoUser = tokenResponse.key == null; + if (newSsoUser) { + await this.keyConnectorService.convertNewSsoUserToKeyConnector(tokenResponse, this.orgId); + } else { + const keyConnectorUrl = this.getKeyConnectorUrl(tokenResponse); + await this.keyConnectorService.setMasterKeyFromUrl(keyConnectorUrl); + } + } + } + + /** + * Determines if it is possible set the `masterKey` from Key Connector. + * @param tokenResponse + * @returns `true` if the master key can be set from Key Connector, `false` otherwise + */ + private shouldSetMasterKeyFromKeyConnector(tokenResponse: IdentityTokenResponse): boolean { + const userDecryptionOptions = tokenResponse?.userDecryptionOptions; + + if (userDecryptionOptions != null) { + const userHasMasterPassword = userDecryptionOptions.hasMasterPassword; + const userHasKeyConnectorUrl = + userDecryptionOptions.keyConnectorOption?.keyConnectorUrl != null; + + // In order for us to set the master key from Key Connector, we need to have a Key Connector URL + // and the user must not have a master password. + return userHasKeyConnectorUrl && !userHasMasterPassword; + } else { + // In pre-TDE versions of the server, the userDecryptionOptions will not be present. + // In this case, we can determine if the user has a master password and has a Key Connector URL by + // just checking the keyConnectorUrl property. This is because the server short-circuits on the response + // and will not pass back the URL in the response if the user has a master password. + // TODO: remove compatibility check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537) + return tokenResponse.keyConnectorUrl != null; + } + } + + private getKeyConnectorUrl(tokenResponse: IdentityTokenResponse): string { + // TODO: remove tokenResponse.keyConnectorUrl reference after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537) + const userDecryptionOptions = tokenResponse?.userDecryptionOptions; + return ( + tokenResponse.keyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl + ); + } + + // TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request) + // so might be worth moving this logic to a common place (base login strategy or a separate service?) + protected override async setUserKey(tokenResponse: IdentityTokenResponse): Promise { + const masterKeyEncryptedUserKey = tokenResponse.key; + + // Note: masterKeyEncryptedUserKey is undefined for SSO JIT provisioned users + // on account creation and subsequent logins (confirmed or unconfirmed) + // but that is fine for TDE so we cannot return if it is undefined + + if (masterKeyEncryptedUserKey) { + // set the master key encrypted user key if it exists + await this.cryptoService.setMasterKeyEncryptedUserKey(masterKeyEncryptedUserKey); + } + + const userDecryptionOptions = tokenResponse?.userDecryptionOptions; + + // Note: TDE and key connector are mutually exclusive + if (userDecryptionOptions?.trustedDeviceOption) { + await this.trySetUserKeyWithApprovedAdminRequestIfExists(); + + const hasUserKey = await this.cryptoService.hasUserKey(); + + // Only try to set user key with device key if admin approval request was not successful + if (!hasUserKey) { + await this.trySetUserKeyWithDeviceKey(tokenResponse); + } + } else if ( + masterKeyEncryptedUserKey != null && + this.getKeyConnectorUrl(tokenResponse) != null + ) { + // Key connector enabled for user + await this.trySetUserKeyWithMasterKey(); + } + + // Note: In the traditional SSO flow with MP without key connector, the lock component + // is responsible for deriving master key from MP entry and then decrypting the user key + } + + private async trySetUserKeyWithApprovedAdminRequestIfExists(): Promise { + // At this point a user could have an admin auth request that has been approved + const adminAuthReqStorable = await this.stateService.getAdminAuthRequest(); + + if (!adminAuthReqStorable) { + return; + } + + // Call server to see if admin auth request has been approved + let adminAuthReqResponse: AuthRequestResponse; + + try { + adminAuthReqResponse = await this.apiService.getAuthRequest(adminAuthReqStorable.id); + } catch (error) { + if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) { + // if we get a 404, it means the auth request has been deleted so clear it from storage + await this.stateService.setAdminAuthRequest(null); + } + + // Always return on an error here as we don't want to block the user from logging in + return; + } + + if (adminAuthReqResponse?.requestApproved) { + // if masterPasswordHash has a value, we will always receive authReqResponse.key + // as authRequestPublicKey(masterKey) + authRequestPublicKey(masterPasswordHash) + if (adminAuthReqResponse.masterPasswordHash) { + await this.authReqCryptoService.setKeysAfterDecryptingSharedMasterKeyAndHash( + adminAuthReqResponse, + adminAuthReqStorable.privateKey + ); + } else { + // if masterPasswordHash is null, we will always receive authReqResponse.key + // as authRequestPublicKey(userKey) + await this.authReqCryptoService.setUserKeyAfterDecryptingSharedUserKey( + adminAuthReqResponse, + adminAuthReqStorable.privateKey + ); + } + + if (await this.cryptoService.hasUserKey()) { + // Now that we have a decrypted user key in memory, we can check if we + // need to establish trust on the current device + await this.deviceTrustCryptoService.trustDeviceIfRequired(); + + // if we successfully decrypted the user key, we can delete the admin auth request out of state + // TODO: eventually we post and clean up DB as well once consumed on client + await this.stateService.setAdminAuthRequest(null); + + this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved")); + } + } + } + + private async trySetUserKeyWithDeviceKey(tokenResponse: IdentityTokenResponse): Promise { + const trustedDeviceOption = tokenResponse.userDecryptionOptions?.trustedDeviceOption; + + const deviceKey = await this.deviceTrustCryptoService.getDeviceKey(); + const encDevicePrivateKey = trustedDeviceOption?.encryptedPrivateKey; + const encUserKey = trustedDeviceOption?.encryptedUserKey; + + if (!deviceKey || !encDevicePrivateKey || !encUserKey) { + return; + } + + const userKey = await this.deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + encDevicePrivateKey, + encUserKey, + deviceKey + ); + + if (userKey) { + await this.cryptoService.setUserKey(userKey); + } + } + + private async trySetUserKeyWithMasterKey(): Promise { + const masterKey = await this.cryptoService.getMasterKey(); + + // There is a scenario in which the master key is not set here. That will occur if the user + // has a master password and is using Key Connector. In that case, we cannot set the master key + // because the user hasn't entered their master password yet. + // Instead, we'll return here and let the migration to Key Connector handle setting the master key. + if (!masterKey) { + return; + } + + const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); + await this.cryptoService.setUserKey(userKey); + } + + protected override async setPrivateKey(tokenResponse: IdentityTokenResponse): Promise { + const newSsoUser = tokenResponse.key == null; + + if (!newSsoUser) { + await this.cryptoService.setPrivateKey( + tokenResponse.privateKey ?? (await this.createKeyPairForOldAccount()) + ); + } + } } diff --git a/libs/common/src/auth/login-strategies/user-api-login.strategy.spec.ts b/libs/common/src/auth/login-strategies/user-api-login.strategy.spec.ts index 66452f44577..044b594b89d 100644 --- a/libs/common/src/auth/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/common/src/auth/login-strategies/user-api-login.strategy.spec.ts @@ -1,14 +1,20 @@ import { mock, MockProxy } from "jest-mock-extended"; import { ApiService } from "../../abstractions/api.service"; -import { AppIdService } from "../../abstractions/appId.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { EnvironmentService } from "../../abstractions/environment.service"; -import { LogService } from "../../abstractions/log.service"; -import { MessagingService } from "../../abstractions/messaging.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; -import { StateService } from "../../abstractions/state.service"; -import { Utils } from "../../misc/utils"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { EnvironmentService } from "../../platform/abstractions/environment.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; +import { + MasterKey, + SymmetricCryptoKey, + UserKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../types/csprng"; import { KeyConnectorService } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; @@ -101,15 +107,44 @@ describe("UserApiLogInStrategy", () => { expect(stateService.addAccount).toHaveBeenCalled(); }); - it("gets and sets the Key Connector key from environmentUrl", async () => { + it("sets the encrypted user key and private key from the identity token response", async () => { + const tokenResponse = identityTokenResponseFactory(); + + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + + await apiLogInStrategy.logIn(credentials); + + expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); + expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey); + }); + + it("gets and sets the master key if Key Connector is enabled", async () => { + const tokenResponse = identityTokenResponseFactory(); + tokenResponse.apiUseKeyConnector = true; + + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + environmentService.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl); + + await apiLogInStrategy.logIn(credentials); + + expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl); + }); + + it("decrypts and sets the user key if Key Connector is enabled", async () => { + const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; + const masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; + const tokenResponse = identityTokenResponseFactory(); tokenResponse.apiUseKeyConnector = true; apiService.postIdentityToken.mockResolvedValue(tokenResponse); environmentService.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl); + cryptoService.getMasterKey.mockResolvedValue(masterKey); + cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await apiLogInStrategy.logIn(credentials); - expect(keyConnectorService.getAndSetKey).toHaveBeenCalledWith(keyConnectorUrl); + expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(masterKey); + expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); }); }); diff --git a/libs/common/src/auth/login-strategies/user-api-login.strategy.ts b/libs/common/src/auth/login-strategies/user-api-login.strategy.ts index ce0b52e51e0..80aed0d1538 100644 --- a/libs/common/src/auth/login-strategies/user-api-login.strategy.ts +++ b/libs/common/src/auth/login-strategies/user-api-login.strategy.ts @@ -1,13 +1,13 @@ import { ApiService } from "../../abstractions/api.service"; -import { AppIdService } from "../../abstractions/appId.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { EnvironmentService } from "../../abstractions/environment.service"; -import { LogService } from "../../abstractions/log.service"; -import { MessagingService } from "../../abstractions/messaging.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; -import { StateService } from "../../abstractions/state.service"; import { TokenService } from "../../auth/abstractions/token.service"; import { TwoFactorService } from "../../auth/abstractions/two-factor.service"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { EnvironmentService } from "../../platform/abstractions/environment.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; import { KeyConnectorService } from "../abstractions/key-connector.service"; import { UserApiLogInCredentials } from "../models/domain/log-in-credentials"; import { UserApiTokenRequest } from "../models/request/identity-token/user-api-token.request"; @@ -44,14 +44,7 @@ export class UserApiLogInStrategy extends LogInStrategy { ); } - async setUserKey(tokenResponse: IdentityTokenResponse) { - if (tokenResponse.apiUseKeyConnector) { - const keyConnectorUrl = this.environmentService.getKeyConnectorUrl(); - await this.keyConnectorService.getAndSetKey(keyConnectorUrl); - } - } - - async logIn(credentials: UserApiLogInCredentials) { + override async logIn(credentials: UserApiLogInCredentials) { this.tokenRequest = new UserApiTokenRequest( credentials.clientId, credentials.clientSecret, @@ -63,6 +56,31 @@ export class UserApiLogInStrategy extends LogInStrategy { return authResult; } + protected override async setMasterKey(response: IdentityTokenResponse) { + if (response.apiUseKeyConnector) { + const keyConnectorUrl = this.environmentService.getKeyConnectorUrl(); + await this.keyConnectorService.setMasterKeyFromUrl(keyConnectorUrl); + } + } + + protected override async setUserKey(response: IdentityTokenResponse): Promise { + await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); + + if (response.apiUseKeyConnector) { + const masterKey = await this.cryptoService.getMasterKey(); + if (masterKey) { + const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); + await this.cryptoService.setUserKey(userKey); + } + } + } + + protected override async setPrivateKey(response: IdentityTokenResponse): Promise { + await this.cryptoService.setPrivateKey( + response.privateKey ?? (await this.createKeyPairForOldAccount()) + ); + } + protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) { await super.saveAccountInformation(tokenResponse); await this.stateService.setApiKeyClientId(this.tokenRequest.clientId); diff --git a/libs/common/src/auth/models/domain/admin-auth-req-storable.ts b/libs/common/src/auth/models/domain/admin-auth-req-storable.ts new file mode 100644 index 00000000000..1eae7eeab1a --- /dev/null +++ b/libs/common/src/auth/models/domain/admin-auth-req-storable.ts @@ -0,0 +1,41 @@ +import { Utils } from "../../../platform/misc/utils"; + +// TODO: Tech Debt: potentially create a type Storage shape vs using a class here in the future +// type StorageShape { +// id: string; +// privateKey: string; +// } +// so we can get rid of the any type passed into fromJSON and coming out of ToJSON +export class AdminAuthRequestStorable { + id: string; + privateKey: Uint8Array; + + constructor(init?: Partial) { + if (init) { + Object.assign(this, init); + } + } + + toJSON() { + return { + id: this.id, + privateKey: Utils.fromBufferToByteString(this.privateKey), + }; + } + + static fromJSON(obj: any): AdminAuthRequestStorable { + if (obj == null) { + return null; + } + + let privateKeyBuffer = null; + if (obj.privateKey) { + privateKeyBuffer = Utils.fromByteStringToArray(obj.privateKey); + } + + return new AdminAuthRequestStorable({ + id: obj.id, + privateKey: privateKeyBuffer, + }); + } +} diff --git a/libs/common/src/auth/models/domain/auth-result.ts b/libs/common/src/auth/models/domain/auth-result.ts index 6ce1e568e1e..6900cba1c48 100644 --- a/libs/common/src/auth/models/domain/auth-result.ts +++ b/libs/common/src/auth/models/domain/auth-result.ts @@ -1,15 +1,23 @@ -import { Utils } from "../../../misc/utils"; +import { Utils } from "../../../platform/misc/utils"; import { TwoFactorProviderType } from "../../enums/two-factor-provider-type"; import { ForceResetPasswordReason } from "./force-reset-password-reason"; export class AuthResult { captchaSiteKey = ""; + // TODO: PM-3287 - Remove this after 3 releases of backwards compatibility. - Target release 2023.12 for removal + /** + * @deprecated + * Replace with using AccountDecryptionOptions to determine if the user does + * not have a master password and is not using Key Connector. + * */ resetMasterPassword = false; + forcePasswordReset: ForceResetPasswordReason = ForceResetPasswordReason.None; twoFactorProviders: Map = null; ssoEmail2FaSessionToken?: string; email: string; + requiresEncryptionKeyMigration: boolean; get requiresCaptcha() { return !Utils.isNullOrWhitespace(this.captchaSiteKey); diff --git a/libs/common/src/auth/models/domain/force-reset-password-reason.ts b/libs/common/src/auth/models/domain/force-reset-password-reason.ts index c70a4cb33cb..99e461c2ea1 100644 --- a/libs/common/src/auth/models/domain/force-reset-password-reason.ts +++ b/libs/common/src/auth/models/domain/force-reset-password-reason.ts @@ -1,3 +1,7 @@ +/* + * This enum is used to determine if a user should be forced to reset their password + * on login (server flag) or unlock via MP (client evaluation). + */ export enum ForceResetPasswordReason { /** * A password reset should not be forced. @@ -6,12 +10,14 @@ export enum ForceResetPasswordReason { /** * Occurs when an organization admin forces a user to reset their password. + * Communicated via server flag. */ AdminForcePasswordReset, /** * Occurs when a user logs in / unlocks their vault with a master password that does not meet an organization's * master password policy that is enforced on login/unlock. + * Only set client side b/c server can't evaluate MP. */ WeakMasterPassword, } diff --git a/libs/common/src/auth/models/domain/log-in-credentials.ts b/libs/common/src/auth/models/domain/log-in-credentials.ts index aabdaea02d2..7022bb66ded 100644 --- a/libs/common/src/auth/models/domain/log-in-credentials.ts +++ b/libs/common/src/auth/models/domain/log-in-credentials.ts @@ -1,4 +1,4 @@ -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import { MasterKey, UserKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { AuthenticationType } from "../../enums/authentication-type"; import { TokenTwoFactorRequest } from "../request/identity-token/token-two-factor.request"; @@ -38,8 +38,9 @@ export class PasswordlessLogInCredentials { public email: string, public accessCode: string, public authRequestId: string, - public decKey: SymmetricCryptoKey, - public localPasswordHash: string, + public decryptedUserKey: UserKey, + public decryptedMasterKey: MasterKey, + public decryptedMasterKeyHash: string, public twoFactor?: TokenTwoFactorRequest ) {} } diff --git a/libs/common/src/auth/models/domain/user-decryption-options/key-connector-user-decryption-option.ts b/libs/common/src/auth/models/domain/user-decryption-options/key-connector-user-decryption-option.ts new file mode 100644 index 00000000000..3422c078e46 --- /dev/null +++ b/libs/common/src/auth/models/domain/user-decryption-options/key-connector-user-decryption-option.ts @@ -0,0 +1,3 @@ +export class KeyConnectorUserDecryptionOption { + constructor(public keyConnectorUrl: string) {} +} diff --git a/libs/common/src/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option.ts b/libs/common/src/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option.ts new file mode 100644 index 00000000000..7d7211061f2 --- /dev/null +++ b/libs/common/src/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option.ts @@ -0,0 +1,7 @@ +export class TrustedDeviceUserDecryptionOption { + constructor( + public hasAdminApproval: boolean, + public hasLoginApprovingDevice: boolean, + public hasManageResetPasswordPermission: boolean + ) {} +} diff --git a/libs/common/src/auth/models/request/identity-token/device.request.ts b/libs/common/src/auth/models/request/identity-token/device.request.ts index 50d6c9c7e28..49b40f11d03 100644 --- a/libs/common/src/auth/models/request/identity-token/device.request.ts +++ b/libs/common/src/auth/models/request/identity-token/device.request.ts @@ -1,5 +1,5 @@ -import { PlatformUtilsService } from "../../../../abstractions/platformUtils.service"; import { DeviceType } from "../../../../enums"; +import { PlatformUtilsService } from "../../../../platform/abstractions/platform-utils.service"; export class DeviceRequest { type: DeviceType; diff --git a/libs/common/src/auth/models/request/identity-token/password-token.request.ts b/libs/common/src/auth/models/request/identity-token/password-token.request.ts index 5fc8abe71aa..a7b23479117 100644 --- a/libs/common/src/auth/models/request/identity-token/password-token.request.ts +++ b/libs/common/src/auth/models/request/identity-token/password-token.request.ts @@ -1,5 +1,5 @@ import { ClientType } from "../../../../enums"; -import { Utils } from "../../../../misc/utils"; +import { Utils } from "../../../../platform/misc/utils"; import { CaptchaProtectedRequest } from "../captcha-protected.request"; import { DeviceRequest } from "./device.request"; diff --git a/libs/common/src/auth/models/request/update-devices-trust.request.ts b/libs/common/src/auth/models/request/update-devices-trust.request.ts new file mode 100644 index 00000000000..000e9f139da --- /dev/null +++ b/libs/common/src/auth/models/request/update-devices-trust.request.ts @@ -0,0 +1,15 @@ +import { SecretVerificationRequest } from "./secret-verification.request"; + +export class UpdateDevicesTrustRequest extends SecretVerificationRequest { + currentDevice: DeviceKeysUpdateRequest; + otherDevices: OtherDeviceKeysUpdateRequest[]; +} + +export class DeviceKeysUpdateRequest { + encryptedPublicKey: string; + encryptedUserKey: string; +} + +export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest { + id: string; +} diff --git a/libs/common/src/auth/models/response/identity-token.response.ts b/libs/common/src/auth/models/response/identity-token.response.ts index 76f34617f86..d080341ed41 100644 --- a/libs/common/src/auth/models/response/identity-token.response.ts +++ b/libs/common/src/auth/models/response/identity-token.response.ts @@ -2,6 +2,7 @@ import { KdfType } from "../../../enums"; import { BaseResponse } from "../../../models/response/base.response"; import { MasterPasswordPolicyResponse } from "./master-password-policy.response"; +import { UserDecryptionOptionsResponse } from "./user-decryption-options/user-decryption-options.response"; export class IdentityTokenResponse extends BaseResponse { accessToken: string; @@ -22,6 +23,8 @@ export class IdentityTokenResponse extends BaseResponse { apiUseKeyConnector: boolean; keyConnectorUrl: string; + userDecryptionOptions: UserDecryptionOptionsResponse; + constructor(response: any) { super(response); this.accessToken = response.access_token; @@ -43,5 +46,11 @@ export class IdentityTokenResponse extends BaseResponse { this.masterPasswordPolicy = new MasterPasswordPolicyResponse( this.getResponseProperty("MasterPasswordPolicy") ); + + if (response.UserDecryptionOptions) { + this.userDecryptionOptions = new UserDecryptionOptionsResponse( + this.getResponseProperty("UserDecryptionOptions") + ); + } } } diff --git a/libs/common/src/auth/models/response/protected-device.response.ts b/libs/common/src/auth/models/response/protected-device.response.ts new file mode 100644 index 00000000000..6d279f70c73 --- /dev/null +++ b/libs/common/src/auth/models/response/protected-device.response.ts @@ -0,0 +1,39 @@ +import { Jsonify } from "type-fest"; + +import { DeviceType } from "../../../enums"; +import { BaseResponse } from "../../../models/response/base.response"; +import { EncString } from "../../../platform/models/domain/enc-string"; + +export class ProtectedDeviceResponse extends BaseResponse { + constructor(response: Jsonify) { + super(response); + this.id = this.getResponseProperty("id"); + this.name = this.getResponseProperty("name"); + this.identifier = this.getResponseProperty("identifier"); + this.type = this.getResponseProperty("type"); + this.creationDate = new Date(this.getResponseProperty("creationDate")); + if (response.encryptedUserKey) { + this.encryptedUserKey = new EncString(this.getResponseProperty("encryptedUserKey")); + } + if (response.encryptedPublicKey) { + this.encryptedPublicKey = new EncString(this.getResponseProperty("encryptedPublicKey")); + } + } + + id: string; + name: string; + type: DeviceType; + identifier: string; + creationDate: Date; + /** + * Intended to be the users symmetric key that is encrypted in some form, the current way to encrypt this is with + * the devices public key. + */ + encryptedUserKey: EncString; + /** + * Intended to be the public key that was generated for a device upon trust and encrypted. Currenly encrypted using + * a users symmetric key so that when trusted and unlocked a user can decrypt the public key for all their devices. + * This enabled a user to rotate the keys for all of their devices. + */ + encryptedPublicKey: EncString; +} diff --git a/libs/common/src/auth/models/response/two-factor-web-authn.response.ts b/libs/common/src/auth/models/response/two-factor-web-authn.response.ts index 4a311095d71..35b6830b0df 100644 --- a/libs/common/src/auth/models/response/two-factor-web-authn.response.ts +++ b/libs/common/src/auth/models/response/two-factor-web-authn.response.ts @@ -1,5 +1,5 @@ -import { Utils } from "../../../misc/utils"; import { BaseResponse } from "../../../models/response/base.response"; +import { Utils } from "../../../platform/misc/utils"; export class TwoFactorWebAuthnResponse extends BaseResponse { enabled: boolean; diff --git a/libs/common/src/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response.ts b/libs/common/src/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response.ts new file mode 100644 index 00000000000..6448a9547e7 --- /dev/null +++ b/libs/common/src/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response.ts @@ -0,0 +1,14 @@ +import { BaseResponse } from "../../../../models/response/base.response"; + +export interface IKeyConnectorUserDecryptionOptionServerResponse { + KeyConnectorUrl: string; +} + +export class KeyConnectorUserDecryptionOptionResponse extends BaseResponse { + keyConnectorUrl: string; + + constructor(response: IKeyConnectorUserDecryptionOptionServerResponse) { + super(response); + this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl"); + } +} diff --git a/libs/common/src/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response.ts b/libs/common/src/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response.ts new file mode 100644 index 00000000000..903022b0749 --- /dev/null +++ b/libs/common/src/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response.ts @@ -0,0 +1,35 @@ +import { BaseResponse } from "../../../../models/response/base.response"; +import { EncString } from "../../../../platform/models/domain/enc-string"; + +export interface ITrustedDeviceUserDecryptionOptionServerResponse { + HasAdminApproval: boolean; + HasLoginApprovingDevice: boolean; + HasManageResetPasswordPermission: boolean; + EncryptedPrivateKey?: string; + EncryptedUserKey?: string; +} + +export class TrustedDeviceUserDecryptionOptionResponse extends BaseResponse { + hasAdminApproval: boolean; + hasLoginApprovingDevice: boolean; + hasManageResetPasswordPermission: boolean; + encryptedPrivateKey: EncString; + encryptedUserKey: EncString; + + constructor(response: any) { + super(response); + this.hasAdminApproval = this.getResponseProperty("HasAdminApproval"); + + this.hasLoginApprovingDevice = this.getResponseProperty("HasLoginApprovingDevice"); + this.hasManageResetPasswordPermission = this.getResponseProperty( + "HasManageResetPasswordPermission" + ); + + if (response.EncryptedPrivateKey) { + this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey")); + } + if (response.EncryptedUserKey) { + this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey")); + } + } +} diff --git a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts new file mode 100644 index 00000000000..fcf1f49ace6 --- /dev/null +++ b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts @@ -0,0 +1,39 @@ +import { BaseResponse } from "../../../../models/response/base.response"; + +import { + IKeyConnectorUserDecryptionOptionServerResponse, + KeyConnectorUserDecryptionOptionResponse, +} from "./key-connector-user-decryption-option.response"; +import { + ITrustedDeviceUserDecryptionOptionServerResponse, + TrustedDeviceUserDecryptionOptionResponse, +} from "./trusted-device-user-decryption-option.response"; + +export interface IUserDecryptionOptionsServerResponse { + HasMasterPassword: boolean; + TrustedDeviceOption?: ITrustedDeviceUserDecryptionOptionServerResponse; + KeyConnectorOption?: IKeyConnectorUserDecryptionOptionServerResponse; +} + +export class UserDecryptionOptionsResponse extends BaseResponse { + hasMasterPassword: boolean; + trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse; + keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse; + + constructor(response: IUserDecryptionOptionsServerResponse) { + super(response); + + this.hasMasterPassword = this.getResponseProperty("HasMasterPassword"); + + if (response.TrustedDeviceOption) { + this.trustedDeviceOption = new TrustedDeviceUserDecryptionOptionResponse( + this.getResponseProperty("TrustedDeviceOption") + ); + } + if (response.KeyConnectorOption) { + this.keyConnectorOption = new KeyConnectorUserDecryptionOptionResponse( + this.getResponseProperty("KeyConnectorOption") + ); + } + } +} diff --git a/libs/common/src/auth/services/account-api.service.ts b/libs/common/src/auth/services/account-api.service.ts index 4d96f6506f7..270ec1a201f 100644 --- a/libs/common/src/auth/services/account-api.service.ts +++ b/libs/common/src/auth/services/account-api.service.ts @@ -1,9 +1,9 @@ import { ApiService } from "../../abstractions/api.service"; -import { LogService } from "../../abstractions/log.service"; -import { UserVerificationService } from "../../abstractions/userVerification/userVerification.service.abstraction"; +import { LogService } from "../../platform/abstractions/log.service"; import { Verification } from "../../types/verification"; import { AccountApiService } from "../abstractions/account-api.service"; import { InternalAccountService } from "../abstractions/account.service"; +import { UserVerificationService } from "../abstractions/user-verification/user-verification.service.abstraction"; export class AccountApiServiceImplementation implements AccountApiService { constructor( diff --git a/libs/common/src/auth/services/account.service.ts b/libs/common/src/auth/services/account.service.ts index 8d6cd310c0d..02c12050952 100644 --- a/libs/common/src/auth/services/account.service.ts +++ b/libs/common/src/auth/services/account.service.ts @@ -1,6 +1,6 @@ -import { LogService } from "../../abstractions/log.service"; -import { MessagingService } from "../../abstractions/messaging.service"; import { InternalAccountService } from "../../auth/abstractions/account.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; export class AccountServiceImplementation implements InternalAccountService { constructor(private messagingService: MessagingService, private logService: LogService) {} diff --git a/libs/common/src/auth/services/auth-request-crypto.service.implementation.ts b/libs/common/src/auth/services/auth-request-crypto.service.implementation.ts new file mode 100644 index 00000000000..004957e1011 --- /dev/null +++ b/libs/common/src/auth/services/auth-request-crypto.service.implementation.ts @@ -0,0 +1,81 @@ +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { Utils } from "../../platform/misc/utils"; +import { + UserKey, + SymmetricCryptoKey, + MasterKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction"; +import { AuthRequestResponse } from "../models/response/auth-request.response"; + +export class AuthRequestCryptoServiceImplementation implements AuthRequestCryptoServiceAbstraction { + constructor(private cryptoService: CryptoService) {} + + async setUserKeyAfterDecryptingSharedUserKey( + authReqResponse: AuthRequestResponse, + authReqPrivateKey: Uint8Array + ) { + const userKey = await this.decryptPubKeyEncryptedUserKey( + authReqResponse.key, + authReqPrivateKey + ); + await this.cryptoService.setUserKey(userKey); + } + + async setKeysAfterDecryptingSharedMasterKeyAndHash( + authReqResponse: AuthRequestResponse, + authReqPrivateKey: Uint8Array + ) { + const { masterKey, masterKeyHash } = await this.decryptPubKeyEncryptedMasterKeyAndHash( + authReqResponse.key, + authReqResponse.masterPasswordHash, + authReqPrivateKey + ); + + // Decrypt and set user key in state + const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); + + // Set masterKey + masterKeyHash in state after decryption (in case decryption fails) + await this.cryptoService.setMasterKey(masterKey); + await this.cryptoService.setMasterKeyHash(masterKeyHash); + + await this.cryptoService.setUserKey(userKey); + } + + // Decryption helpers + async decryptPubKeyEncryptedUserKey( + pubKeyEncryptedUserKey: string, + privateKey: Uint8Array + ): Promise { + const decryptedUserKeyBytes = await this.cryptoService.rsaDecrypt( + pubKeyEncryptedUserKey, + privateKey + ); + + return new SymmetricCryptoKey(decryptedUserKeyBytes) as UserKey; + } + + async decryptPubKeyEncryptedMasterKeyAndHash( + pubKeyEncryptedMasterKey: string, + pubKeyEncryptedMasterKeyHash: string, + privateKey: Uint8Array + ): Promise<{ masterKey: MasterKey; masterKeyHash: string }> { + const decryptedMasterKeyArrayBuffer = await this.cryptoService.rsaDecrypt( + pubKeyEncryptedMasterKey, + privateKey + ); + + const decryptedMasterKeyHashArrayBuffer = await this.cryptoService.rsaDecrypt( + pubKeyEncryptedMasterKeyHash, + privateKey + ); + + const masterKey = new SymmetricCryptoKey(decryptedMasterKeyArrayBuffer) as MasterKey; + const masterKeyHash = Utils.fromBufferToUtf8(decryptedMasterKeyHashArrayBuffer); + + return { + masterKey, + masterKeyHash, + }; + } +} diff --git a/libs/common/src/auth/services/auth-request-crypto.service.spec.ts b/libs/common/src/auth/services/auth-request-crypto.service.spec.ts new file mode 100644 index 00000000000..cab1cba0e70 --- /dev/null +++ b/libs/common/src/auth/services/auth-request-crypto.service.spec.ts @@ -0,0 +1,165 @@ +import { mock } from "jest-mock-extended"; + +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { Utils } from "../../platform/misc/utils"; +import { + MasterKey, + SymmetricCryptoKey, + UserKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction"; +import { AuthRequestResponse } from "../models/response/auth-request.response"; + +import { AuthRequestCryptoServiceImplementation } from "./auth-request-crypto.service.implementation"; + +describe("AuthRequestCryptoService", () => { + let authReqCryptoService: AuthRequestCryptoServiceAbstraction; + const cryptoService = mock(); + let mockPrivateKey: Uint8Array; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + authReqCryptoService = new AuthRequestCryptoServiceImplementation(cryptoService); + + mockPrivateKey = new Uint8Array(64); + }); + + it("instantiates", () => { + expect(authReqCryptoService).not.toBeFalsy(); + }); + + describe("setUserKeyAfterDecryptingSharedUserKey", () => { + it("decrypts and sets user key when given valid auth request response and private key", async () => { + // Arrange + const mockAuthReqResponse = { + key: "authReqPublicKeyEncryptedUserKey", + } as AuthRequestResponse; + + const mockDecryptedUserKey = {} as UserKey; + jest + .spyOn(authReqCryptoService, "decryptPubKeyEncryptedUserKey") + .mockResolvedValueOnce(mockDecryptedUserKey); + + cryptoService.setUserKey.mockResolvedValueOnce(undefined); + + // Act + await authReqCryptoService.setUserKeyAfterDecryptingSharedUserKey( + mockAuthReqResponse, + mockPrivateKey + ); + + // Assert + expect(authReqCryptoService.decryptPubKeyEncryptedUserKey).toBeCalledWith( + mockAuthReqResponse.key, + mockPrivateKey + ); + expect(cryptoService.setUserKey).toBeCalledWith(mockDecryptedUserKey); + }); + }); + + describe("setKeysAfterDecryptingSharedMasterKeyAndHash", () => { + it("decrypts and sets master key and hash and user key when given valid auth request response and private key", async () => { + // Arrange + const mockAuthReqResponse = { + key: "authReqPublicKeyEncryptedMasterKey", + masterPasswordHash: "authReqPublicKeyEncryptedMasterKeyHash", + } as AuthRequestResponse; + + const mockDecryptedMasterKey = {} as MasterKey; + const mockDecryptedMasterKeyHash = "mockDecryptedMasterKeyHash"; + const mockDecryptedUserKey = {} as UserKey; + + jest + .spyOn(authReqCryptoService, "decryptPubKeyEncryptedMasterKeyAndHash") + .mockResolvedValueOnce({ + masterKey: mockDecryptedMasterKey, + masterKeyHash: mockDecryptedMasterKeyHash, + }); + + cryptoService.setMasterKey.mockResolvedValueOnce(undefined); + cryptoService.setMasterKeyHash.mockResolvedValueOnce(undefined); + cryptoService.decryptUserKeyWithMasterKey.mockResolvedValueOnce(mockDecryptedUserKey); + cryptoService.setUserKey.mockResolvedValueOnce(undefined); + + // Act + await authReqCryptoService.setKeysAfterDecryptingSharedMasterKeyAndHash( + mockAuthReqResponse, + mockPrivateKey + ); + + // Assert + expect(authReqCryptoService.decryptPubKeyEncryptedMasterKeyAndHash).toBeCalledWith( + mockAuthReqResponse.key, + mockAuthReqResponse.masterPasswordHash, + mockPrivateKey + ); + expect(cryptoService.setMasterKey).toBeCalledWith(mockDecryptedMasterKey); + expect(cryptoService.setMasterKeyHash).toBeCalledWith(mockDecryptedMasterKeyHash); + expect(cryptoService.decryptUserKeyWithMasterKey).toBeCalledWith(mockDecryptedMasterKey); + expect(cryptoService.setUserKey).toBeCalledWith(mockDecryptedUserKey); + }); + }); + + describe("decryptAuthReqPubKeyEncryptedUserKey", () => { + it("returns a decrypted user key when given valid public key encrypted user key and an auth req private key", async () => { + // Arrange + const mockPubKeyEncryptedUserKey = "pubKeyEncryptedUserKey"; + const mockDecryptedUserKeyBytes = new Uint8Array(64); + const mockDecryptedUserKey = new SymmetricCryptoKey(mockDecryptedUserKeyBytes) as UserKey; + + cryptoService.rsaDecrypt.mockResolvedValueOnce(mockDecryptedUserKeyBytes); + + // Act + const result = await authReqCryptoService.decryptPubKeyEncryptedUserKey( + mockPubKeyEncryptedUserKey, + mockPrivateKey + ); + + // Assert + expect(cryptoService.rsaDecrypt).toBeCalledWith(mockPubKeyEncryptedUserKey, mockPrivateKey); + expect(result).toEqual(mockDecryptedUserKey); + }); + }); + + describe("decryptAuthReqPubKeyEncryptedMasterKeyAndHash", () => { + it("returns a decrypted master key and hash when given a valid public key encrypted master key, public key encrypted master key hash, and an auth req private key", async () => { + // Arrange + const mockPubKeyEncryptedMasterKey = "pubKeyEncryptedMasterKey"; + const mockPubKeyEncryptedMasterKeyHash = "pubKeyEncryptedMasterKeyHash"; + + const mockDecryptedMasterKeyBytes = new Uint8Array(64); + const mockDecryptedMasterKey = new SymmetricCryptoKey( + mockDecryptedMasterKeyBytes + ) as MasterKey; + const mockDecryptedMasterKeyHashBytes = new Uint8Array(64); + const mockDecryptedMasterKeyHash = Utils.fromBufferToUtf8(mockDecryptedMasterKeyHashBytes); + + cryptoService.rsaDecrypt + .mockResolvedValueOnce(mockDecryptedMasterKeyBytes) + .mockResolvedValueOnce(mockDecryptedMasterKeyHashBytes); + + // Act + const result = await authReqCryptoService.decryptPubKeyEncryptedMasterKeyAndHash( + mockPubKeyEncryptedMasterKey, + mockPubKeyEncryptedMasterKeyHash, + mockPrivateKey + ); + + // Assert + expect(cryptoService.rsaDecrypt).toHaveBeenNthCalledWith( + 1, + mockPubKeyEncryptedMasterKey, + mockPrivateKey + ); + expect(cryptoService.rsaDecrypt).toHaveBeenNthCalledWith( + 2, + mockPubKeyEncryptedMasterKeyHash, + mockPrivateKey + ); + expect(result.masterKey).toEqual(mockDecryptedMasterKey); + expect(result.masterKeyHash).toEqual(mockDecryptedMasterKeyHash); + }); + }); +}); diff --git a/libs/common/src/auth/services/auth.service.ts b/libs/common/src/auth/services/auth.service.ts index f8028c6944a..815e7b2a5ea 100644 --- a/libs/common/src/auth/services/auth.service.ts +++ b/libs/common/src/auth/services/auth.service.ts @@ -1,24 +1,26 @@ import { Observable, Subject } from "rxjs"; import { ApiService } from "../../abstractions/api.service"; -import { AppIdService } from "../../abstractions/appId.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { EncryptService } from "../../abstractions/encrypt.service"; -import { EnvironmentService } from "../../abstractions/environment.service"; -import { I18nService } from "../../abstractions/i18n.service"; -import { LogService } from "../../abstractions/log.service"; -import { MessagingService } from "../../abstractions/messaging.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; -import { StateService } from "../../abstractions/state.service"; import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; import { KdfType, KeySuffixOptions } from "../../enums"; -import { Utils } from "../../misc/utils"; -import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { PreloginRequest } from "../../models/request/prelogin.request"; import { ErrorResponse } from "../../models/response/error.response"; import { AuthRequestPushNotification } from "../../models/response/notification.response"; -import { PasswordGenerationServiceAbstraction } from "../../tools/generator/password"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { EnvironmentService } from "../../platform/abstractions/environment.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; +import { MasterKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { PasswordStrengthServiceAbstraction } from "../../tools/password-strength"; +import { AuthRequestCryptoServiceAbstraction } from "../abstractions/auth-request-crypto.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service"; +import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; import { TwoFactorService } from "../abstractions/two-factor.service"; @@ -102,8 +104,10 @@ export class AuthService implements AuthServiceAbstraction { protected twoFactorService: TwoFactorService, protected i18nService: I18nService, protected encryptService: EncryptService, - protected passwordGenerationService: PasswordGenerationServiceAbstraction, - protected policyService: PolicyService + protected passwordStrengthService: PasswordStrengthServiceAbstraction, + protected policyService: PolicyService, + protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, + protected authReqCryptoService: AuthRequestCryptoServiceAbstraction ) {} async logIn( @@ -133,7 +137,7 @@ export class AuthService implements AuthServiceAbstraction { this.logService, this.stateService, this.twoFactorService, - this.passwordGenerationService, + this.passwordStrengthService, this.policyService, this ); @@ -149,7 +153,10 @@ export class AuthService implements AuthServiceAbstraction { this.logService, this.stateService, this.twoFactorService, - this.keyConnectorService + this.keyConnectorService, + this.deviceTrustCryptoService, + this.authReqCryptoService, + this.i18nService ); break; case AuthenticationType.UserApi: @@ -178,7 +185,7 @@ export class AuthService implements AuthServiceAbstraction { this.logService, this.stateService, this.twoFactorService, - this + this.deviceTrustCryptoService ); break; } @@ -238,23 +245,32 @@ export class AuthService implements AuthServiceAbstraction { } async getAuthStatus(userId?: string): Promise { + // If we don't have an access token or userId, we're logged out const isAuthenticated = await this.stateService.getIsAuthenticated({ userId: userId }); if (!isAuthenticated) { return AuthenticationStatus.LoggedOut; } - // Keys aren't stored for a device that is locked or logged out - // Make sure we're logged in before checking this, otherwise we could mix up those states - const neverLock = - (await this.cryptoService.hasKeyStored(KeySuffixOptions.Auto, userId)) && - !(await this.stateService.getEverBeenUnlocked({ userId: userId })); - if (neverLock) { - // TODO: This also _sets_ the key so when we check memory in the next line it finds a key. - // We should refactor here. - await this.cryptoService.getKey(KeySuffixOptions.Auto, userId); + // If we don't have a user key in memory, we're locked + if (!(await this.cryptoService.hasUserKeyInMemory(userId))) { + // Check if the user has vault timeout set to never and verify that + // they've never unlocked their vault + const neverLock = + (await this.cryptoService.hasUserKeyStored(KeySuffixOptions.Auto, userId)) && + !(await this.stateService.getEverBeenUnlocked({ userId: userId })); + + if (neverLock) { + // Attempt to get the key from storage and set it in memory + const userKey = await this.cryptoService.getUserKeyFromStorage( + KeySuffixOptions.Auto, + userId + ); + await this.cryptoService.setUserKey(userKey, userId); + } } - const hasKeyInMemory = await this.cryptoService.hasKeyInMemory(userId); + // We do another check here in case setting the auto key failed + const hasKeyInMemory = await this.cryptoService.hasUserKeyInMemory(userId); if (!hasKeyInMemory) { return AuthenticationStatus.Locked; } @@ -262,7 +278,7 @@ export class AuthService implements AuthServiceAbstraction { return AuthenticationStatus.Unlocked; } - async makePreloginKey(masterPassword: string, email: string): Promise { + async makePreloginKey(masterPassword: string, email: string): Promise { email = email.trim().toLowerCase(); let kdf: KdfType = null; let kdfConfig: KdfConfig = null; @@ -281,7 +297,7 @@ export class AuthService implements AuthServiceAbstraction { throw e; } } - return this.cryptoService.makeKey(masterPassword, email, kdf, kdfConfig); + return await this.cryptoService.makeMasterKey(masterPassword, email, kdf, kdfConfig); } async authResponsePushNotification(notification: AuthRequestPushNotification): Promise { @@ -298,19 +314,33 @@ export class AuthService implements AuthServiceAbstraction { requestApproved: boolean ): Promise { const pubKey = Utils.fromB64ToArray(key); - const encryptedKey = await this.cryptoService.rsaEncrypt( - ( - await this.cryptoService.getKey() - ).encKey, - pubKey.buffer - ); - const encryptedMasterPassword = await this.cryptoService.rsaEncrypt( - Utils.fromUtf8ToArray(await this.stateService.getKeyHash()), - pubKey.buffer - ); + + const masterKey = await this.cryptoService.getMasterKey(); + let keyToEncrypt; + let encryptedMasterKeyHash = null; + + if (masterKey) { + keyToEncrypt = masterKey.encKey; + + // Only encrypt the master password hash if masterKey exists as + // we won't have a masterKeyHash without a masterKey + const masterKeyHash = await this.stateService.getKeyHash(); + if (masterKeyHash != null) { + encryptedMasterKeyHash = await this.cryptoService.rsaEncrypt( + Utils.fromUtf8ToArray(masterKeyHash), + pubKey + ); + } + } else { + const userKey = await this.cryptoService.getUserKey(); + keyToEncrypt = userKey.key; + } + + const encryptedKey = await this.cryptoService.rsaEncrypt(keyToEncrypt, pubKey); + const request = new PasswordlessAuthRequest( encryptedKey.encryptedString, - encryptedMasterPassword.encryptedString, + encryptedMasterKeyHash?.encryptedString, await this.appIdService.getAppId(), requestApproved ); diff --git a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts new file mode 100644 index 00000000000..fe45c5e208e --- /dev/null +++ b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts @@ -0,0 +1,215 @@ +import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { + SymmetricCryptoKey, + DeviceKey, + UserKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../types/csprng"; +import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; +import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction"; +import { SecretVerificationRequest } from "../models/request/secret-verification.request"; +import { + DeviceKeysUpdateRequest, + UpdateDevicesTrustRequest, +} from "../models/request/update-devices-trust.request"; + +export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstraction { + constructor( + private cryptoFunctionService: CryptoFunctionService, + private cryptoService: CryptoService, + private encryptService: EncryptService, + private stateService: StateService, + private appIdService: AppIdService, + private devicesApiService: DevicesApiServiceAbstraction, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService + ) {} + + /** + * @description Retrieves the users choice to trust the device which can only happen after decryption + * Note: this value should only be used once and then reset + */ + async getShouldTrustDevice(): Promise { + return await this.stateService.getShouldTrustDevice(); + } + + async setShouldTrustDevice(value: boolean): Promise { + await this.stateService.setShouldTrustDevice(value); + } + + async trustDeviceIfRequired(): Promise { + const shouldTrustDevice = await this.getShouldTrustDevice(); + if (shouldTrustDevice) { + await this.trustDevice(); + // reset the trust choice + await this.setShouldTrustDevice(false); + } + } + + async trustDevice(): Promise { + // Attempt to get user key + const userKey: UserKey = await this.cryptoService.getUserKey(); + + // If user key is not found, throw error + if (!userKey) { + throw new Error("User symmetric key not found"); + } + + // Generate deviceKey + const deviceKey = await this.makeDeviceKey(); + + // Generate asymmetric RSA key pair: devicePrivateKey, devicePublicKey + const [devicePublicKey, devicePrivateKey] = await this.cryptoFunctionService.rsaGenerateKeyPair( + 2048 + ); + + const [ + devicePublicKeyEncryptedUserKey, + userKeyEncryptedDevicePublicKey, + deviceKeyEncryptedDevicePrivateKey, + ] = await Promise.all([ + // Encrypt user key with the DevicePublicKey + this.cryptoService.rsaEncrypt(userKey.key, devicePublicKey), + + // Encrypt devicePublicKey with user key + this.encryptService.encrypt(devicePublicKey, userKey), + + // Encrypt devicePrivateKey with deviceKey + this.encryptService.encrypt(devicePrivateKey, deviceKey), + ]); + + // Send encrypted keys to server + const deviceIdentifier = await this.appIdService.getAppId(); + const deviceResponse = await this.devicesApiService.updateTrustedDeviceKeys( + deviceIdentifier, + devicePublicKeyEncryptedUserKey.encryptedString, + userKeyEncryptedDevicePublicKey.encryptedString, + deviceKeyEncryptedDevicePrivateKey.encryptedString + ); + + // store device key in local/secure storage if enc keys posted to server successfully + await this.setDeviceKey(deviceKey); + + this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted")); + + return deviceResponse; + } + + async rotateDevicesTrust(newUserKey: UserKey, masterPasswordHash: string): Promise { + const currentDeviceKey = await this.getDeviceKey(); + if (currentDeviceKey == null) { + // If the current device doesn't have a device key available to it, then we can't + // rotate any trust at all, so early return. + return; + } + + // At this point of rotating their keys, they should still have their old user key in state + const oldUserKey = await this.stateService.getUserKey(); + + const deviceIdentifier = await this.appIdService.getAppId(); + const secretVerificationRequest = new SecretVerificationRequest(); + secretVerificationRequest.masterPasswordHash = masterPasswordHash; + + // Get the keys that are used in rotating a devices keys from the server + const currentDeviceKeys = await this.devicesApiService.getDeviceKeys( + deviceIdentifier, + secretVerificationRequest + ); + + // Decrypt the existing device public key with the old user key + const decryptedDevicePublicKey = await this.encryptService.decryptToBytes( + currentDeviceKeys.encryptedPublicKey, + oldUserKey + ); + + // Encrypt the brand new user key with the now-decrypted public key for the device + const encryptedNewUserKey = await this.cryptoService.rsaEncrypt( + newUserKey.key, + decryptedDevicePublicKey + ); + + // Re-encrypt the device public key with the new user key + const encryptedDevicePublicKey = await this.encryptService.encrypt( + decryptedDevicePublicKey, + newUserKey + ); + + const currentDeviceUpdateRequest = new DeviceKeysUpdateRequest(); + currentDeviceUpdateRequest.encryptedUserKey = encryptedNewUserKey.encryptedString; + currentDeviceUpdateRequest.encryptedPublicKey = encryptedDevicePublicKey.encryptedString; + + // TODO: For device management, allow this method to take an array of device ids that can be looped over and individually rotated + // then it can be added to trustRequest.otherDevices. + + const trustRequest = new UpdateDevicesTrustRequest(); + trustRequest.masterPasswordHash = masterPasswordHash; + trustRequest.currentDevice = currentDeviceUpdateRequest; + trustRequest.otherDevices = []; + + await this.devicesApiService.updateTrust(trustRequest); + } + + async getDeviceKey(): Promise { + return await this.stateService.getDeviceKey(); + } + + private async setDeviceKey(deviceKey: DeviceKey | null): Promise { + await this.stateService.setDeviceKey(deviceKey); + } + + private async makeDeviceKey(): Promise { + // Create 512-bit device key + const randomBytes: CsprngArray = await this.cryptoFunctionService.aesGenerateKey(512); + const deviceKey = new SymmetricCryptoKey(randomBytes) as DeviceKey; + + return deviceKey; + } + + async decryptUserKeyWithDeviceKey( + encryptedDevicePrivateKey: EncString, + encryptedUserKey: EncString, + deviceKey?: DeviceKey + ): Promise { + // If device key provided use it, otherwise try to retrieve from storage + deviceKey ||= await this.getDeviceKey(); + + if (!deviceKey) { + // User doesn't have a device key anymore so device is untrusted + return null; + } + + try { + // attempt to decrypt encryptedDevicePrivateKey with device key + const devicePrivateKey = await this.encryptService.decryptToBytes( + encryptedDevicePrivateKey, + deviceKey + ); + + // Attempt to decrypt encryptedUserDataKey with devicePrivateKey + const userKey = await this.cryptoService.rsaDecrypt( + encryptedUserKey.encryptedString, + devicePrivateKey + ); + + return new SymmetricCryptoKey(userKey) as UserKey; + } catch (e) { + // If either decryption effort fails, we want to remove the device key + await this.setDeviceKey(null); + + return null; + } + } + + async supportsDeviceTrust(): Promise { + const decryptionOptions = await this.stateService.getAccountDecryptionOptions(); + return decryptionOptions?.trustedDeviceOption != null; + } +} diff --git a/libs/common/src/auth/services/device-trust-crypto.service.spec.ts b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts new file mode 100644 index 00000000000..b56c8b922ab --- /dev/null +++ b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts @@ -0,0 +1,598 @@ +import { matches, mock } from "jest-mock-extended"; + +import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; +import { DeviceType } from "../../enums"; +import { EncryptionType } from "../../enums/encryption-type.enum"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { + SymmetricCryptoKey, + DeviceKey, + UserKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../types/csprng"; +import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction"; +import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request"; +import { ProtectedDeviceResponse } from "../models/response/protected-device.response"; + +import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implementation"; +describe("deviceTrustCryptoService", () => { + let deviceTrustCryptoService: DeviceTrustCryptoService; + + const cryptoFunctionService = mock(); + const cryptoService = mock(); + const encryptService = mock(); + const stateService = mock(); + const appIdService = mock(); + const devicesApiService = mock(); + const i18nService = mock(); + const platformUtilsService = mock(); + + beforeEach(() => { + jest.clearAllMocks(); + + deviceTrustCryptoService = new DeviceTrustCryptoService( + cryptoFunctionService, + cryptoService, + encryptService, + stateService, + appIdService, + devicesApiService, + i18nService, + platformUtilsService + ); + }); + + it("instantiates", () => { + expect(deviceTrustCryptoService).not.toBeFalsy(); + }); + + describe("User Trust Device Choice For Decryption", () => { + describe("getShouldTrustDevice", () => { + it("gets the user trust device choice for decryption from the state service", async () => { + const stateSvcGetShouldTrustDeviceSpy = jest.spyOn(stateService, "getShouldTrustDevice"); + + const expectedValue = true; + stateSvcGetShouldTrustDeviceSpy.mockResolvedValue(expectedValue); + const result = await deviceTrustCryptoService.getShouldTrustDevice(); + + expect(stateSvcGetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual(expectedValue); + }); + }); + + describe("setShouldTrustDevice", () => { + it("sets the user trust device choice for decryption in the state service", async () => { + const stateSvcSetShouldTrustDeviceSpy = jest.spyOn(stateService, "setShouldTrustDevice"); + + const newValue = true; + await deviceTrustCryptoService.setShouldTrustDevice(newValue); + + expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1); + expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledWith(newValue); + }); + }); + }); + + describe("trustDeviceIfRequired", () => { + it("should trust device and reset when getShouldTrustDevice returns true", async () => { + jest.spyOn(deviceTrustCryptoService, "getShouldTrustDevice").mockResolvedValue(true); + jest.spyOn(deviceTrustCryptoService, "trustDevice").mockResolvedValue({} as DeviceResponse); + jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice").mockResolvedValue(); + + await deviceTrustCryptoService.trustDeviceIfRequired(); + + expect(deviceTrustCryptoService.getShouldTrustDevice).toHaveBeenCalledTimes(1); + expect(deviceTrustCryptoService.trustDevice).toHaveBeenCalledTimes(1); + expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(false); + }); + + it("should not trust device nor reset when getShouldTrustDevice returns false", async () => { + const getShouldTrustDeviceSpy = jest + .spyOn(deviceTrustCryptoService, "getShouldTrustDevice") + .mockResolvedValue(false); + const trustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "trustDevice"); + const setShouldTrustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice"); + + await deviceTrustCryptoService.trustDeviceIfRequired(); + + expect(getShouldTrustDeviceSpy).toHaveBeenCalledTimes(1); + expect(trustDeviceSpy).not.toHaveBeenCalled(); + expect(setShouldTrustDeviceSpy).not.toHaveBeenCalled(); + }); + }); + + describe("Trusted Device Encryption core logic tests", () => { + const deviceKeyBytesLength = 64; + const userKeyBytesLength = 64; + + describe("getDeviceKey", () => { + let existingDeviceKey: DeviceKey; + let stateSvcGetDeviceKeySpy: jest.SpyInstance; + + beforeEach(() => { + existingDeviceKey = new SymmetricCryptoKey( + new Uint8Array(deviceKeyBytesLength) as CsprngArray + ) as DeviceKey; + + stateSvcGetDeviceKeySpy = jest.spyOn(stateService, "getDeviceKey"); + }); + + it("returns null when there is not an existing device key", async () => { + stateSvcGetDeviceKeySpy.mockResolvedValue(null); + + const deviceKey = await deviceTrustCryptoService.getDeviceKey(); + + expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1); + + expect(deviceKey).toBeNull(); + }); + + it("returns the device key when there is an existing device key", async () => { + stateSvcGetDeviceKeySpy.mockResolvedValue(existingDeviceKey); + + const deviceKey = await deviceTrustCryptoService.getDeviceKey(); + + expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1); + + expect(deviceKey).not.toBeNull(); + expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); + expect(deviceKey).toEqual(existingDeviceKey); + }); + }); + + describe("setDeviceKey", () => { + it("sets the device key in the state service", async () => { + const stateSvcSetDeviceKeySpy = jest.spyOn(stateService, "setDeviceKey"); + + const deviceKey = new SymmetricCryptoKey( + new Uint8Array(deviceKeyBytesLength) as CsprngArray + ) as DeviceKey; + + // TypeScript will allow calling private methods if the object is of type 'any' + // This is a hacky workaround, but it allows for cleaner tests + await (deviceTrustCryptoService as any).setDeviceKey(deviceKey); + + expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledTimes(1); + expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledWith(deviceKey); + }); + }); + + describe("makeDeviceKey", () => { + it("creates a new non-null 64 byte device key, securely stores it, and returns it", async () => { + const mockRandomBytes = new Uint8Array(deviceKeyBytesLength) as CsprngArray; + + const cryptoFuncSvcGenerateKeySpy = jest + .spyOn(cryptoFunctionService, "aesGenerateKey") + .mockResolvedValue(mockRandomBytes); + + // TypeScript will allow calling private methods if the object is of type 'any' + // This is a hacky workaround, but it allows for cleaner tests + const deviceKey = await (deviceTrustCryptoService as any).makeDeviceKey(); + + expect(cryptoFuncSvcGenerateKeySpy).toHaveBeenCalledTimes(1); + expect(cryptoFuncSvcGenerateKeySpy).toHaveBeenCalledWith(deviceKeyBytesLength * 8); + + expect(deviceKey).not.toBeNull(); + expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); + }); + }); + + describe("trustDevice", () => { + let mockDeviceKeyRandomBytes: CsprngArray; + let mockDeviceKey: DeviceKey; + + let mockUserKeyRandomBytes: CsprngArray; + let mockUserKey: UserKey; + + const deviceRsaKeyLength = 2048; + let mockDeviceRsaKeyPair: [Uint8Array, Uint8Array]; + let mockDevicePrivateKey: Uint8Array; + let mockDevicePublicKey: Uint8Array; + let mockDevicePublicKeyEncryptedUserKey: EncString; + let mockUserKeyEncryptedDevicePublicKey: EncString; + let mockDeviceKeyEncryptedDevicePrivateKey: EncString; + + const mockDeviceResponse: DeviceResponse = new DeviceResponse({ + Id: "mockId", + Name: "mockName", + Identifier: "mockIdentifier", + Type: "mockType", + CreationDate: "mockCreationDate", + }); + + const mockDeviceId = "mockDeviceId"; + + let makeDeviceKeySpy: jest.SpyInstance; + let rsaGenerateKeyPairSpy: jest.SpyInstance; + let cryptoSvcGetUserKeySpy: jest.SpyInstance; + let cryptoSvcRsaEncryptSpy: jest.SpyInstance; + let encryptServiceEncryptSpy: jest.SpyInstance; + let appIdServiceGetAppIdSpy: jest.SpyInstance; + let devicesApiServiceUpdateTrustedDeviceKeysSpy: jest.SpyInstance; + + beforeEach(() => { + // Setup all spies and default return values for the happy path + + mockDeviceKeyRandomBytes = new Uint8Array(deviceKeyBytesLength) as CsprngArray; + mockDeviceKey = new SymmetricCryptoKey(mockDeviceKeyRandomBytes) as DeviceKey; + + mockUserKeyRandomBytes = new Uint8Array(userKeyBytesLength) as CsprngArray; + mockUserKey = new SymmetricCryptoKey(mockUserKeyRandomBytes) as UserKey; + + mockDeviceRsaKeyPair = [ + new Uint8Array(deviceRsaKeyLength), + new Uint8Array(deviceRsaKeyLength), + ]; + + mockDevicePublicKey = mockDeviceRsaKeyPair[0]; + mockDevicePrivateKey = mockDeviceRsaKeyPair[1]; + + mockDevicePublicKeyEncryptedUserKey = new EncString( + EncryptionType.Rsa2048_OaepSha1_B64, + "mockDevicePublicKeyEncryptedUserKey" + ); + + mockUserKeyEncryptedDevicePublicKey = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "mockUserKeyEncryptedDevicePublicKey" + ); + + mockDeviceKeyEncryptedDevicePrivateKey = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "mockDeviceKeyEncryptedDevicePrivateKey" + ); + + // TypeScript will allow calling private methods if the object is of type 'any' + makeDeviceKeySpy = jest + .spyOn(deviceTrustCryptoService as any, "makeDeviceKey") + .mockResolvedValue(mockDeviceKey); + + rsaGenerateKeyPairSpy = jest + .spyOn(cryptoFunctionService, "rsaGenerateKeyPair") + .mockResolvedValue(mockDeviceRsaKeyPair); + + cryptoSvcGetUserKeySpy = jest + .spyOn(cryptoService, "getUserKey") + .mockResolvedValue(mockUserKey); + + cryptoSvcRsaEncryptSpy = jest + .spyOn(cryptoService, "rsaEncrypt") + .mockResolvedValue(mockDevicePublicKeyEncryptedUserKey); + + encryptServiceEncryptSpy = jest + .spyOn(encryptService, "encrypt") + .mockImplementation((plainValue, key) => { + if (plainValue === mockDevicePublicKey && key === mockUserKey) { + return Promise.resolve(mockUserKeyEncryptedDevicePublicKey); + } + if (plainValue === mockDevicePrivateKey && key === mockDeviceKey) { + return Promise.resolve(mockDeviceKeyEncryptedDevicePrivateKey); + } + }); + + appIdServiceGetAppIdSpy = jest + .spyOn(appIdService, "getAppId") + .mockResolvedValue(mockDeviceId); + + devicesApiServiceUpdateTrustedDeviceKeysSpy = jest + .spyOn(devicesApiService, "updateTrustedDeviceKeys") + .mockResolvedValue(mockDeviceResponse); + }); + + it("calls the required methods with the correct arguments and returns a DeviceResponse", async () => { + const response = await deviceTrustCryptoService.trustDevice(); + + expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1); + expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1); + expect(cryptoSvcGetUserKeySpy).toHaveBeenCalledTimes(1); + + expect(cryptoSvcRsaEncryptSpy).toHaveBeenCalledTimes(1); + + // RsaEncrypt must be called w/ a user key array buffer of 64 bytes + const userKeyKey: Uint8Array = cryptoSvcRsaEncryptSpy.mock.calls[0][0]; + expect(userKeyKey.byteLength).toBe(64); + + expect(encryptServiceEncryptSpy).toHaveBeenCalledTimes(2); + + expect(appIdServiceGetAppIdSpy).toHaveBeenCalledTimes(1); + expect(devicesApiServiceUpdateTrustedDeviceKeysSpy).toHaveBeenCalledTimes(1); + expect(devicesApiServiceUpdateTrustedDeviceKeysSpy).toHaveBeenCalledWith( + mockDeviceId, + mockDevicePublicKeyEncryptedUserKey.encryptedString, + mockUserKeyEncryptedDevicePublicKey.encryptedString, + mockDeviceKeyEncryptedDevicePrivateKey.encryptedString + ); + + expect(response).toBeInstanceOf(DeviceResponse); + expect(response).toEqual(mockDeviceResponse); + }); + + it("throws specific error if user key is not found", async () => { + // setup the spy to return null + cryptoSvcGetUserKeySpy.mockResolvedValue(null); + // check if the expected error is thrown + await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow( + "User symmetric key not found" + ); + + // reset the spy + cryptoSvcGetUserKeySpy.mockReset(); + + // setup the spy to return undefined + cryptoSvcGetUserKeySpy.mockResolvedValue(undefined); + // check if the expected error is thrown + await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow( + "User symmetric key not found" + ); + }); + + const methodsToTestForErrorsOrInvalidReturns: any = [ + { + method: "makeDeviceKey", + spy: () => makeDeviceKeySpy, + errorText: "makeDeviceKey error", + }, + { + method: "rsaGenerateKeyPair", + spy: () => rsaGenerateKeyPairSpy, + errorText: "rsaGenerateKeyPair error", + }, + { + method: "getUserKey", + spy: () => cryptoSvcGetUserKeySpy, + errorText: "getUserKey error", + }, + { + method: "rsaEncrypt", + spy: () => cryptoSvcRsaEncryptSpy, + errorText: "rsaEncrypt error", + }, + { + method: "encryptService.encrypt", + spy: () => encryptServiceEncryptSpy, + errorText: "encryptService.encrypt error", + }, + ]; + + describe.each(methodsToTestForErrorsOrInvalidReturns)( + "trustDevice error handling and invalid return testing", + ({ method, spy, errorText }) => { + // ensures that error propagation works correctly + it(`throws an error if ${method} fails`, async () => { + const methodSpy = spy(); + methodSpy.mockRejectedValue(new Error(errorText)); + await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(errorText); + }); + + test.each([null, undefined])( + `throws an error if ${method} returns %s`, + async (invalidValue) => { + const methodSpy = spy(); + methodSpy.mockResolvedValue(invalidValue); + await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(); + } + ); + } + ); + }); + + describe("decryptUserKeyWithDeviceKey", () => { + let mockDeviceKey: DeviceKey; + let mockEncryptedDevicePrivateKey: EncString; + let mockEncryptedUserKey: EncString; + let mockUserKey: UserKey; + + beforeEach(() => { + const mockDeviceKeyRandomBytes = new Uint8Array(deviceKeyBytesLength) as CsprngArray; + mockDeviceKey = new SymmetricCryptoKey(mockDeviceKeyRandomBytes) as DeviceKey; + + const mockUserKeyRandomBytes = new Uint8Array(userKeyBytesLength) as CsprngArray; + mockUserKey = new SymmetricCryptoKey(mockUserKeyRandomBytes) as UserKey; + + mockEncryptedDevicePrivateKey = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "mockEncryptedDevicePrivateKey" + ); + + mockEncryptedUserKey = new EncString( + EncryptionType.AesCbc256_HmacSha256_B64, + "mockEncryptedUserKey" + ); + + jest.clearAllMocks(); + }); + + it("returns null when device key isn't provided and isn't in state", async () => { + const getDeviceKeySpy = jest + .spyOn(deviceTrustCryptoService, "getDeviceKey") + .mockResolvedValue(null); + + const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockEncryptedDevicePrivateKey, + mockEncryptedUserKey + ); + + expect(result).toBeNull(); + + expect(getDeviceKeySpy).toHaveBeenCalledTimes(1); + }); + + it("successfully returns the user key when provided keys (including device key) can decrypt it", async () => { + const decryptToBytesSpy = jest + .spyOn(encryptService, "decryptToBytes") + .mockResolvedValue(new Uint8Array(userKeyBytesLength)); + const rsaDecryptSpy = jest + .spyOn(cryptoService, "rsaDecrypt") + .mockResolvedValue(new Uint8Array(userKeyBytesLength)); + + const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockEncryptedDevicePrivateKey, + mockEncryptedUserKey, + mockDeviceKey + ); + + expect(result).toEqual(mockUserKey); + expect(decryptToBytesSpy).toHaveBeenCalledTimes(1); + expect(rsaDecryptSpy).toHaveBeenCalledTimes(1); + }); + + it("successfully returns the user key when a device key is not provided (retrieves device key from state)", async () => { + const getDeviceKeySpy = jest + .spyOn(deviceTrustCryptoService, "getDeviceKey") + .mockResolvedValue(mockDeviceKey); + + const decryptToBytesSpy = jest + .spyOn(encryptService, "decryptToBytes") + .mockResolvedValue(new Uint8Array(userKeyBytesLength)); + const rsaDecryptSpy = jest + .spyOn(cryptoService, "rsaDecrypt") + .mockResolvedValue(new Uint8Array(userKeyBytesLength)); + + // Call without providing a device key + const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockEncryptedDevicePrivateKey, + mockEncryptedUserKey + ); + + expect(getDeviceKeySpy).toHaveBeenCalledTimes(1); + + expect(result).toEqual(mockUserKey); + expect(decryptToBytesSpy).toHaveBeenCalledTimes(1); + expect(rsaDecryptSpy).toHaveBeenCalledTimes(1); + }); + + it("returns null and removes device key when the decryption fails", async () => { + const decryptToBytesSpy = jest + .spyOn(encryptService, "decryptToBytes") + .mockRejectedValue(new Error("Decryption error")); + const setDeviceKeySpy = jest.spyOn(deviceTrustCryptoService as any, "setDeviceKey"); + + const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockEncryptedDevicePrivateKey, + mockEncryptedUserKey, + mockDeviceKey + ); + + expect(result).toBeNull(); + expect(decryptToBytesSpy).toHaveBeenCalledTimes(1); + expect(setDeviceKeySpy).toHaveBeenCalledTimes(1); + expect(setDeviceKeySpy).toHaveBeenCalledWith(null); + }); + }); + + describe("rotateDevicesTrust", () => { + let fakeNewUserKey: UserKey = null; + + const FakeNewUserKeyMarker = 1; + const FakeOldUserKeyMarker = 5; + const FakeDecryptedPublicKeyMarker = 17; + + beforeEach(() => { + const fakeNewUserKeyData = new Uint8Array(64); + fakeNewUserKeyData.fill(FakeNewUserKeyMarker, 0, 1); + fakeNewUserKey = new SymmetricCryptoKey(fakeNewUserKeyData) as UserKey; + }); + + it("does an early exit when the current device is not a trusted device", async () => { + stateService.getDeviceKey.mockResolvedValue(null); + + await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, ""); + + expect(devicesApiService.updateTrust).not.toBeCalled(); + }); + + describe("is on a trusted device", () => { + beforeEach(() => { + stateService.getDeviceKey.mockResolvedValue( + new SymmetricCryptoKey(new Uint8Array(deviceKeyBytesLength)) as DeviceKey + ); + }); + + it("rotates current device keys and calls api service when the current device is trusted", async () => { + const currentEncryptedPublicKey = new EncString("2.cHVibGlj|cHVibGlj|cHVibGlj"); + const currentEncryptedUserKey = new EncString("4.dXNlcg=="); + + const fakeOldUserKeyData = new Uint8Array(new Uint8Array(64)); + // Fill the first byte with something identifiable + fakeOldUserKeyData.fill(FakeOldUserKeyMarker, 0, 1); + + // Mock the retrieval of a user key that differs from the new one passed into the method + stateService.getUserKey.mockResolvedValue( + new SymmetricCryptoKey(fakeOldUserKeyData) as UserKey + ); + + appIdService.getAppId.mockResolvedValue("test_device_identifier"); + + devicesApiService.getDeviceKeys.mockImplementation((deviceIdentifier, secretRequest) => { + if ( + deviceIdentifier !== "test_device_identifier" || + secretRequest.masterPasswordHash !== "my_password_hash" + ) { + return Promise.resolve(null); + } + + return Promise.resolve( + new ProtectedDeviceResponse({ + id: "", + creationDate: "", + identifier: "test_device_identifier", + name: "Firefox", + type: DeviceType.FirefoxBrowser, + encryptedPublicKey: currentEncryptedPublicKey.encryptedString, + encryptedUserKey: currentEncryptedUserKey.encryptedString, + }) + ); + }); + + // Mock the decryption of the public key with the old user key + encryptService.decryptToBytes.mockImplementationOnce((_encValue, privateKeyValue) => { + expect(privateKeyValue.key.byteLength).toBe(64); + expect(new Uint8Array(privateKeyValue.key)[0]).toBe(FakeOldUserKeyMarker); + const data = new Uint8Array(250); + data.fill(FakeDecryptedPublicKeyMarker, 0, 1); + return Promise.resolve(data); + }); + + // Mock the encryption of the new user key with the decrypted public key + cryptoService.rsaEncrypt.mockImplementationOnce((data, publicKey) => { + expect(data.byteLength).toBe(64); // New key should also be 64 bytes + expect(new Uint8Array(data)[0]).toBe(FakeNewUserKeyMarker); // New key should have the first byte be '1'; + + expect(new Uint8Array(publicKey)[0]).toBe(FakeDecryptedPublicKeyMarker); + return Promise.resolve(new EncString("4.ZW5jcnlwdGVkdXNlcg==")); + }); + + // Mock the reencryption of the device public key with the new user key + encryptService.encrypt.mockImplementationOnce((plainValue, key) => { + expect(plainValue).toBeInstanceOf(Uint8Array); + expect(new Uint8Array(plainValue as Uint8Array)[0]).toBe(FakeDecryptedPublicKeyMarker); + + expect(new Uint8Array(key.key)[0]).toBe(FakeNewUserKeyMarker); + return Promise.resolve( + new EncString("2.ZW5jcnlwdGVkcHVibGlj|ZW5jcnlwdGVkcHVibGlj|ZW5jcnlwdGVkcHVibGlj") + ); + }); + + await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, "my_password_hash"); + + expect(devicesApiService.updateTrust).toBeCalledWith( + matches((updateTrustModel: UpdateDevicesTrustRequest) => { + return ( + updateTrustModel.currentDevice.encryptedPublicKey === + "2.ZW5jcnlwdGVkcHVibGlj|ZW5jcnlwdGVkcHVibGlj|ZW5jcnlwdGVkcHVibGlj" && + updateTrustModel.currentDevice.encryptedUserKey === "4.ZW5jcnlwdGVkdXNlcg==" + ); + }) + ); + }); + }); + }); + }); +}); diff --git a/libs/common/src/auth/services/devices-api.service.implementation.ts b/libs/common/src/auth/services/devices-api.service.implementation.ts new file mode 100644 index 00000000000..e149a79ea2f --- /dev/null +++ b/libs/common/src/auth/services/devices-api.service.implementation.ts @@ -0,0 +1,96 @@ +import { ApiService } from "../../abstractions/api.service"; +import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; +import { ListResponse } from "../../models/response/list.response"; +import { Utils } from "../../platform/misc/utils"; +import { TrustedDeviceKeysRequest } from "../../services/devices/requests/trusted-device-keys.request"; +import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction"; +import { SecretVerificationRequest } from "../models/request/secret-verification.request"; +import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request"; +import { ProtectedDeviceResponse } from "../models/response/protected-device.response"; + +export class DevicesApiServiceImplementation implements DevicesApiServiceAbstraction { + constructor(private apiService: ApiService) {} + + async getKnownDevice(email: string, deviceIdentifier: string): Promise { + const r = await this.apiService.send( + "GET", + "/devices/knowndevice", + null, + false, + true, + null, + (headers) => { + headers.set("X-Device-Identifier", deviceIdentifier); + headers.set("X-Request-Email", Utils.fromUtf8ToUrlB64(email)); + } + ); + return r as boolean; + } + + /** + * Get device by identifier + * @param deviceIdentifier - client generated id (not device id in DB) + */ + async getDeviceByIdentifier(deviceIdentifier: string): Promise { + const r = await this.apiService.send( + "GET", + `/devices/identifier/${deviceIdentifier}`, + null, + true, + true + ); + return new DeviceResponse(r); + } + + async getDevices(): Promise> { + const r = await this.apiService.send("GET", "/devices", null, true, true, null); + return new ListResponse(r, DeviceResponse); + } + + async updateTrustedDeviceKeys( + deviceIdentifier: string, + devicePublicKeyEncryptedUserKey: string, + userKeyEncryptedDevicePublicKey: string, + deviceKeyEncryptedDevicePrivateKey: string + ): Promise { + const request = new TrustedDeviceKeysRequest( + devicePublicKeyEncryptedUserKey, + userKeyEncryptedDevicePublicKey, + deviceKeyEncryptedDevicePrivateKey + ); + + const result = await this.apiService.send( + "PUT", + `/devices/${deviceIdentifier}/keys`, + request, + true, + true + ); + + return new DeviceResponse(result); + } + + async updateTrust(updateDevicesTrustRequestModel: UpdateDevicesTrustRequest): Promise { + await this.apiService.send( + "POST", + "/devices/update-trust", + updateDevicesTrustRequestModel, + true, + false + ); + } + + async getDeviceKeys( + deviceIdentifier: string, + secretVerificationRequest: SecretVerificationRequest + ): Promise { + const result = await this.apiService.send( + "POST", + `/devices/${deviceIdentifier}/retrieve-keys`, + secretVerificationRequest, + true, + true + ); + return new ProtectedDeviceResponse(result); + } +} diff --git a/libs/common/src/auth/services/key-connector.service.ts b/libs/common/src/auth/services/key-connector.service.ts index c601e6d42b3..e277537cbde 100644 --- a/libs/common/src/auth/services/key-connector.service.ts +++ b/libs/common/src/auth/services/key-connector.service.ts @@ -1,13 +1,13 @@ import { ApiService } from "../../abstractions/api.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { CryptoFunctionService } from "../../abstractions/cryptoFunction.service"; -import { LogService } from "../../abstractions/log.service"; -import { StateService } from "../../abstractions/state.service"; import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserType } from "../../admin-console/enums"; -import { Utils } from "../../misc/utils"; -import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { KeysRequest } from "../../models/request/keys.request"; +import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; +import { MasterKey, SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service"; import { TokenService } from "../abstractions/token.service"; import { KdfConfig } from "../models/domain/kdf-config"; @@ -24,7 +24,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { private logService: LogService, private organizationService: OrganizationService, private cryptoFunctionService: CryptoFunctionService, - private logoutCallback: (expired: boolean, userId?: string) => void + private logoutCallback: (expired: boolean, userId?: string) => Promise ) {} setUsesKeyConnector(usesKeyConnector: boolean) { @@ -45,8 +45,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { async migrateUser() { const organization = await this.getManagingOrganization(); - const key = await this.cryptoService.getKey(); - const keyConnectorRequest = new KeyConnectorUserKeyRequest(key.encKeyB64); + const masterKey = await this.cryptoService.getMasterKey(); + const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); try { await this.apiService.postUserKeyToKeyConnector( @@ -60,12 +60,13 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { await this.apiService.postConvertToKeyConnector(); } - async getAndSetKey(url: string) { + // TODO: UserKey should be renamed to MasterKey and typed accordingly + async setMasterKeyFromUrl(url: string) { try { - const userKeyResponse = await this.apiService.getUserKeyFromKeyConnector(url); - const keyArr = Utils.fromB64ToArray(userKeyResponse.key); - const k = new SymmetricCryptoKey(keyArr); - await this.cryptoService.setKey(k); + const masterKeyResponse = await this.apiService.getMasterKeyFromKeyConnector(url); + const keyArr = Utils.fromB64ToArray(masterKeyResponse.key); + const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey; + await this.cryptoService.setMasterKey(masterKey); } catch (e) { this.handleKeyConnectorError(e); } @@ -83,25 +84,36 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { } async convertNewSsoUserToKeyConnector(tokenResponse: IdentityTokenResponse, orgId: string) { - const { kdf, kdfIterations, kdfMemory, kdfParallelism, keyConnectorUrl } = tokenResponse; - const password = await this.cryptoFunctionService.randomBytes(64); + // TODO: Remove after tokenResponse.keyConnectorUrl is deprecated in 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537) + const { + kdf, + kdfIterations, + kdfMemory, + kdfParallelism, + keyConnectorUrl: legacyKeyConnectorUrl, + userDecryptionOptions, + } = tokenResponse; + const password = await this.cryptoFunctionService.aesGenerateKey(512); const kdfConfig = new KdfConfig(kdfIterations, kdfMemory, kdfParallelism); - const k = await this.cryptoService.makeKey( + const masterKey = await this.cryptoService.makeMasterKey( Utils.fromBufferToB64(password), await this.tokenService.getEmail(), kdf, kdfConfig ); - const keyConnectorRequest = new KeyConnectorUserKeyRequest(k.encKeyB64); - await this.cryptoService.setKey(k); + const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + await this.cryptoService.setMasterKey(masterKey); - const encKey = await this.cryptoService.makeEncKey(k); - await this.cryptoService.setEncKey(encKey[1].encryptedString); + const userKey = await this.cryptoService.makeUserKey(masterKey); + await this.cryptoService.setUserKey(userKey[0]); + await this.cryptoService.setMasterKeyEncryptedUserKey(userKey[1].encryptedString); const [pubKey, privKey] = await this.cryptoService.makeKeyPair(); try { + const keyConnectorUrl = + legacyKeyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl; await this.apiService.postUserKeyToKeyConnector(keyConnectorUrl, keyConnectorRequest); } catch (e) { this.handleKeyConnectorError(e); @@ -109,7 +121,7 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { const keys = new KeysRequest(pubKey, privKey.encryptedString); const setPasswordRequest = new SetKeyConnectorKeyRequest( - encKey[1].encryptedString, + userKey[1].encryptedString, kdf, kdfConfig, orgId, diff --git a/libs/common/src/auth/services/login.service.ts b/libs/common/src/auth/services/login.service.ts index d1ab63c5660..f1d038b2f80 100644 --- a/libs/common/src/auth/services/login.service.ts +++ b/libs/common/src/auth/services/login.service.ts @@ -1,4 +1,4 @@ -import { StateService } from "../../abstractions/state.service"; +import { StateService } from "../../platform/abstractions/state.service"; import { LoginService as LoginServiceAbstraction } from "../abstractions/login.service"; export class LoginService implements LoginServiceAbstraction { diff --git a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts new file mode 100644 index 00000000000..7d1ade83321 --- /dev/null +++ b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts @@ -0,0 +1,123 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { OrganizationUserService } from "../../abstractions/organization-user/organization-user.service"; +import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { StateService } from "../../platform/abstractions/state.service"; + +import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation"; + +describe("PasswordResetEnrollmentServiceImplementation", () => { + let organizationApiService: MockProxy; + let stateService: MockProxy; + let cryptoService: MockProxy; + let organizationUserService: MockProxy; + let i18nService: MockProxy; + let service: PasswordResetEnrollmentServiceImplementation; + + beforeEach(() => { + organizationApiService = mock(); + stateService = mock(); + cryptoService = mock(); + organizationUserService = mock(); + i18nService = mock(); + service = new PasswordResetEnrollmentServiceImplementation( + organizationApiService, + stateService, + cryptoService, + organizationUserService, + i18nService + ); + }); + + describe("enrollIfRequired", () => { + it("should not enroll when user is already enrolled in password reset", async () => { + const mockResponse = new OrganizationAutoEnrollStatusResponse({ + ResetPasswordEnabled: true, + Id: "orgId", + }); + organizationApiService.getAutoEnrollStatus.mockResolvedValue(mockResponse); + + const enrollSpy = jest.spyOn(service, "enroll"); + enrollSpy.mockResolvedValue(); + + await service.enrollIfRequired("ssoId"); + + expect(service.enroll).not.toHaveBeenCalled(); + }); + + it("should enroll when user is not enrolled in password reset", async () => { + const mockResponse = new OrganizationAutoEnrollStatusResponse({ + ResetPasswordEnabled: false, + Id: "orgId", + }); + organizationApiService.getAutoEnrollStatus.mockResolvedValue(mockResponse); + + const enrollSpy = jest.spyOn(service, "enroll"); + enrollSpy.mockResolvedValue(); + + await service.enrollIfRequired("ssoId"); + + expect(service.enroll).toHaveBeenCalled(); + }); + }); + + describe("enroll", () => { + it("should throw an error if the organization keys are not found", async () => { + organizationApiService.getKeys.mockResolvedValue(null); + i18nService.t.mockReturnValue("resetPasswordOrgKeysError"); + + const result = () => service.enroll("orgId"); + + await expect(result).rejects.toThrowError("resetPasswordOrgKeysError"); + }); + + it("should enroll the user when no user id or key is provided", async () => { + const orgKeyResponse = { + publicKey: "publicKey", + privateKey: "privateKey", + }; + const encryptedKey = { encryptedString: "encryptedString" }; + organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any); + stateService.getUserId.mockResolvedValue("userId"); + cryptoService.getUserKey.mockResolvedValue({ key: "key" } as any); + cryptoService.rsaEncrypt.mockResolvedValue(encryptedKey as any); + + await service.enroll("orgId"); + + expect( + organizationUserService.putOrganizationUserResetPasswordEnrollment + ).toHaveBeenCalledWith( + "orgId", + "userId", + expect.objectContaining({ + resetPasswordKey: encryptedKey.encryptedString, + }) + ); + }); + + it("should enroll the user when a user id and key is provided", async () => { + const orgKeyResponse = { + publicKey: "publicKey", + privateKey: "privateKey", + }; + const encryptedKey = { encryptedString: "encryptedString" }; + organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any); + cryptoService.rsaEncrypt.mockResolvedValue(encryptedKey as any); + + await service.enroll("orgId", "userId", { key: "key" } as any); + + expect( + organizationUserService.putOrganizationUserResetPasswordEnrollment + ).toHaveBeenCalledWith( + "orgId", + "userId", + expect.objectContaining({ + resetPasswordKey: encryptedKey.encryptedString, + }) + ); + }); + }); +}); diff --git a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts new file mode 100644 index 00000000000..c6e19635c8a --- /dev/null +++ b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts @@ -0,0 +1,56 @@ +import { OrganizationUserService } from "../../abstractions/organization-user/organization-user.service"; +import { OrganizationUserResetPasswordEnrollmentRequest } from "../../abstractions/organization-user/requests"; +import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; +import { UserKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { PasswordResetEnrollmentServiceAbstraction } from "../abstractions/password-reset-enrollment.service.abstraction"; + +export class PasswordResetEnrollmentServiceImplementation + implements PasswordResetEnrollmentServiceAbstraction +{ + constructor( + protected organizationApiService: OrganizationApiServiceAbstraction, + protected stateService: StateService, + protected cryptoService: CryptoService, + protected organizationUserService: OrganizationUserService, + protected i18nService: I18nService + ) {} + + async enrollIfRequired(organizationSsoIdentifier: string): Promise { + const orgAutoEnrollStatusResponse = await this.organizationApiService.getAutoEnrollStatus( + organizationSsoIdentifier + ); + + if (!orgAutoEnrollStatusResponse.resetPasswordEnabled) { + await this.enroll(orgAutoEnrollStatusResponse.id, null, null); + } + } + + async enroll(organizationId: string): Promise; + async enroll(organizationId: string, userId: string, userKey: UserKey): Promise; + async enroll(organizationId: string, userId?: string, userKey?: UserKey): Promise { + const orgKeyResponse = await this.organizationApiService.getKeys(organizationId); + if (orgKeyResponse == null) { + throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); + } + + const orgPublicKey = Utils.fromB64ToArray(orgKeyResponse.publicKey); + + userId = userId ?? (await this.stateService.getUserId()); + userKey = userKey ?? (await this.cryptoService.getUserKey(userId)); + // RSA Encrypt user's userKey.key with organization public key + const encryptedKey = await this.cryptoService.rsaEncrypt(userKey.key, orgPublicKey); + + const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest(); + resetRequest.resetPasswordKey = encryptedKey.encryptedString; + + await this.organizationUserService.putOrganizationUserResetPasswordEnrollment( + organizationId, + userId, + resetRequest + ); + } +} diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index fec14272572..d68917f21be 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -1,5 +1,5 @@ -import { StateService } from "../../abstractions/state.service"; -import { Utils } from "../../misc/utils"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; import { TokenService as TokenServiceAbstraction } from "../abstractions/token.service"; import { IdentityTokenResponse } from "../models/response/identity-token.response"; diff --git a/libs/common/src/auth/services/two-factor.service.ts b/libs/common/src/auth/services/two-factor.service.ts index 542d190c04e..71687b675ee 100644 --- a/libs/common/src/auth/services/two-factor.service.ts +++ b/libs/common/src/auth/services/two-factor.service.ts @@ -1,5 +1,5 @@ -import { I18nService } from "../../abstractions/i18n.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { TwoFactorProviderDetails, TwoFactorService as TwoFactorServiceAbstraction, @@ -55,7 +55,7 @@ export const TwoFactorProviders: Partial { + const decryptionOptions = await this.stateService.getAccountDecryptionOptions({ userId }); + + if (decryptionOptions?.hasMasterPassword != undefined) { + return decryptionOptions.hasMasterPassword; + } + + // TODO: PM-3518 - Left for backwards compatibility, remove after 2023.12.0 + return !(await this.stateService.getUsesKeyConnector({ userId })); + } + + async hasMasterPasswordAndMasterKeyHash(userId?: string): Promise { + return ( + (await this.hasMasterPassword(userId)) && + (await this.cryptoService.getMasterKeyHash()) != null + ); + } + private validateInput(verification: Verification) { if (verification?.secret == null || verification.secret === "") { if (verification.type === VerificationType.OTP) { diff --git a/libs/common/src/auth/webauthn-iframe.ts b/libs/common/src/auth/webauthn-iframe.ts index 969dde04605..b08977e87c9 100644 --- a/libs/common/src/auth/webauthn-iframe.ts +++ b/libs/common/src/auth/webauthn-iframe.ts @@ -1,5 +1,5 @@ -import { I18nService } from "../abstractions/i18n.service"; -import { PlatformUtilsService } from "../abstractions/platformUtils.service"; +import { I18nService } from "../platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; export class WebAuthnIFrame { private iframe: HTMLIFrameElement = null; diff --git a/libs/common/src/billing/enums/bitwarden-product-type.enum.ts b/libs/common/src/billing/enums/bitwarden-product-type.enum.ts new file mode 100644 index 00000000000..76b0899fd9c --- /dev/null +++ b/libs/common/src/billing/enums/bitwarden-product-type.enum.ts @@ -0,0 +1,4 @@ +export enum BitwardenProductType { + PasswordManager = 0, + SecretsManager = 1, +} diff --git a/libs/common/src/billing/enums/index.ts b/libs/common/src/billing/enums/index.ts index b4f96cd8fd6..70a3495a8bf 100644 --- a/libs/common/src/billing/enums/index.ts +++ b/libs/common/src/billing/enums/index.ts @@ -2,3 +2,4 @@ export * from "./payment-method-type.enum"; export * from "./plan-sponsorship-type.enum"; export * from "./plan-type.enum"; export * from "./transaction-type.enum"; +export * from "./bitwarden-product-type.enum"; diff --git a/libs/common/src/billing/models/request/organization-sm-subscription-update.request.ts b/libs/common/src/billing/models/request/organization-sm-subscription-update.request.ts new file mode 100644 index 00000000000..7971b1f6a91 --- /dev/null +++ b/libs/common/src/billing/models/request/organization-sm-subscription-update.request.ts @@ -0,0 +1,21 @@ +export class OrganizationSmSubscriptionUpdateRequest { + /** + * The number of seats to add or remove from the subscription. + */ + seatAdjustment: number; + + /** + * The maximum number of seats that can be auto-scaled for the subscription. + */ + maxAutoscaleSeats?: number; + + /** + * The number of additional service accounts to add or remove from the subscription. + */ + serviceAccountAdjustment: number; + + /** + * The maximum number of additional service accounts that can be auto-scaled for the subscription. + */ + maxAutoscaleServiceAccounts?: number; +} diff --git a/libs/common/src/billing/models/request/organization-subscription-update.request.ts b/libs/common/src/billing/models/request/organization-subscription-update.request.ts index 9db3d4be80e..d7566806f6b 100644 --- a/libs/common/src/billing/models/request/organization-subscription-update.request.ts +++ b/libs/common/src/billing/models/request/organization-subscription-update.request.ts @@ -1,3 +1,23 @@ export class OrganizationSubscriptionUpdateRequest { - constructor(public seatAdjustment: number, public maxAutoscaleSeats?: number) {} + /** + * The number of seats to add or remove from the subscription. + * Applies to both PM and SM request types. + */ + seatAdjustment: number; + + /** + * The maximum number of seats that can be auto-scaled for the subscription. + * Applies to both PM and SM request types. + */ + maxAutoscaleSeats?: number; + + /** + * Build a subscription update request for the Password Manager product type. + * @param seatAdjustment - The number of seats to add or remove from the subscription. + * @param maxAutoscaleSeats - The maximum number of seats that can be auto-scaled for the subscription. + */ + constructor(seatAdjustment: number, maxAutoscaleSeats?: number) { + this.seatAdjustment = seatAdjustment; + this.maxAutoscaleSeats = maxAutoscaleSeats; + } } diff --git a/libs/common/src/billing/models/request/sm-subscribe.request.ts b/libs/common/src/billing/models/request/sm-subscribe.request.ts new file mode 100644 index 00000000000..581d3007c81 --- /dev/null +++ b/libs/common/src/billing/models/request/sm-subscribe.request.ts @@ -0,0 +1,4 @@ +export class SecretsManagerSubscribeRequest { + additionalSmSeats: number; + additionalServiceAccounts: number; +} diff --git a/libs/common/src/billing/models/response/organization-subscription.response.ts b/libs/common/src/billing/models/response/organization-subscription.response.ts index 35d66bba048..a86adbabe7c 100644 --- a/libs/common/src/billing/models/response/organization-subscription.response.ts +++ b/libs/common/src/billing/models/response/organization-subscription.response.ts @@ -3,6 +3,7 @@ import { OrganizationResponse } from "../../../admin-console/models/response/org import { BillingSubscriptionResponse, BillingSubscriptionUpcomingInvoiceResponse, + BillingCustomerDiscount, } from "./subscription.response"; export class OrganizationSubscriptionResponse extends OrganizationResponse { @@ -10,8 +11,10 @@ export class OrganizationSubscriptionResponse extends OrganizationResponse { storageGb: number; subscription: BillingSubscriptionResponse; upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse; + discount: BillingCustomerDiscount; expiration: string; expirationWithoutGracePeriod: string; + secretsManagerBeta: boolean; constructor(response: any) { super(response); @@ -24,7 +27,10 @@ export class OrganizationSubscriptionResponse extends OrganizationResponse { upcomingInvoice == null ? null : new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice); + const discount = this.getResponseProperty("Discount"); + this.discount = discount == null ? null : new BillingCustomerDiscount(discount); this.expiration = this.getResponseProperty("Expiration"); this.expirationWithoutGracePeriod = this.getResponseProperty("ExpirationWithoutGracePeriod"); + this.secretsManagerBeta = this.getResponseProperty("SecretsManagerBeta"); } } diff --git a/libs/common/src/billing/models/response/plan.response.ts b/libs/common/src/billing/models/response/plan.response.ts index 8368849b86d..bb09a9b143c 100644 --- a/libs/common/src/billing/models/response/plan.response.ts +++ b/libs/common/src/billing/models/response/plan.response.ts @@ -1,10 +1,11 @@ import { ProductType } from "../../../enums"; import { BaseResponse } from "../../../models/response/base.response"; -import { PlanType } from "../../enums"; +import { BitwardenProductType, PlanType } from "../../enums"; export class PlanResponse extends BaseResponse { type: PlanType; product: ProductType; + bitwardenProduct: BitwardenProductType; name: string; isAnnual: boolean; nameLocalizationKey: string; @@ -48,6 +49,15 @@ export class PlanResponse extends BaseResponse { additionalStoragePricePerGb: number; premiumAccessOptionPrice: number; + // SM only + additionalPricePerServiceAccount: number; + baseServiceAccount: number; + maxServiceAccount: number; + hasAdditionalServiceAccountOption: boolean; + maxProjects: number; + maxAdditionalServiceAccounts: number; + stripeServiceAccountPlanId: string; + constructor(response: any) { super(response); this.type = this.getResponseProperty("Type"); @@ -90,5 +100,18 @@ export class PlanResponse extends BaseResponse { this.seatPrice = this.getResponseProperty("SeatPrice"); this.additionalStoragePricePerGb = this.getResponseProperty("AdditionalStoragePricePerGb"); this.premiumAccessOptionPrice = this.getResponseProperty("PremiumAccessOptionPrice"); + + this.bitwardenProduct = this.getResponseProperty("BitwardenProduct"); + this.additionalPricePerServiceAccount = this.getResponseProperty( + "AdditionalPricePerServiceAccount" + ); + this.baseServiceAccount = this.getResponseProperty("BaseServiceAccount"); + this.maxServiceAccount = this.getResponseProperty("MaxServiceAccount"); + this.hasAdditionalServiceAccountOption = this.getResponseProperty( + "HasAdditionalServiceAccountOption" + ); + this.maxProjects = this.getResponseProperty("MaxProjects"); + this.maxAdditionalServiceAccounts = this.getResponseProperty("MaxAdditionalServiceAccounts"); + this.stripeServiceAccountPlanId = this.getResponseProperty("StripeServiceAccountPlanId"); } } diff --git a/libs/common/src/billing/models/response/subscription.response.ts b/libs/common/src/billing/models/response/subscription.response.ts index 8230d98417a..29850eb7677 100644 --- a/libs/common/src/billing/models/response/subscription.response.ts +++ b/libs/common/src/billing/models/response/subscription.response.ts @@ -1,4 +1,5 @@ import { BaseResponse } from "../../../models/response/base.response"; +import { BitwardenProductType } from "../../enums"; export class SubscriptionResponse extends BaseResponse { storageName: string; @@ -6,6 +7,7 @@ export class SubscriptionResponse extends BaseResponse { maxStorageGb: number; subscription: BillingSubscriptionResponse; upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse; + discount: BillingCustomerDiscount; license: any; expiration: string; usingInAppPurchase: boolean; @@ -20,11 +22,13 @@ export class SubscriptionResponse extends BaseResponse { this.usingInAppPurchase = this.getResponseProperty("UsingInAppPurchase"); const subscription = this.getResponseProperty("Subscription"); const upcomingInvoice = this.getResponseProperty("UpcomingInvoice"); + const discount = this.getResponseProperty("Discount"); this.subscription = subscription == null ? null : new BillingSubscriptionResponse(subscription); this.upcomingInvoice = upcomingInvoice == null ? null : new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice); + this.discount = discount == null ? null : new BillingCustomerDiscount(discount); } } @@ -62,6 +66,8 @@ export class BillingSubscriptionItemResponse extends BaseResponse { quantity: number; interval: string; sponsoredSubscriptionItem: boolean; + addonSubscriptionItem: boolean; + bitwardenProduct: BitwardenProductType; constructor(response: any) { super(response); @@ -70,6 +76,8 @@ export class BillingSubscriptionItemResponse extends BaseResponse { this.quantity = this.getResponseProperty("Quantity"); this.interval = this.getResponseProperty("Interval"); this.sponsoredSubscriptionItem = this.getResponseProperty("SponsoredSubscriptionItem"); + this.addonSubscriptionItem = this.getResponseProperty("AddonSubscriptionItem"); + this.bitwardenProduct = this.getResponseProperty("BitwardenProduct"); } } @@ -83,3 +91,14 @@ export class BillingSubscriptionUpcomingInvoiceResponse extends BaseResponse { this.amount = this.getResponseProperty("Amount"); } } + +export class BillingCustomerDiscount extends BaseResponse { + id: string; + active: boolean; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.active = this.getResponseProperty("Active"); + } +} diff --git a/libs/common/src/enums/client-type.enum.ts b/libs/common/src/enums/client-type.enum.ts index 246769ebf21..54653f74462 100644 --- a/libs/common/src/enums/client-type.enum.ts +++ b/libs/common/src/enums/client-type.enum.ts @@ -2,7 +2,7 @@ export enum ClientType { Web = "web", Browser = "browser", Desktop = "desktop", - Mobile = "mobile", + // Mobile = "mobile", Cli = "cli", - DirectoryConnector = "connector", + // DirectoryConnector = "connector", } diff --git a/libs/common/src/enums/device-type.enum.ts b/libs/common/src/enums/device-type.enum.ts index d5ab33bbdd0..663c4824892 100644 --- a/libs/common/src/enums/device-type.enum.ts +++ b/libs/common/src/enums/device-type.enum.ts @@ -23,3 +23,16 @@ export enum DeviceType { SDK = 21, Server = 22, } + +export const MobileDeviceTypes: Set = new Set([ + DeviceType.Android, + DeviceType.iOS, + DeviceType.AndroidAmazon, +]); + +export const DesktopDeviceTypes: Set = new Set([ + DeviceType.WindowsDesktop, + DeviceType.MacOsDesktop, + DeviceType.LinuxDesktop, + DeviceType.UWP, +]); diff --git a/libs/common/src/enums/event-type.enum.ts b/libs/common/src/enums/event-type.enum.ts index 72cd73c39bc..3232d885dca 100644 --- a/libs/common/src/enums/event-type.enum.ts +++ b/libs/common/src/enums/event-type.enum.ts @@ -10,6 +10,7 @@ export enum EventType { User_ClientExportedVault = 1007, User_UpdatedTempPassword = 1008, User_MigratedKeyToKeyConnector = 1009, + User_RequestedDeviceApproval = 1010, Cipher_Created = 1100, Cipher_Updated = 1101, @@ -51,6 +52,8 @@ export enum EventType { OrganizationUser_FirstSsoLogin = 1510, OrganizationUser_Revoked = 1511, OrganizationUser_Restored = 1512, + OrganizationUser_ApprovedAuthRequest = 1513, + OrganizationUser_RejectedAuthRequest = 1514, Organization_Updated = 1600, Organization_PurgedVault = 1601, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index e8a05911b9f..cc0873351b8 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -2,4 +2,10 @@ export enum FeatureFlag { DisplayEuEnvironmentFlag = "display-eu-environment", DisplayLowKdfIterationWarningFlag = "display-kdf-iteration-warning", TrustedDeviceEncryption = "trusted-device-encryption", + PasswordlessLogin = "passwordless-login", + AutofillV2 = "autofill-v2", + BrowserFilelessImport = "browser-fileless-import", } + +// Replace this with a type safe lookup of the feature flag values in PM-2282 +export type FeatureFlagValue = number | string | boolean; diff --git a/libs/common/src/enums/index.ts b/libs/common/src/enums/index.ts index 87a688b856e..b62b3ecfa81 100644 --- a/libs/common/src/enums/index.ts +++ b/libs/common/src/enums/index.ts @@ -18,7 +18,6 @@ export * from "./notification-type.enum"; export * from "./product-type.enum"; export * from "./provider-type.enum"; export * from "./secure-note-type.enum"; -export * from "./state-version.enum"; export * from "./storage-location.enum"; export * from "./theme-type.enum"; export * from "./uri-match-type.enum"; diff --git a/libs/common/src/enums/key-suffix-options.enum.ts b/libs/common/src/enums/key-suffix-options.enum.ts index 2ae98d8e9f9..b268c4b777f 100644 --- a/libs/common/src/enums/key-suffix-options.enum.ts +++ b/libs/common/src/enums/key-suffix-options.enum.ts @@ -1,4 +1,5 @@ export enum KeySuffixOptions { Auto = "auto", Biometric = "biometric", + Pin = "pin", } diff --git a/libs/common/src/enums/state-version.enum.ts b/libs/common/src/enums/state-version.enum.ts deleted file mode 100644 index 927ce3a1105..00000000000 --- a/libs/common/src/enums/state-version.enum.ts +++ /dev/null @@ -1,10 +0,0 @@ -export enum StateVersion { - One = 1, // Original flat key/value pair store - Two = 2, // Move to a typed State object - Three = 3, // Fix migration of users' premium status - Four = 4, // Fix 'Never Lock' option by removing stale data - Five = 5, // Migrate to new storage of encrypted organization keys - Six = 6, // Delete account.keys.legacyEtmKey property - Seven = 7, // Remove global desktop auto prompt setting, move to account - Latest = Seven, -} diff --git a/libs/common/src/interfaces/IEncrypted.ts b/libs/common/src/interfaces/IEncrypted.ts deleted file mode 100644 index 9ad23b2f4b3..00000000000 --- a/libs/common/src/interfaces/IEncrypted.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { EncryptionType } from "../enums"; - -export interface IEncrypted { - encryptionType?: EncryptionType; - dataBytes: ArrayBuffer; - macBytes: ArrayBuffer; - ivBytes: ArrayBuffer; -} diff --git a/libs/common/src/models/domain/encrypted-object.ts b/libs/common/src/models/domain/encrypted-object.ts deleted file mode 100644 index 08186fd357f..00000000000 --- a/libs/common/src/models/domain/encrypted-object.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { SymmetricCryptoKey } from "./symmetric-crypto-key"; - -export class EncryptedObject { - iv: ArrayBuffer; - data: ArrayBuffer; - mac: ArrayBuffer; - key: SymmetricCryptoKey; -} diff --git a/libs/common/src/models/export/card.export.ts b/libs/common/src/models/export/card.export.ts index 9818625e542..55bb3a7be14 100644 --- a/libs/common/src/models/export/card.export.ts +++ b/libs/common/src/models/export/card.export.ts @@ -1,6 +1,6 @@ +import { EncString } from "../../platform/models/domain/enc-string"; import { Card as CardDomain } from "../../vault/models/domain/card"; import { CardView } from "../../vault/models/view/card.view"; -import { EncString } from "../domain/enc-string"; export class CardExport { static template(): CardExport { diff --git a/libs/common/src/models/export/cipher.export.ts b/libs/common/src/models/export/cipher.export.ts index 44b1cb4efff..3ae6c9757dd 100644 --- a/libs/common/src/models/export/cipher.export.ts +++ b/libs/common/src/models/export/cipher.export.ts @@ -1,13 +1,14 @@ +import { EncString } from "../../platform/models/domain/enc-string"; import { CipherRepromptType } from "../../vault/enums/cipher-reprompt-type"; import { CipherType } from "../../vault/enums/cipher-type"; import { Cipher as CipherDomain } from "../../vault/models/domain/cipher"; import { CipherView } from "../../vault/models/view/cipher.view"; -import { EncString } from "../domain/enc-string"; import { CardExport } from "./card.export"; import { FieldExport } from "./field.export"; import { IdentityExport } from "./identity.export"; import { LoginExport } from "./login.export"; +import { PasswordHistoryExport } from "./password-history.export"; import { SecureNoteExport } from "./secure-note.export"; export class CipherExport { @@ -26,6 +27,10 @@ export class CipherExport { req.card = null; req.identity = null; req.reprompt = CipherRepromptType.None; + req.passwordHistory = []; + req.creationDate = null; + req.revisionDate = null; + req.deletedDate = null; return req; } @@ -63,6 +68,13 @@ export class CipherExport { break; } + if (req.passwordHistory != null) { + view.passwordHistory = req.passwordHistory.map((ph) => PasswordHistoryExport.toView(ph)); + } + + view.creationDate = req.creationDate; + view.revisionDate = req.revisionDate; + view.deletedDate = req.deletedDate; return view; } @@ -76,6 +88,7 @@ export class CipherExport { domain.notes = req.notes != null ? new EncString(req.notes) : null; domain.favorite = req.favorite; domain.reprompt = req.reprompt ?? CipherRepromptType.None; + domain.key = req.key != null ? new EncString(req.key) : null; if (req.fields != null) { domain.fields = req.fields.map((f) => FieldExport.toDomain(f)); @@ -96,6 +109,13 @@ export class CipherExport { break; } + if (req.passwordHistory != null) { + domain.passwordHistory = req.passwordHistory.map((ph) => PasswordHistoryExport.toDomain(ph)); + } + + domain.creationDate = req.creationDate; + domain.revisionDate = req.revisionDate; + domain.deletedDate = req.deletedDate; return domain; } @@ -112,6 +132,11 @@ export class CipherExport { card: CardExport; identity: IdentityExport; reprompt: CipherRepromptType; + passwordHistory: PasswordHistoryExport[] = null; + revisionDate: Date = null; + creationDate: Date = null; + deletedDate: Date = null; + key: string; // Use build method instead of ctor so that we can control order of JSON stringify for pretty print build(o: CipherView | CipherDomain) { @@ -126,6 +151,7 @@ export class CipherExport { } else { this.name = o.name?.encryptedString; this.notes = o.notes?.encryptedString; + this.key = o.key?.encryptedString; } this.favorite = o.favorite; @@ -152,5 +178,17 @@ export class CipherExport { this.identity = new IdentityExport(o.identity); break; } + + if (o.passwordHistory != null) { + if (o instanceof CipherView) { + this.passwordHistory = o.passwordHistory.map((ph) => new PasswordHistoryExport(ph)); + } else { + this.passwordHistory = o.passwordHistory.map((ph) => new PasswordHistoryExport(ph)); + } + } + + this.creationDate = o.creationDate; + this.revisionDate = o.revisionDate; + this.deletedDate = o.deletedDate; } } diff --git a/libs/common/src/models/export/collection-with-id.export.ts b/libs/common/src/models/export/collection-with-id.export.ts index b97c8b7900e..be25eaa7ef6 100644 --- a/libs/common/src/models/export/collection-with-id.export.ts +++ b/libs/common/src/models/export/collection-with-id.export.ts @@ -1,5 +1,5 @@ -import { Collection as CollectionDomain } from "../../admin-console/models/domain/collection"; -import { CollectionView } from "../../admin-console/models/view/collection.view"; +import { Collection as CollectionDomain } from "../../vault/models/domain/collection"; +import { CollectionView } from "../../vault/models/view/collection.view"; import { CollectionExport } from "./collection.export"; diff --git a/libs/common/src/models/export/collection.export.ts b/libs/common/src/models/export/collection.export.ts index aa0d7d194f2..48251d581f9 100644 --- a/libs/common/src/models/export/collection.export.ts +++ b/libs/common/src/models/export/collection.export.ts @@ -1,6 +1,6 @@ -import { Collection as CollectionDomain } from "../../admin-console/models/domain/collection"; -import { CollectionView } from "../../admin-console/models/view/collection.view"; -import { EncString } from "../domain/enc-string"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { Collection as CollectionDomain } from "../../vault/models/domain/collection"; +import { CollectionView } from "../../vault/models/view/collection.view"; export class CollectionExport { static template(): CollectionExport { diff --git a/libs/common/src/models/export/field.export.ts b/libs/common/src/models/export/field.export.ts index 645bec10867..b30ce64eeb9 100644 --- a/libs/common/src/models/export/field.export.ts +++ b/libs/common/src/models/export/field.export.ts @@ -1,7 +1,7 @@ import { FieldType, LinkedIdType } from "../../enums"; +import { EncString } from "../../platform/models/domain/enc-string"; import { Field as FieldDomain } from "../../vault/models/domain/field"; import { FieldView } from "../../vault/models/view/field.view"; -import { EncString } from "../domain/enc-string"; export class FieldExport { static template(): FieldExport { diff --git a/libs/common/src/models/export/folder.export.ts b/libs/common/src/models/export/folder.export.ts index 94446d77858..4015034ebe5 100644 --- a/libs/common/src/models/export/folder.export.ts +++ b/libs/common/src/models/export/folder.export.ts @@ -1,6 +1,6 @@ +import { EncString } from "../../platform/models/domain/enc-string"; import { Folder as FolderDomain } from "../../vault/models/domain/folder"; import { FolderView } from "../../vault/models/view/folder.view"; -import { EncString } from "../domain/enc-string"; export class FolderExport { static template(): FolderExport { diff --git a/libs/common/src/models/export/identity.export.ts b/libs/common/src/models/export/identity.export.ts index 4a1b4056569..2eb9c8364f2 100644 --- a/libs/common/src/models/export/identity.export.ts +++ b/libs/common/src/models/export/identity.export.ts @@ -1,6 +1,6 @@ +import { EncString } from "../../platform/models/domain/enc-string"; import { Identity as IdentityDomain } from "../../vault/models/domain/identity"; import { IdentityView } from "../../vault/models/view/identity.view"; -import { EncString } from "../domain/enc-string"; export class IdentityExport { static template(): IdentityExport { diff --git a/libs/common/src/models/export/login-uri.export.ts b/libs/common/src/models/export/login-uri.export.ts index fd031f64754..af9c362e44e 100644 --- a/libs/common/src/models/export/login-uri.export.ts +++ b/libs/common/src/models/export/login-uri.export.ts @@ -1,7 +1,7 @@ import { UriMatchType } from "../../enums"; +import { EncString } from "../../platform/models/domain/enc-string"; import { LoginUri as LoginUriDomain } from "../../vault/models/domain/login-uri"; import { LoginUriView } from "../../vault/models/view/login-uri.view"; -import { EncString } from "../domain/enc-string"; export class LoginUriExport { static template(): LoginUriExport { diff --git a/libs/common/src/models/export/login.export.ts b/libs/common/src/models/export/login.export.ts index f33fccc0dbc..7a22b12537f 100644 --- a/libs/common/src/models/export/login.export.ts +++ b/libs/common/src/models/export/login.export.ts @@ -1,6 +1,6 @@ +import { EncString } from "../../platform/models/domain/enc-string"; import { Login as LoginDomain } from "../../vault/models/domain/login"; import { LoginView } from "../../vault/models/view/login.view"; -import { EncString } from "../domain/enc-string"; import { LoginUriExport } from "./login-uri.export"; diff --git a/libs/common/src/models/export/password-history.export.ts b/libs/common/src/models/export/password-history.export.ts new file mode 100644 index 00000000000..0bdbc6697ac --- /dev/null +++ b/libs/common/src/models/export/password-history.export.ts @@ -0,0 +1,40 @@ +import { EncString } from "../../platform/models/domain/enc-string"; +import { Password } from "../../vault/models/domain/password"; +import { PasswordHistoryView } from "../../vault/models/view/password-history.view"; + +export class PasswordHistoryExport { + static template(): PasswordHistoryExport { + const req = new PasswordHistoryExport(); + req.password = null; + req.lastUsedDate = null; + return req; + } + + static toView(req: PasswordHistoryExport, view = new PasswordHistoryView()) { + view.password = req.password; + view.lastUsedDate = req.lastUsedDate; + return view; + } + + static toDomain(req: PasswordHistoryExport, domain = new Password()) { + domain.password = req.password != null ? new EncString(req.password) : null; + domain.lastUsedDate = req.lastUsedDate; + return domain; + } + + password: string; + lastUsedDate: Date = null; + + constructor(o?: PasswordHistoryView | Password) { + if (o == null) { + return; + } + + if (o instanceof PasswordHistoryView) { + this.password = o.password; + } else { + this.password = o.password?.encryptedString; + } + this.lastUsedDate = o.lastUsedDate; + } +} diff --git a/libs/common/src/models/request/import-organization-ciphers.request.ts b/libs/common/src/models/request/import-organization-ciphers.request.ts index 5f1a4ee4c37..0689762de32 100644 --- a/libs/common/src/models/request/import-organization-ciphers.request.ts +++ b/libs/common/src/models/request/import-organization-ciphers.request.ts @@ -1,5 +1,5 @@ -import { CollectionWithIdRequest } from "../../admin-console/models/request/collection-with-id.request"; import { CipherRequest } from "../../vault/models/request/cipher.request"; +import { CollectionWithIdRequest } from "../../vault/models/request/collection-with-id.request"; import { KvpRequest } from "./kvp.request"; diff --git a/libs/common/src/models/request/reference-event.request.ts b/libs/common/src/models/request/reference-event.request.ts index 7a0b535a126..73a2532743a 100644 --- a/libs/common/src/models/request/reference-event.request.ts +++ b/libs/common/src/models/request/reference-event.request.ts @@ -1,5 +1,6 @@ export class ReferenceEventRequest { id: string; + session: string; layout: string; flow: string; } diff --git a/libs/common/src/models/response/error.response.ts b/libs/common/src/models/response/error.response.ts index 81b69d540b9..43e30df0eaa 100644 --- a/libs/common/src/models/response/error.response.ts +++ b/libs/common/src/models/response/error.response.ts @@ -1,4 +1,4 @@ -import { Utils } from "../../misc/utils"; +import { Utils } from "../../platform/misc/utils"; import { BaseResponse } from "./base.response"; diff --git a/libs/common/src/abstractions/appId.service.ts b/libs/common/src/platform/abstractions/app-id.service.ts similarity index 100% rename from libs/common/src/abstractions/appId.service.ts rename to libs/common/src/platform/abstractions/app-id.service.ts diff --git a/libs/common/src/abstractions/broadcaster.service.ts b/libs/common/src/platform/abstractions/broadcaster.service.ts similarity index 100% rename from libs/common/src/abstractions/broadcaster.service.ts rename to libs/common/src/platform/abstractions/broadcaster.service.ts diff --git a/libs/common/src/abstractions/config/config-api.service.abstraction.ts b/libs/common/src/platform/abstractions/config/config-api.service.abstraction.ts similarity index 100% rename from libs/common/src/abstractions/config/config-api.service.abstraction.ts rename to libs/common/src/platform/abstractions/config/config-api.service.abstraction.ts diff --git a/libs/common/src/platform/abstractions/config/config.service.abstraction.ts b/libs/common/src/platform/abstractions/config/config.service.abstraction.ts new file mode 100644 index 00000000000..67f7f2f4ce7 --- /dev/null +++ b/libs/common/src/platform/abstractions/config/config.service.abstraction.ts @@ -0,0 +1,30 @@ +import { Observable } from "rxjs"; +import { SemVer } from "semver"; + +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { Region } from "../environment.service"; + +import { ServerConfig } from "./server-config"; + +export abstract class ConfigServiceAbstraction { + serverConfig$: Observable; + cloudRegion$: Observable; + getFeatureFlag$: ( + key: FeatureFlag, + defaultValue?: T + ) => Observable; + getFeatureFlag: ( + key: FeatureFlag, + defaultValue?: T + ) => Promise; + checkServerMeetsVersionRequirement$: ( + minimumRequiredServerVersion: SemVer + ) => Observable; + + /** + * Force ConfigService to fetch an updated config from the server and emit it from serverConfig$ + * @deprecated The service implementation should subscribe to an observable and use that to trigger a new fetch from + * server instead + */ + triggerServerConfigFetch: () => void; +} diff --git a/libs/common/src/abstractions/config/server-config.ts b/libs/common/src/platform/abstractions/config/server-config.ts similarity index 100% rename from libs/common/src/abstractions/config/server-config.ts rename to libs/common/src/platform/abstractions/config/server-config.ts diff --git a/libs/common/src/platform/abstractions/crypto-function.service.ts b/libs/common/src/platform/abstractions/crypto-function.service.ts new file mode 100644 index 00000000000..755eaeb1a75 --- /dev/null +++ b/libs/common/src/platform/abstractions/crypto-function.service.ts @@ -0,0 +1,87 @@ +import { CsprngArray } from "../../types/csprng"; +import { DecryptParameters } from "../models/domain/decrypt-parameters"; +import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; + +export abstract class CryptoFunctionService { + pbkdf2: ( + password: string | Uint8Array, + salt: string | Uint8Array, + algorithm: "sha256" | "sha512", + iterations: number + ) => Promise; + argon2: ( + password: string | Uint8Array, + salt: string | Uint8Array, + iterations: number, + memory: number, + parallelism: number + ) => Promise; + hkdf: ( + ikm: Uint8Array, + salt: string | Uint8Array, + info: string | Uint8Array, + outputByteSize: number, + algorithm: "sha256" | "sha512" + ) => Promise; + hkdfExpand: ( + prk: Uint8Array, + info: string | Uint8Array, + outputByteSize: number, + algorithm: "sha256" | "sha512" + ) => Promise; + hash: ( + value: string | Uint8Array, + algorithm: "sha1" | "sha256" | "sha512" | "md5" + ) => Promise; + hmac: ( + value: Uint8Array, + key: Uint8Array, + algorithm: "sha1" | "sha256" | "sha512" + ) => Promise; + compare: (a: Uint8Array, b: Uint8Array) => Promise; + hmacFast: ( + value: Uint8Array | string, + key: Uint8Array | string, + algorithm: "sha1" | "sha256" | "sha512" + ) => Promise; + compareFast: (a: Uint8Array | string, b: Uint8Array | string) => Promise; + aesEncrypt: (data: Uint8Array, iv: Uint8Array, key: Uint8Array) => Promise; + aesDecryptFastParameters: ( + data: string, + iv: string, + mac: string, + key: SymmetricCryptoKey + ) => DecryptParameters; + aesDecryptFast: ( + parameters: DecryptParameters, + mode: "cbc" | "ecb" + ) => Promise; + aesDecrypt: ( + data: Uint8Array, + iv: Uint8Array, + key: Uint8Array, + mode: "cbc" | "ecb" + ) => Promise; + rsaEncrypt: ( + data: Uint8Array, + publicKey: Uint8Array, + algorithm: "sha1" | "sha256" + ) => Promise; + rsaDecrypt: ( + data: Uint8Array, + privateKey: Uint8Array, + algorithm: "sha1" | "sha256" + ) => Promise; + rsaExtractPublicKey: (privateKey: Uint8Array) => Promise; + rsaGenerateKeyPair: (length: 1024 | 2048 | 4096) => Promise<[Uint8Array, Uint8Array]>; + /** + * Generates a key of the given length suitable for use in AES encryption + */ + aesGenerateKey: (bitLength: 128 | 192 | 256 | 512) => Promise; + /** + * Generates a random array of bytes of the given length. Uses a cryptographically secure random number generator. + * + * Do not use this for generating encryption keys. Use aesGenerateKey or rsaGenerateKeyPair instead. + */ + randomBytes: (length: number) => Promise; +} diff --git a/libs/common/src/platform/abstractions/crypto.service.ts b/libs/common/src/platform/abstractions/crypto.service.ts new file mode 100644 index 00000000000..8f1d9c48662 --- /dev/null +++ b/libs/common/src/platform/abstractions/crypto.service.ts @@ -0,0 +1,436 @@ +import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response"; +import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response"; +import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response"; +import { KdfConfig } from "../../auth/models/domain/kdf-config"; +import { KeySuffixOptions, KdfType, HashPurpose } from "../../enums"; +import { EncArrayBuffer } from "../models/domain/enc-array-buffer"; +import { EncString } from "../models/domain/enc-string"; +import { + CipherKey, + MasterKey, + OrgKey, + PinKey, + ProviderKey, + SymmetricCryptoKey, + UserKey, +} from "../models/domain/symmetric-crypto-key"; + +export abstract class CryptoService { + /** + * Sets the provided user key and stores + * any other necessary versions (such as auto, biometrics, + * or pin) + * @param key The user key to set + * @param userId The desired user + */ + setUserKey: (key: UserKey, userId?: string) => Promise; + /** + * Gets the user key from memory and sets it again, + * kicking off a refresh of any additional keys + * (such as auto, biometrics, or pin) + */ + /** + * Check if the current sessions has ever had a user key, i.e. has ever been unlocked/decrypted. + * This is key for differentiating between TDE locked and standard locked states. + * @param userId The desired user + * @returns True if the current session has ever had a user key + */ + getEverHadUserKey: (userId?: string) => Promise; + refreshAdditionalKeys: () => Promise; + /** + * Retrieves the user key + * @param userId The desired user + * @returns The user key + */ + getUserKey: (userId?: string) => Promise; + + /** + * Checks if the user is using an old encryption scheme that used the master key + * for encryption of data instead of the user key. + */ + isLegacyUser: (masterKey?: MasterKey, userId?: string) => Promise; + /** + * Use for encryption/decryption of data in order to support legacy + * encryption models. It will return the user key if available, + * if not it will return the master key. + * @param userId The desired user + */ + getUserKeyWithLegacySupport: (userId?: string) => Promise; + /** + * Retrieves the user key from storage + * @param keySuffix The desired version of the user's key to retrieve + * @param userId The desired user + * @returns The user key + */ + getUserKeyFromStorage: (keySuffix: KeySuffixOptions, userId?: string) => Promise; + + /** + * @returns True if the user key is available + */ + hasUserKey: () => Promise; + /** + * @param userId The desired user + * @returns True if the user key is set in memory + */ + hasUserKeyInMemory: (userId?: string) => Promise; + /** + * @param keySuffix The desired version of the user's key to check + * @param userId The desired user + * @returns True if the provided version of the user key is stored + */ + hasUserKeyStored: (keySuffix: KeySuffixOptions, userId?: string) => Promise; + /** + * Generates a new user key + * @param masterKey The user's master key + * @returns A new user key and the master key protected version of it + */ + makeUserKey: (key: MasterKey) => Promise<[UserKey, EncString]>; + /** + * Clears the user key + * @param clearStoredKeys Clears all stored versions of the user keys as well, + * such as the biometrics key + * @param userId The desired user + */ + clearUserKey: (clearSecretStorage?: boolean, userId?: string) => Promise; + /** + * Clears the user's stored version of the user key + * @param keySuffix The desired version of the key to clear + * @param userId The desired user + */ + clearStoredUserKey: (keySuffix: KeySuffixOptions, userId?: string) => Promise; + /** + * Stores the master key encrypted user key + * @param userKeyMasterKey The master key encrypted user key to set + * @param userId The desired user + */ + setMasterKeyEncryptedUserKey: (UserKeyMasterKey: string, userId?: string) => Promise; + /** + * Sets the user's master key + * @param key The user's master key to set + * @param userId The desired user + */ + setMasterKey: (key: MasterKey, userId?: string) => Promise; + /** + * @param userId The desired user + * @returns The user's master key + */ + getMasterKey: (userId?: string) => Promise; + + /** + * @param password The user's master password that will be used to derive a master key if one isn't found + * @param userId The desired user + */ + getOrDeriveMasterKey: (password: string, userId?: string) => Promise; + /** + * Generates a master key from the provided password + * @param password The user's master password + * @param email The user's email + * @param kdf The user's selected key derivation function to use + * @param KdfConfig The user's key derivation function configuration + * @returns A master key derived from the provided password + */ + makeMasterKey: ( + password: string, + email: string, + kdf: KdfType, + KdfConfig: KdfConfig + ) => Promise; + /** + * Clears the user's master key + * @param userId The desired user + */ + clearMasterKey: (userId?: string) => Promise; + /** + * Encrypts the existing (or provided) user key with the + * provided master key + * @param masterKey The user's master key + * @param userKey The user key + * @returns The user key and the master key protected version of it + */ + encryptUserKeyWithMasterKey: ( + masterKey: MasterKey, + userKey?: UserKey + ) => Promise<[UserKey, EncString]>; + /** + * Decrypts the user key with the provided master key + * @param masterKey The user's master key + * @param userKey The user's encrypted symmetric key + * @param userId The desired user + * @returns The user key + */ + decryptUserKeyWithMasterKey: ( + masterKey: MasterKey, + userKey?: EncString, + userId?: string + ) => Promise; + /** + * Creates a master password hash from the user's master password. Can + * be used for local authentication or for server authentication depending + * on the hashPurpose provided. + * @param password The user's master password + * @param key The user's master key + * @param hashPurpose The iterations to use for the hash + * @returns The user's master password hash + */ + hashMasterKey: (password: string, key: MasterKey, hashPurpose?: HashPurpose) => Promise; + /** + * Sets the user's master password hash + * @param keyHash The user's master password hash to set + */ + setMasterKeyHash: (keyHash: string) => Promise; + /** + * @returns The user's master password hash + */ + getMasterKeyHash: () => Promise; + /** + * Clears the user's stored master password hash + * @param userId The desired user + */ + clearMasterKeyHash: (userId?: string) => Promise; + /** + * Compares the provided master password to the stored password hash and server password hash. + * Updates the stored hash if outdated. + * @param masterPassword The user's master password + * @param key The user's master key + * @returns True if the provided master password matches either the stored + * key hash or the server key hash + */ + compareAndUpdateKeyHash: (masterPassword: string, masterKey: MasterKey) => Promise; + /** + * Stores the encrypted organization keys and clears any decrypted + * organization keys currently in memory + * @param orgs The organizations to set keys for + * @param providerOrgs The provider organizations to set keys for + */ + setOrgKeys: ( + orgs: ProfileOrganizationResponse[], + providerOrgs: ProfileProviderOrganizationResponse[] + ) => Promise; + /** + * Returns the organization's symmetric key + * @param orgId The desired organization + * @returns The organization's symmetric key + */ + getOrgKey: (orgId: string) => Promise; + /** + * @returns A map of the organization Ids to their symmetric keys + */ + getOrgKeys: () => Promise>; + /** + * Uses the org key to derive a new symmetric key for encrypting data + * @param orgKey The organization's symmetric key + */ + makeDataEncKey: (key: T) => Promise<[SymmetricCryptoKey, EncString]>; + /** + * Clears the user's stored organization keys + * @param memoryOnly Clear only the in-memory keys + * @param userId The desired user + */ + clearOrgKeys: (memoryOnly?: boolean, userId?: string) => Promise; + /** + * Stores the encrypted provider keys and clears any decrypted + * provider keys currently in memory + * @param providers The providers to set keys for + */ + setProviderKeys: (orgs: ProfileProviderResponse[]) => Promise; + /** + * @param providerId The desired provider + * @returns The provider's symmetric key + */ + getProviderKey: (providerId: string) => Promise; + /** + * @returns A map of the provider Ids to their symmetric keys + */ + getProviderKeys: () => Promise>; + /** + * @param memoryOnly Clear only the in-memory keys + * @param userId The desired user + */ + clearProviderKeys: (memoryOnly?: boolean, userId?: string) => Promise; + /** + * Returns the public key from memory. If not available, extracts it + * from the private key and stores it in memory + * @returns The user's public key + */ + getPublicKey: () => Promise; + /** + * Creates a new organization key and encrypts it with the user's public key. + * This method can also return Provider keys for creating new Provider users. + * @returns The new encrypted org key and the decrypted key itself + */ + makeOrgKey: () => Promise<[EncString, T]>; + /** + * Sets the the user's encrypted private key in storage and + * clears the decrypted private key from memory + * Note: does not clear the private key if null is provided + * @param encPrivateKey An encrypted private key + */ + setPrivateKey: (encPrivateKey: string) => Promise; + /** + * Returns the private key from memory. If not available, decrypts it + * from storage and stores it in memory + * @returns The user's private key + */ + getPrivateKey: () => Promise; + /** + * Generates a fingerprint phrase for the user based on their public key + * @param fingerprintMaterial Fingerprint material + * @param publicKey The user's public key + * @returns The user's fingerprint phrase + */ + getFingerprint: (fingerprintMaterial: string, publicKey?: Uint8Array) => Promise; + /** + * Generates a new keypair + * @param key A key to encrypt the private key with. If not provided, + * defaults to the user key + * @returns A new keypair: [publicKey in Base64, encrypted privateKey] + */ + makeKeyPair: (key?: SymmetricCryptoKey) => Promise<[string, EncString]>; + /** + * Clears the user's key pair + * @param memoryOnly Clear only the in-memory keys + * @param userId The desired user + */ + clearKeyPair: (memoryOnly?: boolean, userId?: string) => Promise; + /** + * @param pin The user's pin + * @param salt The user's salt + * @param kdf The user's kdf + * @param kdfConfig The user's kdf config + * @returns A key derived from the user's pin + */ + makePinKey: (pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig) => Promise; + /** + * Clears the user's pin keys from storage + * Note: This will remove the stored pin and as a result, + * disable pin protection for the user + * @param userId The desired user + */ + clearPinKeys: (userId?: string) => Promise; + /** + * Decrypts the user key with their pin + * @param pin The user's PIN + * @param salt The user's salt + * @param kdf The user's KDF + * @param kdfConfig The user's KDF config + * @param pinProtectedUserKey The user's PIN protected symmetric key, if not provided + * it will be retrieved from storage + * @returns The decrypted user key + */ + decryptUserKeyWithPin: ( + pin: string, + salt: string, + kdf: KdfType, + kdfConfig: KdfConfig, + protectedKeyCs?: EncString + ) => Promise; + /** + * Creates a new Pin key that encrypts the user key instead of the + * master key. Clears the old Pin key from state. + * @param masterPasswordOnRestart True if Master Password on Restart is enabled + * @param pin User's PIN + * @param email User's email + * @param kdf User's KdfType + * @param kdfConfig User's KdfConfig + * @param oldPinKey The old Pin key from state (retrieved from different + * places depending on if Master Password on Restart was enabled) + * @returns The user key + */ + decryptAndMigrateOldPinKey: ( + masterPasswordOnRestart: boolean, + pin: string, + email: string, + kdf: KdfType, + kdfConfig: KdfConfig, + oldPinKey: EncString + ) => Promise; + /** + * Replaces old master auto keys with new user auto keys + */ + migrateAutoKeyIfNeeded: (userId?: string) => Promise; + /** + * @param keyMaterial The key material to derive the send key from + * @returns A new send key + */ + makeSendKey: (keyMaterial: Uint8Array) => Promise; + /** + * Clears all of the user's keys from storage + * @param userId The user's Id + */ + clearKeys: (userId?: string) => Promise; + /** + * RSA encrypts a value. + * @param data The data to encrypt + * @param publicKey The public key to use for encryption, if not provided, the user's public key will be used + * @returns The encrypted data + */ + rsaEncrypt: (data: Uint8Array, publicKey?: Uint8Array) => Promise; + /** + * Decrypts a value using RSA. + * @param encValue The encrypted value to decrypt + * @param privateKeyValue The private key to use for decryption + * @returns The decrypted value + */ + rsaDecrypt: (encValue: string, privateKeyValue?: Uint8Array) => Promise; + randomNumber: (min: number, max: number) => Promise; + /** + * Generates a new cipher key + * @returns A new cipher key + */ + makeCipherKey: () => Promise; + + /** + * Initialize all necessary crypto keys needed for a new account. + * Warning! This completely replaces any existing keys! + * @returns The user's newly created public key, private key, and encrypted private key + */ + initAccount: () => Promise<{ + userKey: UserKey; + publicKey: string; + privateKey: EncString; + }>; + + /** + * @deprecated Left for migration purposes. Use decryptUserKeyWithPin instead. + */ + decryptMasterKeyWithPin: ( + pin: string, + salt: string, + kdf: KdfType, + kdfConfig: KdfConfig, + protectedKeyCs?: EncString + ) => Promise; + /** + * Previously, the master key was used for any additional key like the biometrics or pin key. + * We have switched to using the user key for these purposes. This method is for clearing the state + * of the older keys on logout or post migration. + * @param keySuffix The desired type of key to clear + * @param userId The desired user + */ + clearDeprecatedKeys: (keySuffix: KeySuffixOptions, userId?: string) => Promise; + /** + * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) + * and then call encryptService.encrypt + */ + encrypt: (plainValue: string | Uint8Array, key?: SymmetricCryptoKey) => Promise; + /** + * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) + * and then call encryptService.encryptToBytes + */ + encryptToBytes: (plainValue: Uint8Array, key?: SymmetricCryptoKey) => Promise; + /** + * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) + * and then call encryptService.decryptToBytes + */ + decryptToBytes: (encString: EncString, key?: SymmetricCryptoKey) => Promise; + /** + * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) + * and then call encryptService.decryptToUtf8 + */ + decryptToUtf8: (encString: EncString, key?: SymmetricCryptoKey) => Promise; + /** + * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) + * and then call encryptService.decryptToBytes + */ + decryptFromBytes: (encBuffer: EncArrayBuffer, key: SymmetricCryptoKey) => Promise; +} diff --git a/libs/common/src/abstractions/encrypt.service.ts b/libs/common/src/platform/abstractions/encrypt.service.ts similarity index 70% rename from libs/common/src/abstractions/encrypt.service.ts rename to libs/common/src/platform/abstractions/encrypt.service.ts index 17e72907c8a..588a8bb0438 100644 --- a/libs/common/src/abstractions/encrypt.service.ts +++ b/libs/common/src/platform/abstractions/encrypt.service.ts @@ -1,19 +1,19 @@ -import { IEncrypted } from "../interfaces/IEncrypted"; import { Decryptable } from "../interfaces/decryptable.interface"; +import { Encrypted } from "../interfaces/encrypted"; import { InitializerMetadata } from "../interfaces/initializer-metadata.interface"; import { EncArrayBuffer } from "../models/domain/enc-array-buffer"; import { EncString } from "../models/domain/enc-string"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; export abstract class EncryptService { - abstract encrypt(plainValue: string | ArrayBuffer, key: SymmetricCryptoKey): Promise; + abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise; abstract encryptToBytes: ( - plainValue: ArrayBuffer, + plainValue: Uint8Array, key?: SymmetricCryptoKey ) => Promise; abstract decryptToUtf8: (encString: EncString, key: SymmetricCryptoKey) => Promise; - abstract decryptToBytes: (encThing: IEncrypted, key: SymmetricCryptoKey) => Promise; - abstract resolveLegacyKey: (key: SymmetricCryptoKey, encThing: IEncrypted) => SymmetricCryptoKey; + abstract decryptToBytes: (encThing: Encrypted, key: SymmetricCryptoKey) => Promise; + abstract resolveLegacyKey: (key: SymmetricCryptoKey, encThing: Encrypted) => SymmetricCryptoKey; abstract decryptItems: ( items: Decryptable[], key: SymmetricCryptoKey diff --git a/libs/common/src/abstractions/environment.service.ts b/libs/common/src/platform/abstractions/environment.service.ts similarity index 50% rename from libs/common/src/abstractions/environment.service.ts rename to libs/common/src/platform/abstractions/environment.service.ts index 27e4125a29d..37a3169d2f5 100644 --- a/libs/common/src/abstractions/environment.service.ts +++ b/libs/common/src/platform/abstractions/environment.service.ts @@ -17,12 +17,41 @@ export type PayPalConfig = { buttonAction?: string; }; +export enum Region { + US = "US", + EU = "EU", + SelfHosted = "Self-hosted", +} + +export enum RegionDomain { + US = "bitwarden.com", + EU = "bitwarden.eu", + USQA = "bitwarden.pw", +} + export abstract class EnvironmentService { - urls: Observable; + urls: Observable; + usUrls: Urls; + euUrls: Urls; + selectedRegion?: Region; + initialized = true; hasBaseUrl: () => boolean; getNotificationsUrl: () => string; getWebVaultUrl: () => string; + /** + * Retrieves the URL of the cloud web vault app. + * + * @returns {string} The URL of the cloud web vault app. + * @remarks Use this method only in views exclusive to self-host instances. + */ + getCloudWebVaultUrl: () => string; + /** + * Sets the URL of the cloud web vault app based on the region parameter. + * + * @param {Region} region - The region of the cloud web vault app. + */ + setCloudWebVaultUrl: (region: Region) => void; getSendUrl: () => string; getIconsUrl: () => string; getApiUrl: () => string; @@ -32,11 +61,8 @@ export abstract class EnvironmentService { getScimUrl: () => string; setUrlsFromStorage: () => Promise; setUrls: (urls: Urls) => Promise; + setRegion: (region: Region) => Promise; getUrls: () => Urls; isCloud: () => boolean; - /** - * @remarks For desktop and browser use only. - * For web, use PlatformUtilsService.isSelfHost() - */ - isSelfHosted: () => boolean; + isEmpty: () => boolean; } diff --git a/libs/common/src/abstractions/fileDownload/fileDownloadBuilder.ts b/libs/common/src/platform/abstractions/file-download/file-download.builder.ts similarity index 96% rename from libs/common/src/abstractions/fileDownload/fileDownloadBuilder.ts rename to libs/common/src/platform/abstractions/file-download/file-download.builder.ts index 29e54a6e28a..8db379fec73 100644 --- a/libs/common/src/abstractions/fileDownload/fileDownloadBuilder.ts +++ b/libs/common/src/platform/abstractions/file-download/file-download.builder.ts @@ -1,4 +1,4 @@ -import { FileDownloadRequest } from "./fileDownloadRequest"; +import { FileDownloadRequest } from "./file-download.request"; export class FileDownloadBuilder { get blobOptions(): any { diff --git a/libs/common/src/abstractions/fileDownload/fileDownloadRequest.ts b/libs/common/src/platform/abstractions/file-download/file-download.request.ts similarity index 100% rename from libs/common/src/abstractions/fileDownload/fileDownloadRequest.ts rename to libs/common/src/platform/abstractions/file-download/file-download.request.ts diff --git a/libs/common/src/abstractions/fileDownload/fileDownload.service.ts b/libs/common/src/platform/abstractions/file-download/file-download.service.ts similarity index 61% rename from libs/common/src/abstractions/fileDownload/fileDownload.service.ts rename to libs/common/src/platform/abstractions/file-download/file-download.service.ts index c3829225897..44d082d72bf 100644 --- a/libs/common/src/abstractions/fileDownload/fileDownload.service.ts +++ b/libs/common/src/platform/abstractions/file-download/file-download.service.ts @@ -1,4 +1,4 @@ -import { FileDownloadRequest } from "./fileDownloadRequest"; +import { FileDownloadRequest } from "./file-download.request"; export abstract class FileDownloadService { download: (request: FileDownloadRequest) => void; diff --git a/libs/common/src/abstractions/file-upload/file-upload.service.ts b/libs/common/src/platform/abstractions/file-upload/file-upload.service.ts similarity index 91% rename from libs/common/src/abstractions/file-upload/file-upload.service.ts rename to libs/common/src/platform/abstractions/file-upload/file-upload.service.ts index eff5a018a29..29fde216453 100644 --- a/libs/common/src/abstractions/file-upload/file-upload.service.ts +++ b/libs/common/src/platform/abstractions/file-upload/file-upload.service.ts @@ -1,4 +1,4 @@ -import { FileUploadType } from "../../enums"; +import { FileUploadType } from "../../../enums"; import { EncArrayBuffer } from "../../models/domain/enc-array-buffer"; import { EncString } from "../../models/domain/enc-string"; diff --git a/libs/common/src/abstractions/i18n.service.ts b/libs/common/src/platform/abstractions/i18n.service.ts similarity index 100% rename from libs/common/src/abstractions/i18n.service.ts rename to libs/common/src/platform/abstractions/i18n.service.ts diff --git a/libs/common/src/abstractions/log.service.ts b/libs/common/src/platform/abstractions/log.service.ts similarity index 84% rename from libs/common/src/abstractions/log.service.ts rename to libs/common/src/platform/abstractions/log.service.ts index ea8520a5206..e8258060170 100644 --- a/libs/common/src/abstractions/log.service.ts +++ b/libs/common/src/platform/abstractions/log.service.ts @@ -1,4 +1,4 @@ -import { LogLevelType } from "../enums"; +import { LogLevelType } from "../../enums"; export abstract class LogService { debug: (message: string) => void; diff --git a/libs/common/src/abstractions/messaging.service.ts b/libs/common/src/platform/abstractions/messaging.service.ts similarity index 100% rename from libs/common/src/abstractions/messaging.service.ts rename to libs/common/src/platform/abstractions/messaging.service.ts diff --git a/libs/common/src/abstractions/platformUtils.service.ts b/libs/common/src/platform/abstractions/platform-utils.service.ts similarity index 77% rename from libs/common/src/abstractions/platformUtils.service.ts rename to libs/common/src/platform/abstractions/platform-utils.service.ts index fd21d854335..55118728a7d 100644 --- a/libs/common/src/abstractions/platformUtils.service.ts +++ b/libs/common/src/platform/abstractions/platform-utils.service.ts @@ -1,9 +1,16 @@ -import { ClientType, DeviceType } from "../enums"; +import { ClientType, DeviceType } from "../../enums"; interface ToastOptions { timeout?: number; } +export type ClipboardOptions = { + allowHistory?: boolean; + clearing?: boolean; + clearMs?: number; + window?: Window; +}; + export abstract class PlatformUtilsService { getDevice: () => DeviceType; getDeviceString: () => string; @@ -29,8 +36,8 @@ export abstract class PlatformUtilsService { ) => void; isDev: () => boolean; isSelfHost: () => boolean; - copyToClipboard: (text: string, options?: any) => void | boolean; - readFromClipboard: (options?: any) => Promise; + copyToClipboard: (text: string, options?: ClipboardOptions) => void | boolean; + readFromClipboard: () => Promise; supportsBiometric: () => Promise; authenticateBiometric: () => Promise; supportsSecureStorage: () => boolean; diff --git a/libs/common/src/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts similarity index 73% rename from libs/common/src/abstractions/state.service.ts rename to libs/common/src/platform/abstractions/state.service.ts index 04cfb609fed..571dad6478e 100644 --- a/libs/common/src/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -1,32 +1,44 @@ import { Observable } from "rxjs"; -import { CollectionData } from "../admin-console/models/data/collection.data"; -import { EncryptedOrganizationKeyData } from "../admin-console/models/data/encrypted-organization-key.data"; -import { OrganizationData } from "../admin-console/models/data/organization.data"; -import { PolicyData } from "../admin-console/models/data/policy.data"; -import { ProviderData } from "../admin-console/models/data/provider.data"; -import { Policy } from "../admin-console/models/domain/policy"; -import { CollectionView } from "../admin-console/models/view/collection.view"; -import { EnvironmentUrls } from "../auth/models/domain/environment-urls"; -import { ForceResetPasswordReason } from "../auth/models/domain/force-reset-password-reason"; -import { KdfConfig } from "../auth/models/domain/kdf-config"; -import { BiometricKey } from "../auth/types/biometric-key"; -import { KdfType, ThemeType, UriMatchType } from "../enums"; -import { EventData } from "../models/data/event.data"; +import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data"; +import { OrganizationData } from "../../admin-console/models/data/organization.data"; +import { PolicyData } from "../../admin-console/models/data/policy.data"; +import { ProviderData } from "../../admin-console/models/data/provider.data"; +import { Policy } from "../../admin-console/models/domain/policy"; +import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; +import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; +import { ForceResetPasswordReason } from "../../auth/models/domain/force-reset-password-reason"; +import { KdfConfig } from "../../auth/models/domain/kdf-config"; +import { BiometricKey } from "../../auth/types/biometric-key"; +import { KdfType, ThemeType, UriMatchType } from "../../enums"; +import { EventData } from "../../models/data/event.data"; +import { WindowState } from "../../models/domain/window-state"; +import { GeneratorOptions } from "../../tools/generator/generator-options"; +import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; +import { UsernameGeneratorOptions } from "../../tools/generator/username"; +import { SendData } from "../../tools/send/models/data/send.data"; +import { SendView } from "../../tools/send/models/view/send.view"; +import { CipherData } from "../../vault/models/data/cipher.data"; +import { CollectionData } from "../../vault/models/data/collection.data"; +import { FolderData } from "../../vault/models/data/folder.data"; +import { LocalData } from "../../vault/models/data/local.data"; +import { CipherView } from "../../vault/models/view/cipher.view"; +import { CollectionView } from "../../vault/models/view/collection.view"; +import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info"; import { ServerConfigData } from "../models/data/server-config.data"; -import { Account, AccountSettingsSettings } from "../models/domain/account"; +import { + Account, + AccountDecryptionOptions, + AccountSettingsSettings, +} from "../models/domain/account"; import { EncString } from "../models/domain/enc-string"; import { StorageOptions } from "../models/domain/storage-options"; -import { DeviceKey, SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; -import { WindowState } from "../models/domain/window-state"; -import { GeneratedPasswordHistory } from "../tools/generator/password"; -import { SendData } from "../tools/send/models/data/send.data"; -import { SendView } from "../tools/send/models/view/send.view"; -import { CipherData } from "../vault/models/data/cipher.data"; -import { FolderData } from "../vault/models/data/folder.data"; -import { LocalData } from "../vault/models/data/local.data"; -import { CipherView } from "../vault/models/view/cipher.view"; -import { AddEditCipherInfo } from "../vault/types/add-edit-cipher-info"; +import { + DeviceKey, + MasterKey, + SymmetricCryptoKey, + UserKey, +} from "../models/domain/symmetric-crypto-key"; export abstract class StateService { accounts$: Observable<{ [userId: string]: T }>; @@ -71,24 +83,106 @@ export abstract class StateService { setCollapsedGroupings: (value: string[], options?: StorageOptions) => Promise; getConvertAccountToKeyConnector: (options?: StorageOptions) => Promise; setConvertAccountToKeyConnector: (value: boolean, options?: StorageOptions) => Promise; + /** + * gets the user key + */ + getUserKey: (options?: StorageOptions) => Promise; + /** + * Sets the user key + */ + setUserKey: (value: UserKey, options?: StorageOptions) => Promise; + /** + * Gets the user's master key + */ + getMasterKey: (options?: StorageOptions) => Promise; + /** + * Sets the user's master key + */ + setMasterKey: (value: MasterKey, options?: StorageOptions) => Promise; + /** + * Gets the user key encrypted by the master key + */ + getMasterKeyEncryptedUserKey: (options?: StorageOptions) => Promise; + /** + * Sets the user key encrypted by the master key + */ + setMasterKeyEncryptedUserKey: (value: string, options?: StorageOptions) => Promise; + /** + * Gets the user's auto key + */ + getUserKeyAutoUnlock: (options?: StorageOptions) => Promise; + /** + * Sets the user's auto key + */ + setUserKeyAutoUnlock: (value: string, options?: StorageOptions) => Promise; + /** + * Gets the user's biometric key + */ + getUserKeyBiometric: (options?: StorageOptions) => Promise; + /** + * Checks if the user has a biometric key available + */ + hasUserKeyBiometric: (options?: StorageOptions) => Promise; + /** + * Sets the user's biometric key + */ + setUserKeyBiometric: (value: BiometricKey, options?: StorageOptions) => Promise; + /** + * Gets the user key encrypted by the Pin key. + * Used when Lock with MP on Restart is disabled + */ + getPinKeyEncryptedUserKey: (options?: StorageOptions) => Promise; + /** + * Sets the user key encrypted by the Pin key. + * Used when Lock with MP on Restart is disabled + */ + setPinKeyEncryptedUserKey: (value: EncString, options?: StorageOptions) => Promise; + /** + * Gets the ephemeral version of the user key encrypted by the Pin key. + * Used when Lock with MP on Restart is enabled + */ + getPinKeyEncryptedUserKeyEphemeral: (options?: StorageOptions) => Promise; + /** + * Sets the ephemeral version of the user key encrypted by the Pin key. + * Used when Lock with MP on Restart is enabled + */ + setPinKeyEncryptedUserKeyEphemeral: (value: EncString, options?: StorageOptions) => Promise; + /** + * @deprecated For migration purposes only, use getUserKeyMasterKey instead + */ + getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise; + /** + * @deprecated For migration purposes only, use setUserKeyMasterKey instead + */ + setEncryptedCryptoSymmetricKey: (value: string, options?: StorageOptions) => Promise; + /** + * @deprecated For legacy purposes only, use getMasterKey instead + */ getCryptoMasterKey: (options?: StorageOptions) => Promise; - setCryptoMasterKey: (value: SymmetricCryptoKey, options?: StorageOptions) => Promise; + /** + * @deprecated For migration purposes only, use getUserKeyAuto instead + */ getCryptoMasterKeyAuto: (options?: StorageOptions) => Promise; + /** + * @deprecated For migration purposes only, use setUserKeyAuto instead + */ setCryptoMasterKeyAuto: (value: string, options?: StorageOptions) => Promise; - getCryptoMasterKeyB64: (options?: StorageOptions) => Promise; - setCryptoMasterKeyB64: (value: string, options?: StorageOptions) => Promise; + /** + * @deprecated For migration purposes only, use getUserKeyBiometric instead + */ getCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise; + /** + * @deprecated For migration purposes only, use hasUserKeyBiometric instead + */ hasCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise; + /** + * @deprecated For migration purposes only, use setUserKeyBiometric instead + */ setCryptoMasterKeyBiometric: (value: BiometricKey, options?: StorageOptions) => Promise; getDecryptedCiphers: (options?: StorageOptions) => Promise; setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise; getDecryptedCollections: (options?: StorageOptions) => Promise; setDecryptedCollections: (value: CollectionView[], options?: StorageOptions) => Promise; - getDecryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise; - setDecryptedCryptoSymmetricKey: ( - value: SymmetricCryptoKey, - options?: StorageOptions - ) => Promise; getDecryptedOrganizationKeys: ( options?: StorageOptions ) => Promise>; @@ -103,7 +197,13 @@ export abstract class StateService { value: GeneratedPasswordHistory[], options?: StorageOptions ) => Promise; + /** + * @deprecated For migration purposes only, use getDecryptedUserKeyPin instead + */ getDecryptedPinProtected: (options?: StorageOptions) => Promise; + /** + * @deprecated For migration purposes only, use setDecryptedUserKeyPin instead + */ setDecryptedPinProtected: (value: EncString, options?: StorageOptions) => Promise; /** * @deprecated Do not call this, use PolicyService @@ -113,8 +213,8 @@ export abstract class StateService { * @deprecated Do not call this, use PolicyService */ setDecryptedPolicies: (value: Policy[], options?: StorageOptions) => Promise; - getDecryptedPrivateKey: (options?: StorageOptions) => Promise; - setDecryptedPrivateKey: (value: ArrayBuffer, options?: StorageOptions) => Promise; + getDecryptedPrivateKey: (options?: StorageOptions) => Promise; + setDecryptedPrivateKey: (value: Uint8Array, options?: StorageOptions) => Promise; getDecryptedProviderKeys: (options?: StorageOptions) => Promise>; setDecryptedProviderKeys: ( value: Map, @@ -164,7 +264,21 @@ export abstract class StateService { getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise; setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise; getDeviceKey: (options?: StorageOptions) => Promise; - setDeviceKey: (value: DeviceKey, options?: StorageOptions) => Promise; + setDeviceKey: (value: DeviceKey | null, options?: StorageOptions) => Promise; + getAdminAuthRequest: (options?: StorageOptions) => Promise; + setAdminAuthRequest: ( + adminAuthRequest: AdminAuthRequestStorable, + options?: StorageOptions + ) => Promise; + getShouldTrustDevice: (options?: StorageOptions) => Promise; + setShouldTrustDevice: (value: boolean, options?: StorageOptions) => Promise; + getAccountDecryptionOptions: ( + options?: StorageOptions + ) => Promise; + setAccountDecryptionOptions: ( + value: AccountDecryptionOptions, + options?: StorageOptions + ) => Promise; getEmail: (options?: StorageOptions) => Promise; setEmail: (value: string, options?: StorageOptions) => Promise; getEmailVerified: (options?: StorageOptions) => Promise; @@ -205,8 +319,6 @@ export abstract class StateService { value: { [id: string]: CollectionData }, options?: StorageOptions ) => Promise; - getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise; - setEncryptedCryptoSymmetricKey: (value: string, options?: StorageOptions) => Promise; /** * @deprecated Do not call this directly, use FolderService */ @@ -232,7 +344,13 @@ export abstract class StateService { value: GeneratedPasswordHistory[], options?: StorageOptions ) => Promise; + /** + * @deprecated For migration purposes only, use getEncryptedUserKeyPin instead + */ getEncryptedPinProtected: (options?: StorageOptions) => Promise; + /** + * @deprecated For migration purposes only, use setEncryptedUserKeyPin instead + */ setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise; /** * @deprecated Do not call this directly, use PolicyService @@ -263,10 +381,14 @@ export abstract class StateService { setEntityType: (value: string, options?: StorageOptions) => Promise; getEnvironmentUrls: (options?: StorageOptions) => Promise; setEnvironmentUrls: (value: EnvironmentUrls, options?: StorageOptions) => Promise; + getRegion: (options?: StorageOptions) => Promise; + setRegion: (value: string, options?: StorageOptions) => Promise; getEquivalentDomains: (options?: StorageOptions) => Promise; setEquivalentDomains: (value: string, options?: StorageOptions) => Promise; getEventCollection: (options?: StorageOptions) => Promise; setEventCollection: (value: EventData[], options?: StorageOptions) => Promise; + getEverHadUserKey: (options?: StorageOptions) => Promise; + setEverHadUserKey: (value: boolean, options?: StorageOptions) => Promise; getEverBeenUnlocked: (options?: StorageOptions) => Promise; setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise; getForcePasswordResetReason: (options?: StorageOptions) => Promise; @@ -319,18 +441,30 @@ export abstract class StateService { value: { [id: string]: OrganizationData }, options?: StorageOptions ) => Promise; - getPasswordGenerationOptions: (options?: StorageOptions) => Promise; - setPasswordGenerationOptions: (value: any, options?: StorageOptions) => Promise; - getUsernameGenerationOptions: (options?: StorageOptions) => Promise; - setUsernameGenerationOptions: (value: any, options?: StorageOptions) => Promise; - getGeneratorOptions: (options?: StorageOptions) => Promise; - setGeneratorOptions: (value: any, options?: StorageOptions) => Promise; + getPasswordGenerationOptions: (options?: StorageOptions) => Promise; + setPasswordGenerationOptions: ( + value: PasswordGeneratorOptions, + options?: StorageOptions + ) => Promise; + getUsernameGenerationOptions: (options?: StorageOptions) => Promise; + setUsernameGenerationOptions: ( + value: UsernameGeneratorOptions, + options?: StorageOptions + ) => Promise; + getGeneratorOptions: (options?: StorageOptions) => Promise; + setGeneratorOptions: (value: GeneratorOptions, options?: StorageOptions) => Promise; + /** + * Gets the user's Pin, encrypted by the user key + */ getProtectedPin: (options?: StorageOptions) => Promise; + /** + * Sets the user's Pin, encrypted by the user key + */ setProtectedPin: (value: string, options?: StorageOptions) => Promise; getProviders: (options?: StorageOptions) => Promise<{ [id: string]: ProviderData }>; setProviders: (value: { [id: string]: ProviderData }, options?: StorageOptions) => Promise; - getPublicKey: (options?: StorageOptions) => Promise; - setPublicKey: (value: ArrayBuffer, options?: StorageOptions) => Promise; + getPublicKey: (options?: StorageOptions) => Promise; + setPublicKey: (value: Uint8Array, options?: StorageOptions) => Promise; getRefreshToken: (options?: StorageOptions) => Promise; setRefreshToken: (value: string, options?: StorageOptions) => Promise; getRememberedEmail: (options?: StorageOptions) => Promise; @@ -351,6 +485,11 @@ export abstract class StateService { setSsoOrganizationIdentifier: (value: string, options?: StorageOptions) => Promise; getSsoState: (options?: StorageOptions) => Promise; setSsoState: (value: string, options?: StorageOptions) => Promise; + getUserSsoOrganizationIdentifier: (options?: StorageOptions) => Promise; + setUserSsoOrganizationIdentifier: ( + value: string | null, + options?: StorageOptions + ) => Promise; getTheme: (options?: StorageOptions) => Promise; setTheme: (value: ThemeType, options?: StorageOptions) => Promise; getTwoFactorToken: (options?: StorageOptions) => Promise; @@ -364,8 +503,6 @@ export abstract class StateService { setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise; getApproveLoginRequests: (options?: StorageOptions) => Promise; setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise; - getStateVersion: () => Promise; - setStateVersion: (value: number) => Promise; getWindow: () => Promise; setWindow: (value: WindowState) => Promise; /** diff --git a/libs/common/src/abstractions/storage.service.ts b/libs/common/src/platform/abstractions/storage.service.ts similarity index 100% rename from libs/common/src/abstractions/storage.service.ts rename to libs/common/src/platform/abstractions/storage.service.ts diff --git a/libs/common/src/abstractions/system.service.ts b/libs/common/src/platform/abstractions/system.service.ts similarity index 79% rename from libs/common/src/abstractions/system.service.ts rename to libs/common/src/platform/abstractions/system.service.ts index 20e77ed1495..5a7e11f9a12 100644 --- a/libs/common/src/abstractions/system.service.ts +++ b/libs/common/src/platform/abstractions/system.service.ts @@ -1,4 +1,4 @@ -import { AuthService } from "../auth/abstractions/auth.service"; +import { AuthService } from "../../auth/abstractions/auth.service"; export abstract class SystemService { startProcessReload: (authService: AuthService) => Promise; diff --git a/libs/common/src/abstractions/translation.service.ts b/libs/common/src/platform/abstractions/translation.service.ts similarity index 100% rename from libs/common/src/abstractions/translation.service.ts rename to libs/common/src/platform/abstractions/translation.service.ts diff --git a/libs/common/src/abstractions/validation.service.ts b/libs/common/src/platform/abstractions/validation.service.ts similarity index 100% rename from libs/common/src/abstractions/validation.service.ts rename to libs/common/src/platform/abstractions/validation.service.ts diff --git a/libs/common/src/factories/accountFactory.ts b/libs/common/src/platform/factories/account-factory.ts similarity index 100% rename from libs/common/src/factories/accountFactory.ts rename to libs/common/src/platform/factories/account-factory.ts diff --git a/libs/common/src/factories/globalStateFactory.ts b/libs/common/src/platform/factories/global-state-factory.ts similarity index 100% rename from libs/common/src/factories/globalStateFactory.ts rename to libs/common/src/platform/factories/global-state-factory.ts diff --git a/libs/common/src/factories/stateFactory.ts b/libs/common/src/platform/factories/state-factory.ts similarity index 88% rename from libs/common/src/factories/stateFactory.ts rename to libs/common/src/platform/factories/state-factory.ts index ea53d7765c9..5a11b90e18a 100644 --- a/libs/common/src/factories/stateFactory.ts +++ b/libs/common/src/platform/factories/state-factory.ts @@ -1,8 +1,8 @@ import { Account } from "../models/domain/account"; import { GlobalState } from "../models/domain/global-state"; -import { AccountFactory } from "./accountFactory"; -import { GlobalStateFactory } from "./globalStateFactory"; +import { AccountFactory } from "./account-factory"; +import { GlobalStateFactory } from "./global-state-factory"; export class StateFactory< TGlobal extends GlobalState = GlobalState, diff --git a/libs/common/src/interfaces/decryptable.interface.ts b/libs/common/src/platform/interfaces/decryptable.interface.ts similarity index 88% rename from libs/common/src/interfaces/decryptable.interface.ts rename to libs/common/src/platform/interfaces/decryptable.interface.ts index ae5e8ebbf82..35895bfd6ff 100644 --- a/libs/common/src/interfaces/decryptable.interface.ts +++ b/libs/common/src/platform/interfaces/decryptable.interface.ts @@ -8,5 +8,5 @@ import { InitializerMetadata } from "./initializer-metadata.interface"; * @example Cipher implements Decryptable */ export interface Decryptable extends InitializerMetadata { - decrypt: (key?: SymmetricCryptoKey) => Promise; + decrypt: (key: SymmetricCryptoKey) => Promise; } diff --git a/libs/common/src/platform/interfaces/encrypted.ts b/libs/common/src/platform/interfaces/encrypted.ts new file mode 100644 index 00000000000..26a49a32ec5 --- /dev/null +++ b/libs/common/src/platform/interfaces/encrypted.ts @@ -0,0 +1,8 @@ +import { EncryptionType } from "../../enums"; + +export interface Encrypted { + encryptionType?: EncryptionType; + dataBytes: Uint8Array; + macBytes: Uint8Array; + ivBytes: Uint8Array; +} diff --git a/libs/common/src/interfaces/initializer-metadata.interface.ts b/libs/common/src/platform/interfaces/initializer-metadata.interface.ts similarity index 100% rename from libs/common/src/interfaces/initializer-metadata.interface.ts rename to libs/common/src/platform/interfaces/initializer-metadata.interface.ts diff --git a/libs/common/src/misc/flags.spec.ts b/libs/common/src/platform/misc/flags.spec.ts similarity index 100% rename from libs/common/src/misc/flags.spec.ts rename to libs/common/src/platform/misc/flags.spec.ts diff --git a/libs/common/src/misc/flags.ts b/libs/common/src/platform/misc/flags.ts similarity index 98% rename from libs/common/src/misc/flags.ts rename to libs/common/src/platform/misc/flags.ts index c1bdafaa0f3..53609505675 100644 --- a/libs/common/src/misc/flags.ts +++ b/libs/common/src/platform/misc/flags.ts @@ -3,6 +3,7 @@ export type SharedFlags = { multithreadDecryption: boolean; showPasswordless?: boolean; + enableCipherKeyEncryption?: boolean; }; // required to avoid linting errors when there are no flags diff --git a/libs/common/src/misc/sequentialize.spec.ts b/libs/common/src/platform/misc/sequentialize.spec.ts similarity index 100% rename from libs/common/src/misc/sequentialize.spec.ts rename to libs/common/src/platform/misc/sequentialize.spec.ts diff --git a/libs/common/src/misc/sequentialize.ts b/libs/common/src/platform/misc/sequentialize.ts similarity index 100% rename from libs/common/src/misc/sequentialize.ts rename to libs/common/src/platform/misc/sequentialize.ts diff --git a/libs/common/src/misc/throttle.spec.ts b/libs/common/src/platform/misc/throttle.spec.ts similarity index 100% rename from libs/common/src/misc/throttle.spec.ts rename to libs/common/src/platform/misc/throttle.spec.ts diff --git a/libs/common/src/misc/throttle.ts b/libs/common/src/platform/misc/throttle.ts similarity index 100% rename from libs/common/src/misc/throttle.ts rename to libs/common/src/platform/misc/throttle.ts diff --git a/libs/common/src/misc/utils.spec.ts b/libs/common/src/platform/misc/utils.spec.ts similarity index 94% rename from libs/common/src/misc/utils.spec.ts rename to libs/common/src/platform/misc/utils.spec.ts index 5f7d63fee22..0fd76a80921 100644 --- a/libs/common/src/misc/utils.spec.ts +++ b/libs/common/src/platform/misc/utils.spec.ts @@ -358,4 +358,32 @@ describe("Utils Service", () => { expect(actual.protocol).toBe("http:"); }); }); + + describe("daysRemaining", () => { + beforeAll(() => { + const now = new Date(2023, 9, 2, 10); + jest.spyOn(Date, "now").mockReturnValue(now.getTime()); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("should return 0 for equal dates", () => { + expect(Utils.daysRemaining(new Date(2023, 9, 2))).toBe(0); + expect(Utils.daysRemaining(new Date(2023, 9, 2, 12))).toBe(0); + }); + + it("should return 0 for dates in the past", () => { + expect(Utils.daysRemaining(new Date(2020, 5, 11))).toBe(0); + expect(Utils.daysRemaining(new Date(2023, 9, 1))).toBe(0); + }); + + it("should handle future dates", () => { + expect(Utils.daysRemaining(new Date(2023, 9, 3, 10))).toBe(1); + expect(Utils.daysRemaining(new Date(2023, 10, 12, 10))).toBe(41); + // leap year + expect(Utils.daysRemaining(new Date(2024, 9, 2, 10))).toBe(366); + }); + }); }); diff --git a/libs/common/src/misc/utils.ts b/libs/common/src/platform/misc/utils.ts similarity index 98% rename from libs/common/src/misc/utils.ts rename to libs/common/src/platform/misc/utils.ts index 4c30822cc8a..6711e78c3b7 100644 --- a/libs/common/src/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -32,6 +32,7 @@ export class Utils { static regexpEmojiPresentation = /(?:[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u270A\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF93\uDFA0-\uDFCA\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF4\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC3E\uDC40\uDC42-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDD7A\uDD95\uDD96\uDDA4\uDDFB-\uDE4F\uDE80-\uDEC5\uDECC\uDED0-\uDED2\uDED5-\uDED7\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDD78\uDD7A-\uDDCB\uDDCD-\uDDFF\uDE70-\uDE74\uDE78-\uDE7A\uDE80-\uDE86\uDE90-\uDEA8\uDEB0-\uDEB6\uDEC0-\uDEC2\uDED0-\uDED6])/g; static readonly validHosts: string[] = ["localhost"]; + static readonly originalMinimumPasswordLength = 8; static readonly minimumPasswordLength = 12; static readonly DomainMatchBlacklist = new Map>([ ["google.com", new Set(["script.google.com"])], @@ -538,6 +539,16 @@ export class Utils { return of(undefined).pipe(switchMap(() => generator())); } + /** + * Return the number of days remaining before a target date arrives. + * Returns 0 if the day has already passed. + */ + static daysRemaining(targetDate: Date): number { + const diffTime = targetDate.getTime() - Date.now(); + const msPerDay = 86400000; + return Math.max(0, Math.floor(diffTime / msPerDay)); + } + private static isAppleMobile(win: Window) { return ( win.navigator.userAgent.match(/iPhone/i) != null || diff --git a/libs/common/src/misc/wordlist.ts b/libs/common/src/platform/misc/wordlist.ts similarity index 100% rename from libs/common/src/misc/wordlist.ts rename to libs/common/src/platform/misc/wordlist.ts diff --git a/libs/common/src/models/data/server-config.data.spec.ts b/libs/common/src/platform/models/data/server-config.data.spec.ts similarity index 93% rename from libs/common/src/models/data/server-config.data.spec.ts rename to libs/common/src/platform/models/data/server-config.data.spec.ts index 30dc46cf1bc..b94092662a6 100644 --- a/libs/common/src/models/data/server-config.data.spec.ts +++ b/libs/common/src/platform/models/data/server-config.data.spec.ts @@ -1,3 +1,5 @@ +import { Region } from "../../abstractions/environment.service"; + import { EnvironmentServerConfigData, ServerConfigData, @@ -15,6 +17,7 @@ describe("ServerConfigData", () => { url: "https://test.com", }, environment: { + cloudRegion: Region.EU, vault: "https://vault.com", api: "https://api.com", identity: "https://identity.com", diff --git a/libs/common/src/models/data/server-config.data.ts b/libs/common/src/platform/models/data/server-config.data.ts similarity index 94% rename from libs/common/src/models/data/server-config.data.ts rename to libs/common/src/platform/models/data/server-config.data.ts index 574fa8c63a3..a4819f75678 100644 --- a/libs/common/src/models/data/server-config.data.ts +++ b/libs/common/src/platform/models/data/server-config.data.ts @@ -1,5 +1,6 @@ import { Jsonify } from "type-fest"; +import { Region } from "../../abstractions/environment.service"; import { ServerConfigResponse, ThirdPartyServerConfigResponse, @@ -50,6 +51,7 @@ export class ThirdPartyServerConfigData { } export class EnvironmentServerConfigData { + cloudRegion: Region; vault: string; api: string; identity: string; @@ -57,6 +59,7 @@ export class EnvironmentServerConfigData { sso: string; constructor(response: Partial) { + this.cloudRegion = response.cloudRegion; this.vault = response.vault; this.api = response.api; this.identity = response.identity; diff --git a/libs/common/src/models/domain/account-keys.spec.ts b/libs/common/src/platform/models/domain/account-keys.spec.ts similarity index 55% rename from libs/common/src/models/domain/account-keys.spec.ts rename to libs/common/src/platform/models/domain/account-keys.spec.ts index bacfac25a65..96e30a8c6e1 100644 --- a/libs/common/src/models/domain/account-keys.spec.ts +++ b/libs/common/src/platform/models/domain/account-keys.spec.ts @@ -1,14 +1,15 @@ -import { makeStaticByteArray } from "../../../spec"; +import { makeStaticByteArray } from "../../../../spec"; +import { CsprngArray } from "../../../types/csprng"; import { Utils } from "../../misc/utils"; import { AccountKeys, EncryptionPair } from "./account"; -import { SymmetricCryptoKey } from "./symmetric-crypto-key"; +import { DeviceKey, SymmetricCryptoKey } from "./symmetric-crypto-key"; describe("AccountKeys", () => { describe("toJSON", () => { it("should serialize itself", () => { const keys = new AccountKeys(); - const buffer = makeStaticByteArray(64).buffer; + const buffer = makeStaticByteArray(64); keys.publicKey = buffer; const bufferSpy = jest.spyOn(Utils, "fromBufferToByteString"); @@ -18,10 +19,27 @@ describe("AccountKeys", () => { it("should serialize public key as a string", () => { const keys = new AccountKeys(); - keys.publicKey = Utils.fromByteStringToArray("hello").buffer; + keys.publicKey = Utils.fromByteStringToArray("hello"); const json = JSON.stringify(keys); expect(json).toContain('"publicKey":"hello"'); }); + + // As the accountKeys.toJSON doesn't really serialize the device key + // this method just checks the persistence of the deviceKey + it("should persist deviceKey", () => { + // Arrange + const accountKeys = new AccountKeys(); + const deviceKeyBytesLength = 64; + accountKeys.deviceKey = new SymmetricCryptoKey( + new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray + ) as DeviceKey; + + // Act + const serializedKeys = accountKeys.toJSON(); + + // Assert + expect(serializedKeys.deviceKey).toEqual(accountKeys.deviceKey); + }); }); describe("fromJSON", () => { @@ -29,7 +47,7 @@ describe("AccountKeys", () => { const keys = AccountKeys.fromJSON({ publicKey: "hello", }); - expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello").buffer); + expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello")); }); it("should deserialize cryptoMasterKey", () => { @@ -57,5 +75,24 @@ describe("AccountKeys", () => { } as any); expect(spy).toHaveBeenCalled(); }); + + it("should deserialize deviceKey", () => { + // Arrange + const expectedKeyB64 = + "ZJNnhx9BbJeb2EAq1hlMjqt6GFsg9G/GzoFf6SbPKsaiMhKGDcbHcwcyEg56Lh8lfilpZz4SRM6UA7oFCg+lSg=="; + + const symmetricCryptoKeyFromJsonSpy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); + + // Act + const accountKeys = AccountKeys.fromJSON({ + deviceKey: { + keyB64: expectedKeyB64, + }, + } as any); + + // Assert + expect(symmetricCryptoKeyFromJsonSpy).toHaveBeenCalled(); + expect(accountKeys.deviceKey.keyB64).toEqual(expectedKeyB64); + }); }); }); diff --git a/libs/common/src/models/domain/account-profile.spec.ts b/libs/common/src/platform/models/domain/account-profile.spec.ts similarity index 100% rename from libs/common/src/models/domain/account-profile.spec.ts rename to libs/common/src/platform/models/domain/account-profile.spec.ts diff --git a/libs/common/src/models/domain/account-settings.spec.ts b/libs/common/src/platform/models/domain/account-settings.spec.ts similarity index 100% rename from libs/common/src/models/domain/account-settings.spec.ts rename to libs/common/src/platform/models/domain/account-settings.spec.ts diff --git a/libs/common/src/models/domain/account-tokens.spec.ts b/libs/common/src/platform/models/domain/account-tokens.spec.ts similarity index 100% rename from libs/common/src/models/domain/account-tokens.spec.ts rename to libs/common/src/platform/models/domain/account-tokens.spec.ts diff --git a/libs/common/src/models/domain/account.spec.ts b/libs/common/src/platform/models/domain/account.spec.ts similarity index 100% rename from libs/common/src/models/domain/account.spec.ts rename to libs/common/src/platform/models/domain/account.spec.ts diff --git a/libs/common/src/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts similarity index 50% rename from libs/common/src/models/domain/account.ts rename to libs/common/src/platform/models/domain/account.ts index db98a17b42c..6d85d6501fe 100644 --- a/libs/common/src/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -1,29 +1,38 @@ import { Jsonify } from "type-fest"; -import { CollectionData } from "../../admin-console/models/data/collection.data"; -import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data"; -import { OrganizationData } from "../../admin-console/models/data/organization.data"; -import { PolicyData } from "../../admin-console/models/data/policy.data"; -import { ProviderData } from "../../admin-console/models/data/provider.data"; -import { Policy } from "../../admin-console/models/domain/policy"; -import { CollectionView } from "../../admin-console/models/view/collection.view"; -import { AuthenticationStatus } from "../../auth/enums/authentication-status"; -import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; -import { ForceResetPasswordReason } from "../../auth/models/domain/force-reset-password-reason"; -import { KdfType, UriMatchType } from "../../enums"; +import { EncryptedOrganizationKeyData } from "../../../admin-console/models/data/encrypted-organization-key.data"; +import { OrganizationData } from "../../../admin-console/models/data/organization.data"; +import { PolicyData } from "../../../admin-console/models/data/policy.data"; +import { ProviderData } from "../../../admin-console/models/data/provider.data"; +import { Policy } from "../../../admin-console/models/domain/policy"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable"; +import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls"; +import { ForceResetPasswordReason } from "../../../auth/models/domain/force-reset-password-reason"; +import { KeyConnectorUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/key-connector-user-decryption-option"; +import { TrustedDeviceUserDecryptionOption } from "../../../auth/models/domain/user-decryption-options/trusted-device-user-decryption-option"; +import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; +import { KdfType, UriMatchType } from "../../../enums"; +import { EventData } from "../../../models/data/event.data"; +import { GeneratorOptions } from "../../../tools/generator/generator-options"; +import { + GeneratedPasswordHistory, + PasswordGeneratorOptions, +} from "../../../tools/generator/password"; +import { UsernameGeneratorOptions } from "../../../tools/generator/username/username-generation-options"; +import { SendData } from "../../../tools/send/models/data/send.data"; +import { SendView } from "../../../tools/send/models/view/send.view"; +import { DeepJsonify } from "../../../types/deep-jsonify"; +import { CipherData } from "../../../vault/models/data/cipher.data"; +import { CollectionData } from "../../../vault/models/data/collection.data"; +import { FolderData } from "../../../vault/models/data/folder.data"; +import { CipherView } from "../../../vault/models/view/cipher.view"; +import { CollectionView } from "../../../vault/models/view/collection.view"; import { Utils } from "../../misc/utils"; -import { GeneratedPasswordHistory } from "../../tools/generator/password"; -import { SendData } from "../../tools/send/models/data/send.data"; -import { SendView } from "../../tools/send/models/view/send.view"; -import { DeepJsonify } from "../../types/deep-jsonify"; -import { CipherData } from "../../vault/models/data/cipher.data"; -import { FolderData } from "../../vault/models/data/folder.data"; -import { CipherView } from "../../vault/models/view/cipher.view"; -import { EventData } from "../data/event.data"; -import { ServerConfigData } from "../data/server-config.data"; - -import { EncString } from "./enc-string"; -import { DeviceKey, SymmetricCryptoKey } from "./symmetric-crypto-key"; +import { ServerConfigData } from "../../models/data/server-config.data"; + +import { EncryptedString, EncString } from "./enc-string"; +import { MasterKey, SymmetricCryptoKey, UserKey } from "./symmetric-crypto-key"; export class EncryptionPair { encrypted?: TEncrypted; @@ -99,15 +108,10 @@ export class AccountData { } export class AccountKeys { - cryptoMasterKey?: SymmetricCryptoKey; - cryptoMasterKeyAuto?: string; - cryptoMasterKeyB64?: string; - cryptoMasterKeyBiometric?: string; - cryptoSymmetricKey?: EncryptionPair = new EncryptionPair< - string, - SymmetricCryptoKey - >(); - deviceKey?: DeviceKey; + userKey?: UserKey; + masterKey?: MasterKey; + masterKeyEncryptedUserKey?: string; + deviceKey?: ReturnType; organizationKeys?: EncryptionPair< { [orgId: string]: EncryptedOrganizationKeyData }, Record @@ -119,10 +123,22 @@ export class AccountKeys { any, Record >(); - privateKey?: EncryptionPair = new EncryptionPair(); - publicKey?: ArrayBuffer; + privateKey?: EncryptionPair = new EncryptionPair(); + publicKey?: Uint8Array; apiKeyClientSecret?: string; + /** @deprecated July 2023, left for migration purposes*/ + cryptoMasterKey?: SymmetricCryptoKey; + /** @deprecated July 2023, left for migration purposes*/ + cryptoMasterKeyAuto?: string; + /** @deprecated July 2023, left for migration purposes*/ + cryptoMasterKeyBiometric?: string; + /** @deprecated July 2023, left for migration purposes*/ + cryptoSymmetricKey?: EncryptionPair = new EncryptionPair< + string, + SymmetricCryptoKey + >(); + toJSON() { return Utils.merge(this, { publicKey: Utils.fromBufferToByteString(this.publicKey), @@ -135,6 +151,9 @@ export class AccountKeys { } return Object.assign(new AccountKeys(), { + userKey: SymmetricCryptoKey.fromJSON(obj?.userKey), + masterKey: SymmetricCryptoKey.fromJSON(obj?.masterKey), + deviceKey: obj?.deviceKey, cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey), cryptoSymmetricKey: EncryptionPair.fromJSON( obj?.cryptoSymmetricKey, @@ -142,11 +161,10 @@ export class AccountKeys { ), organizationKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.organizationKeys), providerKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.providerKeys), - privateKey: EncryptionPair.fromJSON( - obj?.privateKey, - (decObj: string) => Utils.fromByteStringToArray(decObj).buffer + privateKey: EncryptionPair.fromJSON(obj?.privateKey, (decObj: string) => + Utils.fromByteStringToArray(decObj) ), - publicKey: Utils.fromByteStringToArray(obj?.publicKey)?.buffer, + publicKey: Utils.fromByteStringToArray(obj?.publicKey), }); } @@ -174,6 +192,7 @@ export class AccountProfile { emailVerified?: boolean; entityId?: string; entityType?: string; + everHadUserKey?: boolean; everBeenUnlocked?: boolean; forcePasswordResetReason?: ForceResetPasswordReason; hasPremiumPersonally?: boolean; @@ -221,10 +240,11 @@ export class AccountSettings { equivalentDomains?: any; minimizeOnCopyToClipboard?: boolean; neverDomains?: { [id: string]: any }; - passwordGenerationOptions?: any; - usernameGenerationOptions?: any; - generatorOptions?: any; - pinProtected?: EncryptionPair = new EncryptionPair(); + passwordGenerationOptions?: PasswordGeneratorOptions; + usernameGenerationOptions?: UsernameGeneratorOptions; + generatorOptions?: GeneratorOptions; + pinKeyEncryptedUserKey?: EncryptedString; + pinKeyEncryptedUserKeyEphemeral?: EncryptedString; protectedPin?: string; settings?: AccountSettingsSettings; // TODO: Merge whatever is going on here into the AccountSettings model properly vaultTimeout?: number; @@ -233,7 +253,12 @@ export class AccountSettings { approveLoginRequests?: boolean; avatarColor?: string; activateAutoFillOnPageLoadFromPolicy?: boolean; + region?: string; smOnboardingTasks?: Record>; + trustDeviceChoiceForDecryption?: boolean; + + /** @deprecated July 2023, left for migration purposes*/ + pinProtected?: EncryptionPair = new EncryptionPair(); static fromJSON(obj: Jsonify): AccountSettings { if (obj == null) { @@ -269,12 +294,124 @@ export class AccountTokens { } } +export class AccountDecryptionOptions { + hasMasterPassword: boolean; + trustedDeviceOption?: TrustedDeviceUserDecryptionOption; + keyConnectorOption?: KeyConnectorUserDecryptionOption; + + constructor(init?: Partial) { + if (init) { + Object.assign(this, init); + } + } + + // TODO: these nice getters don't work because the Account object is not properly being deserialized out of + // JSON (the Account static fromJSON method is not running) so these getters don't exist on the + // account decryptions options object when pulled out of state. This is a bug that needs to be fixed later on + // get hasTrustedDeviceOption(): boolean { + // return this.trustedDeviceOption !== null && this.trustedDeviceOption !== undefined; + // } + + // get hasKeyConnectorOption(): boolean { + // return this.keyConnectorOption !== null && this.keyConnectorOption !== undefined; + // } + + static fromResponse(response: IdentityTokenResponse): AccountDecryptionOptions { + if (response == null) { + return null; + } + + const accountDecryptionOptions = new AccountDecryptionOptions(); + + if (response.userDecryptionOptions) { + // If the response has userDecryptionOptions, this means it's on a post-TDE server version and can interrogate + // the new decryption options. + const responseOptions = response.userDecryptionOptions; + accountDecryptionOptions.hasMasterPassword = responseOptions.hasMasterPassword; + + if (responseOptions.trustedDeviceOption) { + accountDecryptionOptions.trustedDeviceOption = new TrustedDeviceUserDecryptionOption( + responseOptions.trustedDeviceOption.hasAdminApproval, + responseOptions.trustedDeviceOption.hasLoginApprovingDevice, + responseOptions.trustedDeviceOption.hasManageResetPasswordPermission + ); + } + + if (responseOptions.keyConnectorOption) { + accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption( + responseOptions.keyConnectorOption.keyConnectorUrl + ); + } + } else { + // If the response does not have userDecryptionOptions, this means it's on a pre-TDE server version and so + // we must base our decryption options on the presence of the keyConnectorUrl. + // Note that the presence of keyConnectorUrl implies that the user does not have a master password, as in pre-TDE + // server versions, a master password short-circuited the addition of the keyConnectorUrl to the response. + // TODO: remove this check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537) + const usingKeyConnector = response.keyConnectorUrl != null; + accountDecryptionOptions.hasMasterPassword = !usingKeyConnector; + if (usingKeyConnector) { + accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption( + response.keyConnectorUrl + ); + } + } + return accountDecryptionOptions; + } + + static fromJSON(obj: Jsonify): AccountDecryptionOptions { + if (obj == null) { + return null; + } + + const accountDecryptionOptions = Object.assign(new AccountDecryptionOptions(), obj); + + if (obj.trustedDeviceOption) { + accountDecryptionOptions.trustedDeviceOption = new TrustedDeviceUserDecryptionOption( + obj.trustedDeviceOption.hasAdminApproval, + obj.trustedDeviceOption.hasLoginApprovingDevice, + obj.trustedDeviceOption.hasManageResetPasswordPermission + ); + } + + if (obj.keyConnectorOption) { + accountDecryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption( + obj.keyConnectorOption.keyConnectorUrl + ); + } + + return accountDecryptionOptions; + } +} + +export class LoginState { + ssoOrganizationIdentifier?: string; + + constructor(init?: Partial) { + if (init) { + Object.assign(this, init); + } + } + + static fromJSON(obj: Jsonify): LoginState { + if (obj == null) { + return null; + } + + const loginState = Object.assign(new LoginState(), obj); + return loginState; + } +} + export class Account { data?: AccountData = new AccountData(); keys?: AccountKeys = new AccountKeys(); profile?: AccountProfile = new AccountProfile(); settings?: AccountSettings = new AccountSettings(); tokens?: AccountTokens = new AccountTokens(); + decryptionOptions?: AccountDecryptionOptions = new AccountDecryptionOptions(); + loginState?: LoginState = new LoginState(); + adminAuthRequest?: Jsonify = null; constructor(init: Partial) { Object.assign(this, { @@ -298,6 +435,15 @@ export class Account { ...new AccountTokens(), ...init?.tokens, }, + decryptionOptions: { + ...new AccountDecryptionOptions(), + ...init?.decryptionOptions, + }, + loginState: { + ...new LoginState(), + ...init?.loginState, + }, + adminAuthRequest: init?.adminAuthRequest, }); } @@ -311,6 +457,9 @@ export class Account { profile: AccountProfile.fromJSON(json?.profile), settings: AccountSettings.fromJSON(json?.settings), tokens: AccountTokens.fromJSON(json?.tokens), + decryptionOptions: AccountDecryptionOptions.fromJSON(json?.decryptionOptions), + loginState: LoginState.fromJSON(json?.loginState), + adminAuthRequest: AdminAuthRequestStorable.fromJSON(json?.adminAuthRequest), }); } } diff --git a/libs/common/src/models/domain/decrypt-parameters.ts b/libs/common/src/platform/models/domain/decrypt-parameters.ts similarity index 100% rename from libs/common/src/models/domain/decrypt-parameters.ts rename to libs/common/src/platform/models/domain/decrypt-parameters.ts diff --git a/libs/common/src/models/domain/domain-base.ts b/libs/common/src/platform/models/domain/domain-base.ts similarity index 97% rename from libs/common/src/models/domain/domain-base.ts rename to libs/common/src/platform/models/domain/domain-base.ts index 4c4bffd4cd5..af7df3de187 100644 --- a/libs/common/src/models/domain/domain-base.ts +++ b/libs/common/src/platform/models/domain/domain-base.ts @@ -1,4 +1,4 @@ -import { View } from "../view/view"; +import { View } from "../../../models/view/view"; import { EncString } from "./enc-string"; import { SymmetricCryptoKey } from "./symmetric-crypto-key"; diff --git a/libs/common/src/models/domain/enc-array-buffer.spec.ts b/libs/common/src/platform/models/domain/enc-array-buffer.spec.ts similarity index 83% rename from libs/common/src/models/domain/enc-array-buffer.spec.ts rename to libs/common/src/platform/models/domain/enc-array-buffer.spec.ts index 2a2052484fd..4e0464fd622 100644 --- a/libs/common/src/models/domain/enc-array-buffer.spec.ts +++ b/libs/common/src/platform/models/domain/enc-array-buffer.spec.ts @@ -1,5 +1,5 @@ -import { makeStaticByteArray } from "../../../spec"; -import { EncryptionType } from "../../enums"; +import { makeStaticByteArray } from "../../../../spec"; +import { EncryptionType } from "../../../enums"; import { EncArrayBuffer } from "./enc-array-buffer"; @@ -20,7 +20,7 @@ describe("encArrayBuffer", () => { array.set(mac, 1 + iv.byteLength); array.set(data, 1 + iv.byteLength + mac.byteLength); - const actual = new EncArrayBuffer(array.buffer); + const actual = new EncArrayBuffer(array); expect(actual.encryptionType).toEqual(encType); expect(actual.ivBytes).toEqualBuffer(iv); @@ -39,11 +39,11 @@ describe("encArrayBuffer", () => { array.set(iv, 1); array.set(data, 1 + iv.byteLength); - const actual = new EncArrayBuffer(array.buffer); + const actual = new EncArrayBuffer(array); expect(actual.encryptionType).toEqual(encType); - expect(actual.ivBytes).toEqualBuffer(iv); - expect(actual.dataBytes).toEqualBuffer(data); + expect(actual.ivBytes).toEqual(iv); + expect(actual.dataBytes).toEqual(data); expect(actual.macBytes).toBeNull(); }); }); @@ -58,13 +58,11 @@ describe("encArrayBuffer", () => { // Minus 1 to leave room for the encType, minus 1 to make it invalid const invalidBytes = makeStaticByteArray(minLength - 2); - const invalidArray = new Uint8Array(1 + invalidBytes.buffer.byteLength); + const invalidArray = new Uint8Array(1 + invalidBytes.byteLength); invalidArray.set([encType]); invalidArray.set(invalidBytes, 1); - expect(() => new EncArrayBuffer(invalidArray.buffer)).toThrow( - "Error parsing encrypted ArrayBuffer" - ); + expect(() => new EncArrayBuffer(invalidArray)).toThrow("Error parsing encrypted ArrayBuffer"); }); }); diff --git a/libs/common/src/models/domain/enc-array-buffer.ts b/libs/common/src/platform/models/domain/enc-array-buffer.ts similarity index 74% rename from libs/common/src/models/domain/enc-array-buffer.ts rename to libs/common/src/platform/models/domain/enc-array-buffer.ts index a3548c037e0..afe9bad57f2 100644 --- a/libs/common/src/models/domain/enc-array-buffer.ts +++ b/libs/common/src/platform/models/domain/enc-array-buffer.ts @@ -1,20 +1,20 @@ -import { EncryptionType } from "../../enums"; -import { IEncrypted } from "../../interfaces/IEncrypted"; -import { Utils } from "../../misc/utils"; +import { EncryptionType } from "../../../enums"; +import { Utils } from "../../../platform/misc/utils"; +import { Encrypted } from "../../interfaces/encrypted"; const ENC_TYPE_LENGTH = 1; const IV_LENGTH = 16; const MAC_LENGTH = 32; const MIN_DATA_LENGTH = 1; -export class EncArrayBuffer implements IEncrypted { +export class EncArrayBuffer implements Encrypted { readonly encryptionType: EncryptionType = null; - readonly dataBytes: ArrayBuffer = null; - readonly ivBytes: ArrayBuffer = null; - readonly macBytes: ArrayBuffer = null; + readonly dataBytes: Uint8Array = null; + readonly ivBytes: Uint8Array = null; + readonly macBytes: Uint8Array = null; - constructor(readonly buffer: ArrayBuffer) { - const encBytes = new Uint8Array(buffer); + constructor(readonly buffer: Uint8Array) { + const encBytes = buffer; const encType = encBytes[0]; switch (encType) { @@ -25,12 +25,12 @@ export class EncArrayBuffer implements IEncrypted { this.throwDecryptionError(); } - this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH).buffer; + this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH); this.macBytes = encBytes.slice( ENC_TYPE_LENGTH + IV_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH - ).buffer; - this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH).buffer; + ); + this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH); break; } case EncryptionType.AesCbc256_B64: { @@ -39,8 +39,8 @@ export class EncArrayBuffer implements IEncrypted { this.throwDecryptionError(); } - this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH).buffer; - this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH).buffer; + this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH); + this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH); break; } default: @@ -63,11 +63,11 @@ export class EncArrayBuffer implements IEncrypted { if (buffer == null) { throw new Error("Cannot create EncArrayBuffer from Response - Response is empty"); } - return new EncArrayBuffer(buffer); + return new EncArrayBuffer(new Uint8Array(buffer)); } static fromB64(b64: string) { - const buffer = Utils.fromB64ToArray(b64).buffer; + const buffer = Utils.fromB64ToArray(b64); return new EncArrayBuffer(buffer); } } diff --git a/libs/common/src/models/domain/enc-string.spec.ts b/libs/common/src/platform/models/domain/enc-string.spec.ts similarity index 92% rename from libs/common/src/models/domain/enc-string.spec.ts rename to libs/common/src/platform/models/domain/enc-string.spec.ts index 08ff9c145d4..2aa4f2161fe 100644 --- a/libs/common/src/models/domain/enc-string.spec.ts +++ b/libs/common/src/platform/models/domain/enc-string.spec.ts @@ -2,13 +2,17 @@ import { Substitute, Arg } from "@fluffy-spoon/substitute"; import { mock, MockProxy } from "jest-mock-extended"; +import { EncryptionType } from "../../../enums"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { + OrgKey, + SymmetricCryptoKey, + UserKey, +} from "../../../platform/models/domain/symmetric-crypto-key"; import { CryptoService } from "../../abstractions/crypto.service"; -import { EncryptService } from "../../abstractions/encrypt.service"; -import { EncryptionType } from "../../enums"; import { ContainerService } from "../../services/container.service"; import { EncString } from "./enc-string"; -import { SymmetricCryptoKey } from "./symmetric-crypto-key"; describe("EncString", () => { afterEach(() => { @@ -225,12 +229,12 @@ describe("EncString", () => { await encString.decrypt(null, key); - expect(cryptoService.getKeyForUserEncryption).not.toHaveBeenCalled(); + expect(cryptoService.getUserKeyWithLegacySupport).not.toHaveBeenCalled(); expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encString, key); }); it("gets an organization key if required", async () => { - const orgKey = mock(); + const orgKey = mock(); cryptoService.getOrgKey.calledWith("orgId").mockResolvedValue(orgKey); @@ -241,13 +245,13 @@ describe("EncString", () => { }); it("gets the user's decryption key if required", async () => { - const userKey = mock(); + const userKey = mock(); - cryptoService.getKeyForUserEncryption.mockResolvedValue(userKey); + cryptoService.getUserKeyWithLegacySupport.mockResolvedValue(userKey); await encString.decrypt(null, null); - expect(cryptoService.getKeyForUserEncryption).toHaveBeenCalledWith(); + expect(cryptoService.getUserKeyWithLegacySupport).toHaveBeenCalledWith(); expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(encString, userKey); }); }); diff --git a/libs/common/src/models/domain/enc-string.ts b/libs/common/src/platform/models/domain/enc-string.ts similarity index 83% rename from libs/common/src/models/domain/enc-string.ts rename to libs/common/src/platform/models/domain/enc-string.ts index 3b2eed30962..60b59fa4d8c 100644 --- a/libs/common/src/models/domain/enc-string.ts +++ b/libs/common/src/platform/models/domain/enc-string.ts @@ -1,13 +1,13 @@ -import { Jsonify } from "type-fest"; +import { Jsonify, Opaque } from "type-fest"; -import { EncryptionType, EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE } from "../../enums"; -import { IEncrypted } from "../../interfaces/IEncrypted"; -import { Utils } from "../../misc/utils"; +import { EncryptionType, EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE } from "../../../enums"; +import { Utils } from "../../../platform/misc/utils"; +import { Encrypted } from "../../interfaces/encrypted"; import { SymmetricCryptoKey } from "./symmetric-crypto-key"; -export class EncString implements IEncrypted { - encryptedString?: string; +export class EncString implements Encrypted { + encryptedString?: EncryptedString; encryptionType?: EncryptionType; decryptedValue?: string; data?: string; @@ -27,16 +27,16 @@ export class EncString implements IEncrypted { } } - get ivBytes(): ArrayBuffer { - return this.iv == null ? null : Utils.fromB64ToArray(this.iv).buffer; + get ivBytes(): Uint8Array { + return this.iv == null ? null : Utils.fromB64ToArray(this.iv); } - get macBytes(): ArrayBuffer { - return this.mac == null ? null : Utils.fromB64ToArray(this.mac).buffer; + get macBytes(): Uint8Array { + return this.mac == null ? null : Utils.fromB64ToArray(this.mac); } - get dataBytes(): ArrayBuffer { - return this.data == null ? null : Utils.fromB64ToArray(this.data).buffer; + get dataBytes(): Uint8Array { + return this.data == null ? null : Utils.fromB64ToArray(this.data); } toJSON() { @@ -53,14 +53,14 @@ export class EncString implements IEncrypted { private initFromData(encType: EncryptionType, data: string, iv: string, mac: string) { if (iv != null) { - this.encryptedString = encType + "." + iv + "|" + data; + this.encryptedString = (encType + "." + iv + "|" + data) as EncryptedString; } else { - this.encryptedString = encType + "." + data; + this.encryptedString = (encType + "." + data) as EncryptedString; } // mac if (mac != null) { - this.encryptedString += "|" + mac; + this.encryptedString = (this.encryptedString + "|" + mac) as EncryptedString; } this.encryptionType = encType; @@ -70,7 +70,7 @@ export class EncString implements IEncrypted { } private initFromEncryptedString(encryptedString: string) { - this.encryptedString = encryptedString as string; + this.encryptedString = encryptedString as EncryptedString; if (!this.encryptedString) { return; } @@ -162,6 +162,8 @@ export class EncString implements IEncrypted { const cryptoService = Utils.getContainerService().getCryptoService(); return orgId != null ? await cryptoService.getOrgKey(orgId) - : await cryptoService.getKeyForUserEncryption(); + : await cryptoService.getUserKeyWithLegacySupport(); } } + +export type EncryptedString = Opaque; diff --git a/libs/common/src/platform/models/domain/encrypted-object.ts b/libs/common/src/platform/models/domain/encrypted-object.ts new file mode 100644 index 00000000000..22d5a388114 --- /dev/null +++ b/libs/common/src/platform/models/domain/encrypted-object.ts @@ -0,0 +1,8 @@ +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; + +export class EncryptedObject { + iv: Uint8Array; + data: Uint8Array; + mac: Uint8Array; + key: SymmetricCryptoKey; +} diff --git a/libs/common/src/models/domain/encryption-pair.spec.ts b/libs/common/src/platform/models/domain/encryption-pair.spec.ts similarity index 77% rename from libs/common/src/models/domain/encryption-pair.spec.ts rename to libs/common/src/platform/models/domain/encryption-pair.spec.ts index c91cb3d2b97..1418c125ed6 100644 --- a/libs/common/src/models/domain/encryption-pair.spec.ts +++ b/libs/common/src/platform/models/domain/encryption-pair.spec.ts @@ -11,6 +11,13 @@ describe("EncryptionPair", () => { expect(json.decrypted).toEqual("hello"); }); + it("should populate decryptedSerialized for TypesArrays", () => { + const pair = new EncryptionPair(); + pair.decrypted = Utils.fromByteStringToArray("hello"); + const json = pair.toJSON(); + expect(json.decrypted).toEqual(new Uint8Array([104, 101, 108, 108, 111])); + }); + it("should serialize encrypted and decrypted", () => { const pair = new EncryptionPair(); pair.encrypted = "hello"; diff --git a/libs/common/src/models/domain/global-state.ts b/libs/common/src/platform/models/domain/global-state.ts similarity index 82% rename from libs/common/src/models/domain/global-state.ts rename to libs/common/src/platform/models/domain/global-state.ts index 5ed8557b48f..30ad32124cf 100644 --- a/libs/common/src/models/domain/global-state.ts +++ b/libs/common/src/platform/models/domain/global-state.ts @@ -1,7 +1,6 @@ -import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; -import { StateVersion, ThemeType } from "../../enums"; - -import { WindowState } from "./window-state"; +import { EnvironmentUrls } from "../../../auth/models/domain/environment-urls"; +import { ThemeType } from "../../../enums"; +import { WindowState } from "../../../models/domain/window-state"; export class GlobalState { enableAlwaysOnTop?: boolean; @@ -26,7 +25,6 @@ export class GlobalState { enableBiometrics?: boolean; biometricText?: string; noAutoPromptBiometricsText?: string; - stateVersion: StateVersion = StateVersion.One; environmentUrls: EnvironmentUrls = new EnvironmentUrls(); enableTray?: boolean; enableMinimizeToTray?: boolean; @@ -37,4 +35,5 @@ export class GlobalState { enableBrowserIntegration?: boolean; enableBrowserIntegrationFingerprint?: boolean; enableDuckDuckGoBrowserIntegration?: boolean; + region?: string; } diff --git a/libs/common/src/models/domain/state.spec.ts b/libs/common/src/platform/models/domain/state.spec.ts similarity index 100% rename from libs/common/src/models/domain/state.spec.ts rename to libs/common/src/platform/models/domain/state.spec.ts diff --git a/libs/common/src/models/domain/state.ts b/libs/common/src/platform/models/domain/state.ts similarity index 100% rename from libs/common/src/models/domain/state.ts rename to libs/common/src/platform/models/domain/state.ts diff --git a/libs/common/src/models/domain/storage-options.ts b/libs/common/src/platform/models/domain/storage-options.ts similarity index 82% rename from libs/common/src/models/domain/storage-options.ts rename to libs/common/src/platform/models/domain/storage-options.ts index 6ed430ac50f..767c4ba2379 100644 --- a/libs/common/src/models/domain/storage-options.ts +++ b/libs/common/src/platform/models/domain/storage-options.ts @@ -1,6 +1,6 @@ import { Jsonify } from "type-fest"; -import { HtmlStorageLocation, StorageLocation } from "../../enums"; +import { HtmlStorageLocation, StorageLocation } from "../../../enums"; export type StorageOptions = { storageLocation?: StorageLocation; diff --git a/libs/common/src/models/domain/symmetric-crypto-key.spec.ts b/libs/common/src/platform/models/domain/symmetric-crypto-key.spec.ts similarity index 92% rename from libs/common/src/models/domain/symmetric-crypto-key.spec.ts rename to libs/common/src/platform/models/domain/symmetric-crypto-key.spec.ts index c371008bb36..13d5835c918 100644 --- a/libs/common/src/models/domain/symmetric-crypto-key.spec.ts +++ b/libs/common/src/platform/models/domain/symmetric-crypto-key.spec.ts @@ -1,5 +1,5 @@ -import { makeStaticByteArray } from "../../../spec"; -import { EncryptionType } from "../../enums"; +import { makeStaticByteArray } from "../../../../spec"; +import { EncryptionType } from "../../../enums"; import { SymmetricCryptoKey } from "./symmetric-crypto-key"; @@ -68,7 +68,7 @@ describe("SymmetricCryptoKey", () => { }); it("toJSON creates object for serialization", () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(64).buffer); + const key = new SymmetricCryptoKey(makeStaticByteArray(64)); const actual = key.toJSON(); const expected = { keyB64: key.keyB64 }; @@ -77,7 +77,7 @@ describe("SymmetricCryptoKey", () => { }); it("fromJSON hydrates new object", () => { - const expected = new SymmetricCryptoKey(makeStaticByteArray(64).buffer); + const expected = new SymmetricCryptoKey(makeStaticByteArray(64)); const actual = SymmetricCryptoKey.fromJSON({ keyB64: expected.keyB64 }); expect(actual).toEqual(expected); diff --git a/libs/common/src/models/domain/symmetric-crypto-key.ts b/libs/common/src/platform/models/domain/symmetric-crypto-key.ts similarity index 75% rename from libs/common/src/models/domain/symmetric-crypto-key.ts rename to libs/common/src/platform/models/domain/symmetric-crypto-key.ts index 8c9920d1319..8f3b46f077c 100644 --- a/libs/common/src/models/domain/symmetric-crypto-key.ts +++ b/libs/common/src/platform/models/domain/symmetric-crypto-key.ts @@ -1,12 +1,12 @@ import { Jsonify, Opaque } from "type-fest"; -import { EncryptionType } from "../../enums"; -import { Utils } from "../../misc/utils"; +import { EncryptionType } from "../../../enums"; +import { Utils } from "../../../platform/misc/utils"; export class SymmetricCryptoKey { - key: ArrayBuffer; - encKey?: ArrayBuffer; - macKey?: ArrayBuffer; + key: Uint8Array; + encKey?: Uint8Array; + macKey?: Uint8Array; encType: EncryptionType; keyB64: string; @@ -15,7 +15,7 @@ export class SymmetricCryptoKey { meta: any; - constructor(key: ArrayBuffer, encType?: EncryptionType) { + constructor(key: Uint8Array, encType?: EncryptionType) { if (key == null) { throw new Error("Must provide key"); } @@ -67,7 +67,7 @@ export class SymmetricCryptoKey { return null; } - const arrayBuffer = Utils.fromB64ToArray(s).buffer; + const arrayBuffer = Utils.fromB64ToArray(s); return new SymmetricCryptoKey(arrayBuffer); } @@ -78,3 +78,9 @@ export class SymmetricCryptoKey { // Setup all separate key types as opaque types export type DeviceKey = Opaque; +export type UserKey = Opaque; +export type MasterKey = Opaque; +export type PinKey = Opaque; +export type OrgKey = Opaque; +export type ProviderKey = Opaque; +export type CipherKey = Opaque; diff --git a/libs/common/src/models/response/server-config.response.ts b/libs/common/src/platform/models/response/server-config.response.ts similarity index 87% rename from libs/common/src/models/response/server-config.response.ts rename to libs/common/src/platform/models/response/server-config.response.ts index 7594f86aa80..f611acf6f42 100644 --- a/libs/common/src/models/response/server-config.response.ts +++ b/libs/common/src/platform/models/response/server-config.response.ts @@ -1,4 +1,5 @@ -import { BaseResponse } from "./base.response"; +import { BaseResponse } from "../../../models/response/base.response"; +import { Region } from "../../abstractions/environment.service"; export class ServerConfigResponse extends BaseResponse { version: string; @@ -23,6 +24,7 @@ export class ServerConfigResponse extends BaseResponse { } export class EnvironmentServerConfigResponse extends BaseResponse { + cloudRegion: Region; vault: string; api: string; identity: string; @@ -36,6 +38,7 @@ export class EnvironmentServerConfigResponse extends BaseResponse { return; } + this.cloudRegion = this.getResponseProperty("CloudRegion"); this.vault = this.getResponseProperty("Vault"); this.api = this.getResponseProperty("Api"); this.identity = this.getResponseProperty("Identity"); diff --git a/libs/common/src/services/appId.service.ts b/libs/common/src/platform/services/app-id.service.ts similarity index 92% rename from libs/common/src/services/appId.service.ts rename to libs/common/src/platform/services/app-id.service.ts index a1baf4fc701..0ce1286e4d3 100644 --- a/libs/common/src/services/appId.service.ts +++ b/libs/common/src/platform/services/app-id.service.ts @@ -1,6 +1,6 @@ -import { AppIdService as AppIdServiceAbstraction } from "../abstractions/appId.service"; +import { HtmlStorageLocation } from "../../enums"; +import { AppIdService as AppIdServiceAbstraction } from "../abstractions/app-id.service"; import { AbstractStorageService } from "../abstractions/storage.service"; -import { HtmlStorageLocation } from "../enums"; import { Utils } from "../misc/utils"; export class AppIdService implements AppIdServiceAbstraction { diff --git a/libs/common/src/services/broadcaster.service.ts b/libs/common/src/platform/services/broadcaster.service.ts similarity index 100% rename from libs/common/src/services/broadcaster.service.ts rename to libs/common/src/platform/services/broadcaster.service.ts diff --git a/libs/common/src/platform/services/config/config-api.service.ts b/libs/common/src/platform/services/config/config-api.service.ts new file mode 100644 index 00000000000..b874dbfd99b --- /dev/null +++ b/libs/common/src/platform/services/config/config-api.service.ts @@ -0,0 +1,17 @@ +import { ApiService } from "../../../abstractions/api.service"; +import { AuthService } from "../../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; +import { ServerConfigResponse } from "../../models/response/server-config.response"; + +export class ConfigApiService implements ConfigApiServiceAbstraction { + constructor(private apiService: ApiService, private authService: AuthService) {} + + async get(): Promise { + const authed: boolean = + (await this.authService.getAuthStatus()) !== AuthenticationStatus.LoggedOut; + + const r = await this.apiService.send("GET", "/config", null, authed, true); + return new ServerConfigResponse(r); + } +} diff --git a/libs/common/src/platform/services/config/config.service.spec.ts b/libs/common/src/platform/services/config/config.service.spec.ts new file mode 100644 index 00000000000..2f3a90f70fd --- /dev/null +++ b/libs/common/src/platform/services/config/config.service.spec.ts @@ -0,0 +1,202 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { ReplaySubject, skip, take } from "rxjs"; + +import { AuthService } from "../../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; +import { ServerConfig } from "../../abstractions/config/server-config"; +import { EnvironmentService } from "../../abstractions/environment.service"; +import { LogService } from "../../abstractions/log.service"; +import { StateService } from "../../abstractions/state.service"; +import { ServerConfigData } from "../../models/data/server-config.data"; +import { + EnvironmentServerConfigResponse, + ServerConfigResponse, + ThirdPartyServerConfigResponse, +} from "../../models/response/server-config.response"; + +import { ConfigService } from "./config.service"; + +describe("ConfigService", () => { + let stateService: MockProxy; + let configApiService: MockProxy; + let authService: MockProxy; + let environmentService: MockProxy; + let logService: MockProxy; + + let serverResponseCount: number; // increments to track distinct responses received from server + + // Observables will start emitting as soon as this is created, so only create it + // after everything is mocked + const configServiceFactory = () => { + const configService = new ConfigService( + stateService, + configApiService, + authService, + environmentService, + logService + ); + configService.init(); + return configService; + }; + + beforeEach(() => { + stateService = mock(); + configApiService = mock(); + authService = mock(); + environmentService = mock(); + logService = mock(); + + environmentService.urls = new ReplaySubject(1); + + serverResponseCount = 1; + configApiService.get.mockImplementation(() => + Promise.resolve(serverConfigResponseFactory("server" + serverResponseCount++)) + ); + + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("Uses storage as fallback", (done) => { + const storedConfigData = serverConfigDataFactory("storedConfig"); + stateService.getServerConfig.mockResolvedValueOnce(storedConfigData); + + configApiService.get.mockRejectedValueOnce(new Error("Unable to fetch")); + + const configService = configServiceFactory(); + + configService.serverConfig$.pipe(take(1)).subscribe((config) => { + expect(config).toEqual(new ServerConfig(storedConfigData)); + expect(stateService.getServerConfig).toHaveBeenCalledTimes(1); + expect(stateService.setServerConfig).not.toHaveBeenCalled(); + done(); + }); + + configService.triggerServerConfigFetch(); + }); + + it("Stream does not error out if fetch fails", (done) => { + const storedConfigData = serverConfigDataFactory("storedConfig"); + stateService.getServerConfig.mockResolvedValueOnce(storedConfigData); + + const configService = configServiceFactory(); + + configService.serverConfig$.pipe(skip(1), take(1)).subscribe((config) => { + try { + expect(config.gitHash).toEqual("server1"); + done(); + } catch (e) { + done(e); + } + }); + + configApiService.get.mockRejectedValueOnce(new Error("Unable to fetch")); + configService.triggerServerConfigFetch(); + + configApiService.get.mockResolvedValueOnce(serverConfigResponseFactory("server1")); + configService.triggerServerConfigFetch(); + }); + + describe("Fetches config from server", () => { + beforeEach(() => { + stateService.getServerConfig.mockResolvedValueOnce(null); + }); + + it.each([1, 2, 3])( + "after %p hour/s", + (hours: number, done: jest.DoneCallback) => { + const configService = configServiceFactory(); + + // skip previous hours (if any) + configService.serverConfig$.pipe(skip(hours - 1), take(1)).subscribe((config) => { + try { + expect(config.gitHash).toEqual("server" + hours); + expect(configApiService.get).toHaveBeenCalledTimes(hours); + done(); + } catch (e) { + done(e); + } + }); + + const oneHourInMs = 1000 * 3600; + jest.advanceTimersByTime(oneHourInMs * hours + 1); + } + ); + + it("when environment URLs change", (done) => { + const configService = configServiceFactory(); + + configService.serverConfig$.pipe(take(1)).subscribe((config) => { + try { + expect(config.gitHash).toEqual("server1"); + done(); + } catch (e) { + done(e); + } + }); + + (environmentService.urls as ReplaySubject).next(); + }); + + it("when triggerServerConfigFetch() is called", (done) => { + const configService = configServiceFactory(); + + configService.serverConfig$.pipe(take(1)).subscribe((config) => { + try { + expect(config.gitHash).toEqual("server1"); + done(); + } catch (e) { + done(e); + } + }); + + configService.triggerServerConfigFetch(); + }); + }); + + it("Saves server config to storage when the user is logged in", (done) => { + stateService.getServerConfig.mockResolvedValueOnce(null); + authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Locked); + const configService = configServiceFactory(); + + configService.serverConfig$.pipe(take(1)).subscribe(() => { + try { + expect(stateService.setServerConfig).toHaveBeenCalledWith( + expect.objectContaining({ gitHash: "server1" }) + ); + done(); + } catch (e) { + done(e); + } + }); + + configService.triggerServerConfigFetch(); + }); +}); + +function serverConfigDataFactory(gitHash: string) { + return new ServerConfigData(serverConfigResponseFactory(gitHash)); +} + +function serverConfigResponseFactory(gitHash: string) { + return new ServerConfigResponse({ + version: "myConfigVersion", + gitHash: gitHash, + server: new ThirdPartyServerConfigResponse({ + name: "myThirdPartyServer", + url: "www.example.com", + }), + environment: new EnvironmentServerConfigResponse({ + vault: "vault.example.com", + }), + featureStates: { + feat1: "off", + feat2: "on", + feat3: "off", + }, + }); +} diff --git a/libs/common/src/platform/services/config/config.service.ts b/libs/common/src/platform/services/config/config.service.ts new file mode 100644 index 00000000000..45db66af0eb --- /dev/null +++ b/libs/common/src/platform/services/config/config.service.ts @@ -0,0 +1,127 @@ +import { + ReplaySubject, + Subject, + catchError, + concatMap, + defer, + delayWhen, + firstValueFrom, + map, + merge, + timer, +} from "rxjs"; +import { SemVer } from "semver"; + +import { AuthService } from "../../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { FeatureFlag, FeatureFlagValue } from "../../../enums/feature-flag.enum"; +import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; +import { ConfigServiceAbstraction } from "../../abstractions/config/config.service.abstraction"; +import { ServerConfig } from "../../abstractions/config/server-config"; +import { EnvironmentService, Region } from "../../abstractions/environment.service"; +import { LogService } from "../../abstractions/log.service"; +import { StateService } from "../../abstractions/state.service"; +import { ServerConfigData } from "../../models/data/server-config.data"; + +const ONE_HOUR_IN_MILLISECONDS = 1000 * 3600; + +export class ConfigService implements ConfigServiceAbstraction { + private inited = false; + + protected _serverConfig = new ReplaySubject(1); + serverConfig$ = this._serverConfig.asObservable(); + + private _forceFetchConfig = new Subject(); + protected refreshTimer$ = timer(ONE_HOUR_IN_MILLISECONDS, ONE_HOUR_IN_MILLISECONDS); // after 1 hour, then every hour + + cloudRegion$ = this.serverConfig$.pipe( + map((config) => config?.environment?.cloudRegion ?? Region.US) + ); + + constructor( + private stateService: StateService, + private configApiService: ConfigApiServiceAbstraction, + private authService: AuthService, + private environmentService: EnvironmentService, + private logService: LogService, + + // Used to avoid duplicate subscriptions, e.g. in browser between the background and popup + private subscribe = true + ) {} + + init() { + if (!this.subscribe || this.inited) { + return; + } + + const latestServerConfig$ = defer(() => this.configApiService.get()).pipe( + map((response) => new ServerConfigData(response)), + delayWhen((data) => this.saveConfig(data)), + catchError((e: unknown) => { + // fall back to stored ServerConfig (if any) + this.logService.error("Unable to fetch ServerConfig: " + (e as Error)?.message); + return this.stateService.getServerConfig(); + }) + ); + + // If you need to fetch a new config when an event occurs, add an observable that emits on that event here + merge( + this.refreshTimer$, // an overridable interval + this.environmentService.urls, // when environment URLs change (including when app is started) + this._forceFetchConfig // manual + ) + .pipe( + concatMap(() => latestServerConfig$), + map((data) => (data == null ? null : new ServerConfig(data))) + ) + .subscribe((config) => this._serverConfig.next(config)); + + this.inited = true; + } + + getFeatureFlag$(key: FeatureFlag, defaultValue?: T) { + return this.serverConfig$.pipe( + map((serverConfig) => { + if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) { + return defaultValue; + } + + return serverConfig.featureStates[key] as T; + }) + ); + } + + async getFeatureFlag(key: FeatureFlag, defaultValue?: T) { + return await firstValueFrom(this.getFeatureFlag$(key, defaultValue)); + } + + triggerServerConfigFetch() { + this._forceFetchConfig.next(); + } + + private async saveConfig(data: ServerConfigData) { + if ((await this.authService.getAuthStatus()) === AuthenticationStatus.LoggedOut) { + return; + } + + await this.stateService.setServerConfig(data); + this.environmentService.setCloudWebVaultUrl(data.environment?.cloudRegion); + } + + /** + * Verifies whether the server version meets the minimum required version + * @param minimumRequiredServerVersion The minimum version required + * @returns True if the server version is greater than or equal to the minimum required version + */ + checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer) { + return this.serverConfig$.pipe( + map((serverConfig) => { + if (serverConfig == null) { + return false; + } + const serverVersion = new SemVer(serverConfig.version); + return serverVersion.compare(minimumRequiredServerVersion) >= 0; + }) + ); + } +} diff --git a/libs/common/src/services/console-log.service.spec.ts b/libs/common/src/platform/services/console-log.service.spec.ts similarity index 92% rename from libs/common/src/services/console-log.service.spec.ts rename to libs/common/src/platform/services/console-log.service.spec.ts index 0b14ac559b1..129969bbc4f 100644 --- a/libs/common/src/services/console-log.service.spec.ts +++ b/libs/common/src/platform/services/console-log.service.spec.ts @@ -1,6 +1,6 @@ -import { interceptConsole, restoreConsole } from "../../spec"; +import { interceptConsole, restoreConsole } from "../../../spec"; -import { ConsoleLogService } from "./consoleLog.service"; +import { ConsoleLogService } from "./console-log.service"; let caughtMessage: any; diff --git a/libs/common/src/services/consoleLog.service.ts b/libs/common/src/platform/services/console-log.service.ts similarity index 96% rename from libs/common/src/services/consoleLog.service.ts rename to libs/common/src/platform/services/console-log.service.ts index 23047f2a92b..966d848f765 100644 --- a/libs/common/src/services/consoleLog.service.ts +++ b/libs/common/src/platform/services/console-log.service.ts @@ -1,5 +1,5 @@ +import { LogLevelType } from "../../enums"; import { LogService as LogServiceAbstraction } from "../abstractions/log.service"; -import { LogLevelType } from "../enums"; export class ConsoleLogService implements LogServiceAbstraction { protected timersMap: Map = new Map(); diff --git a/libs/common/src/services/container.service.ts b/libs/common/src/platform/services/container.service.ts similarity index 100% rename from libs/common/src/services/container.service.ts rename to libs/common/src/platform/services/container.service.ts diff --git a/libs/common/src/platform/services/crypto.service.spec.ts b/libs/common/src/platform/services/crypto.service.spec.ts new file mode 100644 index 00000000000..c84b305537a --- /dev/null +++ b/libs/common/src/platform/services/crypto.service.spec.ts @@ -0,0 +1,216 @@ +import { mock, mockReset } from "jest-mock-extended"; + +import { CsprngArray } from "../../types/csprng"; +import { CryptoFunctionService } from "../abstractions/crypto-function.service"; +import { EncryptService } from "../abstractions/encrypt.service"; +import { LogService } from "../abstractions/log.service"; +import { PlatformUtilsService } from "../abstractions/platform-utils.service"; +import { StateService } from "../abstractions/state.service"; +import { EncString } from "../models/domain/enc-string"; +import { + MasterKey, + PinKey, + SymmetricCryptoKey, + UserKey, +} from "../models/domain/symmetric-crypto-key"; +import { CryptoService } from "../services/crypto.service"; + +describe("cryptoService", () => { + let cryptoService: CryptoService; + + const cryptoFunctionService = mock(); + const encryptService = mock(); + const platformUtilService = mock(); + const logService = mock(); + const stateService = mock(); + + const mockUserId = "mock user id"; + + beforeEach(() => { + mockReset(cryptoFunctionService); + mockReset(encryptService); + mockReset(platformUtilService); + mockReset(logService); + mockReset(stateService); + + cryptoService = new CryptoService( + cryptoFunctionService, + encryptService, + platformUtilService, + logService, + stateService + ); + }); + + it("instantiates", () => { + expect(cryptoService).not.toBeFalsy(); + }); + + describe("getUserKey", () => { + let mockUserKey: UserKey; + let stateSvcGetUserKey: jest.SpyInstance; + + beforeEach(() => { + const mockRandomBytes = new Uint8Array(64) as CsprngArray; + mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; + + stateSvcGetUserKey = jest.spyOn(stateService, "getUserKey"); + }); + + it("returns the User Key if available", async () => { + stateSvcGetUserKey.mockResolvedValue(mockUserKey); + + const userKey = await cryptoService.getUserKey(mockUserId); + + expect(stateSvcGetUserKey).toHaveBeenCalledWith({ userId: mockUserId }); + expect(userKey).toEqual(mockUserKey); + }); + + it("sets the Auto key if the User Key if not set", async () => { + const autoKeyB64 = + "IT5cA1i5Hncd953pb00E58D2FqJX+fWTj4AvoI67qkGHSQPgulAqKv+LaKRAo9Bg0xzP9Nw00wk4TqjMmGSM+g=="; + stateService.getUserKeyAutoUnlock.mockResolvedValue(autoKeyB64); + + const userKey = await cryptoService.getUserKey(mockUserId); + + expect(stateService.setUserKey).toHaveBeenCalledWith(expect.any(SymmetricCryptoKey), { + userId: mockUserId, + }); + expect(userKey.keyB64).toEqual(autoKeyB64); + }); + }); + + describe("getUserKeyWithLegacySupport", () => { + let mockUserKey: UserKey; + let mockMasterKey: MasterKey; + let stateSvcGetUserKey: jest.SpyInstance; + let stateSvcGetMasterKey: jest.SpyInstance; + + beforeEach(() => { + const mockRandomBytes = new Uint8Array(64) as CsprngArray; + mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; + mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey; + + stateSvcGetUserKey = jest.spyOn(stateService, "getUserKey"); + stateSvcGetMasterKey = jest.spyOn(stateService, "getMasterKey"); + }); + + it("returns the User Key if available", async () => { + stateSvcGetUserKey.mockResolvedValue(mockUserKey); + + const userKey = await cryptoService.getUserKeyWithLegacySupport(mockUserId); + + expect(stateSvcGetUserKey).toHaveBeenCalledWith({ userId: mockUserId }); + expect(stateSvcGetMasterKey).not.toHaveBeenCalled(); + + expect(userKey).toEqual(mockUserKey); + }); + + it("returns the user's master key when User Key is not available", async () => { + stateSvcGetUserKey.mockResolvedValue(null); + stateSvcGetMasterKey.mockResolvedValue(mockMasterKey); + + const userKey = await cryptoService.getUserKeyWithLegacySupport(mockUserId); + + expect(stateSvcGetMasterKey).toHaveBeenCalledWith({ userId: mockUserId }); + expect(userKey).toEqual(mockMasterKey); + }); + }); + + describe("setUserKey", () => { + let mockUserKey: UserKey; + + beforeEach(() => { + const mockRandomBytes = new Uint8Array(64) as CsprngArray; + mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; + }); + + describe("Auto Key refresh", () => { + it("sets an Auto key if vault timeout is set to null", async () => { + stateService.getVaultTimeout.mockResolvedValue(null); + + await cryptoService.setUserKey(mockUserKey, mockUserId); + + expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(mockUserKey.keyB64, { + userId: mockUserId, + }); + }); + + it("clears the Auto key if vault timeout is set to anything other than null", async () => { + stateService.getVaultTimeout.mockResolvedValue(10); + + await cryptoService.setUserKey(mockUserKey, mockUserId); + + expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { + userId: mockUserId, + }); + }); + + it("clears the old deprecated Auto key whenever a User Key is set", async () => { + await cryptoService.setUserKey(mockUserKey, mockUserId); + + expect(stateService.setCryptoMasterKeyAuto).toHaveBeenCalledWith(null, { + userId: mockUserId, + }); + }); + }); + + describe("Pin Key refresh", () => { + let cryptoSvcMakePinKey: jest.SpyInstance; + const protectedPin = + "2.jcow2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|YvFBff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg="; + let encPin: EncString; + + beforeEach(() => { + cryptoSvcMakePinKey = jest.spyOn(cryptoService, "makePinKey"); + cryptoSvcMakePinKey.mockResolvedValue(new SymmetricCryptoKey(new Uint8Array(64)) as PinKey); + encPin = new EncString( + "2.jcow2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|YvFBff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=" + ); + encryptService.encrypt.mockResolvedValue(encPin); + }); + + it("sets a UserKeyPin if a ProtectedPin and UserKeyPin is set", async () => { + stateService.getProtectedPin.mockResolvedValue(protectedPin); + stateService.getPinKeyEncryptedUserKey.mockResolvedValue( + new EncString( + "2.OdGNE3L23GaDZGvu9h2Brw==|/OAcNnrYwu0rjiv8+RUr3Tc+Ef8fV035Tm1rbTxfEuC+2LZtiCAoIvHIZCrM/V1PWnb/pHO2gh9+Koks04YhX8K29ED4FzjeYP8+YQD/dWo=|+12xTcIK/UVRsOyawYudPMHb6+lCHeR2Peq1pQhPm0A=" + ) + ); + + await cryptoService.setUserKey(mockUserKey, mockUserId); + + expect(stateService.setPinKeyEncryptedUserKey).toHaveBeenCalledWith(expect.any(EncString), { + userId: mockUserId, + }); + }); + + it("sets a PinKeyEphemeral if a ProtectedPin is set, but a UserKeyPin is not set", async () => { + stateService.getProtectedPin.mockResolvedValue(protectedPin); + stateService.getPinKeyEncryptedUserKey.mockResolvedValue(null); + + await cryptoService.setUserKey(mockUserKey, mockUserId); + + expect(stateService.setPinKeyEncryptedUserKeyEphemeral).toHaveBeenCalledWith( + expect.any(EncString), + { + userId: mockUserId, + } + ); + }); + + it("clears the UserKeyPin and UserKeyPinEphemeral if the ProtectedPin is not set", async () => { + stateService.getProtectedPin.mockResolvedValue(null); + + await cryptoService.setUserKey(mockUserKey, mockUserId); + + expect(stateService.setPinKeyEncryptedUserKey).toHaveBeenCalledWith(null, { + userId: mockUserId, + }); + expect(stateService.setPinKeyEncryptedUserKeyEphemeral).toHaveBeenCalledWith(null, { + userId: mockUserId, + }); + }); + }); + }); +}); diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts new file mode 100644 index 00000000000..0ea9acc53da --- /dev/null +++ b/libs/common/src/platform/services/crypto.service.ts @@ -0,0 +1,1068 @@ +import * as bigInt from "big-integer"; + +import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data"; +import { BaseEncryptedOrganizationKey } from "../../admin-console/models/domain/encrypted-organization-key"; +import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response"; +import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response"; +import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response"; +import { KdfConfig } from "../../auth/models/domain/kdf-config"; +import { + KeySuffixOptions, + HashPurpose, + KdfType, + DEFAULT_ARGON2_ITERATIONS, + DEFAULT_ARGON2_MEMORY, + DEFAULT_ARGON2_PARALLELISM, + EncryptionType, +} from "../../enums"; +import { Utils } from "../../platform/misc/utils"; +import { CryptoFunctionService } from "../abstractions/crypto-function.service"; +import { CryptoService as CryptoServiceAbstraction } from "../abstractions/crypto.service"; +import { EncryptService } from "../abstractions/encrypt.service"; +import { LogService } from "../abstractions/log.service"; +import { PlatformUtilsService } from "../abstractions/platform-utils.service"; +import { StateService } from "../abstractions/state.service"; +import { sequentialize } from "../misc/sequentialize"; +import { EFFLongWordList } from "../misc/wordlist"; +import { EncArrayBuffer } from "../models/domain/enc-array-buffer"; +import { EncString } from "../models/domain/enc-string"; +import { + CipherKey, + MasterKey, + OrgKey, + PinKey, + ProviderKey, + SymmetricCryptoKey, + UserKey, +} from "../models/domain/symmetric-crypto-key"; + +export class CryptoService implements CryptoServiceAbstraction { + constructor( + protected cryptoFunctionService: CryptoFunctionService, + protected encryptService: EncryptService, + protected platformUtilService: PlatformUtilsService, + protected logService: LogService, + protected stateService: StateService + ) {} + + async setUserKey(key: UserKey, userId?: string): Promise { + if (key != null) { + await this.stateService.setEverHadUserKey(true, { userId: userId }); + } + await this.stateService.setUserKey(key, { userId: userId }); + await this.storeAdditionalKeys(key, userId); + } + + async getEverHadUserKey(userId?: string): Promise { + return await this.stateService.getEverHadUserKey({ userId: userId }); + } + + async refreshAdditionalKeys(): Promise { + const key = await this.getUserKey(); + await this.setUserKey(key); + } + + async getUserKey(userId?: string): Promise { + let userKey = await this.stateService.getUserKey({ userId: userId }); + if (userKey) { + return userKey; + } + + // If the user has set their vault timeout to 'Never', we can load the user key from storage + if (await this.hasUserKeyStored(KeySuffixOptions.Auto, userId)) { + userKey = await this.getKeyFromStorage(KeySuffixOptions.Auto, userId); + if (userKey) { + await this.setUserKey(userKey, userId); + return userKey; + } + } + } + + async isLegacyUser(masterKey?: MasterKey, userId?: string): Promise { + return await this.validateUserKey( + (masterKey ?? (await this.getMasterKey(userId))) as unknown as UserKey + ); + } + + async getUserKeyWithLegacySupport(userId?: string): Promise { + const userKey = await this.getUserKey(userId); + if (userKey) { + return userKey; + } + + // Legacy support: encryption used to be done with the master key (derived from master password). + // Users who have not migrated will have a null user key and must use the master key instead. + return (await this.getMasterKey(userId)) as unknown as UserKey; + } + + async getUserKeyFromStorage(keySuffix: KeySuffixOptions, userId?: string): Promise { + const userKey = await this.getKeyFromStorage(keySuffix, userId); + if (userKey) { + if (!(await this.validateUserKey(userKey))) { + this.logService.warning("Invalid key, throwing away stored keys"); + await this.clearAllStoredUserKeys(userId); + } + return userKey; + } + } + + async hasUserKey(): Promise { + return ( + (await this.hasUserKeyInMemory()) || (await this.hasUserKeyStored(KeySuffixOptions.Auto)) + ); + } + + async hasUserKeyInMemory(userId?: string): Promise { + return (await this.stateService.getUserKey({ userId: userId })) != null; + } + + async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: string): Promise { + return (await this.getKeyFromStorage(keySuffix, userId)) != null; + } + + async makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]> { + masterKey ||= await this.getMasterKey(); + if (masterKey == null) { + throw new Error("No Master Key found."); + } + + const newUserKey = await this.cryptoFunctionService.aesGenerateKey(512); + return this.buildProtectedSymmetricKey(masterKey, newUserKey); + } + + async clearUserKey(clearStoredKeys = true, userId?: string): Promise { + await this.stateService.setUserKey(null, { userId: userId }); + if (clearStoredKeys) { + await this.clearAllStoredUserKeys(userId); + } + } + + async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: string): Promise { + if (keySuffix === KeySuffixOptions.Auto) { + this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); + this.clearDeprecatedKeys(KeySuffixOptions.Auto, userId); + } + if (keySuffix === KeySuffixOptions.Pin) { + this.stateService.setPinKeyEncryptedUserKeyEphemeral(null, { userId: userId }); + this.clearDeprecatedKeys(KeySuffixOptions.Pin, userId); + } + } + + async setMasterKeyEncryptedUserKey(userKeyMasterKey: string, userId?: string): Promise { + await this.stateService.setMasterKeyEncryptedUserKey(userKeyMasterKey, { userId: userId }); + } + + async setMasterKey(key: MasterKey, userId?: string): Promise { + await this.stateService.setMasterKey(key, { userId: userId }); + } + + async getMasterKey(userId?: string): Promise { + let masterKey = await this.stateService.getMasterKey({ userId: userId }); + if (!masterKey) { + masterKey = (await this.stateService.getCryptoMasterKey({ userId: userId })) as MasterKey; + await this.setMasterKey(masterKey, userId); + } + return masterKey; + } + + async getOrDeriveMasterKey(password: string, userId?: string) { + let masterKey = await this.getMasterKey(userId); + return (masterKey ||= await this.makeMasterKey( + password, + await this.stateService.getEmail({ userId: userId }), + await this.stateService.getKdfType({ userId: userId }), + await this.stateService.getKdfConfig({ userId: userId }) + )); + } + + async makeMasterKey( + password: string, + email: string, + kdf: KdfType, + KdfConfig: KdfConfig + ): Promise { + return (await this.makeKey(password, email, kdf, KdfConfig)) as MasterKey; + } + + async clearMasterKey(userId?: string): Promise { + await this.stateService.setMasterKey(null, { userId: userId }); + } + + async encryptUserKeyWithMasterKey( + masterKey: MasterKey, + userKey?: UserKey + ): Promise<[UserKey, EncString]> { + userKey ||= await this.getUserKey(); + return await this.buildProtectedSymmetricKey(masterKey, userKey.key); + } + + async decryptUserKeyWithMasterKey( + masterKey: MasterKey, + userKey?: EncString, + userId?: string + ): Promise { + masterKey ||= await this.getMasterKey(userId); + if (masterKey == null) { + throw new Error("No master key found."); + } + + if (!userKey) { + let masterKeyEncryptedUserKey = await this.stateService.getMasterKeyEncryptedUserKey({ + userId: userId, + }); + + // Try one more way to get the user key if it still wasn't found. + if (masterKeyEncryptedUserKey == null) { + masterKeyEncryptedUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({ + userId: userId, + }); + } + + if (masterKeyEncryptedUserKey == null) { + throw new Error("No encrypted user key found."); + } + userKey = new EncString(masterKeyEncryptedUserKey); + } + + let decUserKey: Uint8Array; + if (userKey.encryptionType === EncryptionType.AesCbc256_B64) { + decUserKey = await this.encryptService.decryptToBytes(userKey, masterKey); + } else if (userKey.encryptionType === EncryptionType.AesCbc256_HmacSha256_B64) { + const newKey = await this.stretchKey(masterKey); + decUserKey = await this.encryptService.decryptToBytes(userKey, newKey); + } else { + throw new Error("Unsupported encryption type."); + } + if (decUserKey == null) { + return null; + } + + return new SymmetricCryptoKey(decUserKey) as UserKey; + } + + async hashMasterKey( + password: string, + key: MasterKey, + hashPurpose?: HashPurpose + ): Promise { + key ||= await this.getMasterKey(); + + if (password == null || key == null) { + throw new Error("Invalid parameters."); + } + + const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1; + const hash = await this.cryptoFunctionService.pbkdf2(key.key, password, "sha256", iterations); + return Utils.fromBufferToB64(hash); + } + + async setMasterKeyHash(keyHash: string): Promise { + await this.stateService.setKeyHash(keyHash); + } + + async getMasterKeyHash(): Promise { + return await this.stateService.getKeyHash(); + } + + async clearMasterKeyHash(userId?: string): Promise { + return await this.stateService.setKeyHash(null, { userId: userId }); + } + + async compareAndUpdateKeyHash(masterPassword: string, masterKey: MasterKey): Promise { + const storedPasswordHash = await this.getMasterKeyHash(); + if (masterPassword != null && storedPasswordHash != null) { + const localKeyHash = await this.hashMasterKey( + masterPassword, + masterKey, + HashPurpose.LocalAuthorization + ); + if (localKeyHash != null && storedPasswordHash === localKeyHash) { + return true; + } + + // TODO: remove serverKeyHash check in 1-2 releases after everyone's keyHash has been updated + const serverKeyHash = await this.hashMasterKey( + masterPassword, + masterKey, + HashPurpose.ServerAuthorization + ); + if (serverKeyHash != null && storedPasswordHash === serverKeyHash) { + await this.setMasterKeyHash(localKeyHash); + return true; + } + } + + return false; + } + + async setOrgKeys( + orgs: ProfileOrganizationResponse[] = [], + providerOrgs: ProfileProviderOrganizationResponse[] = [] + ): Promise { + const encOrgKeyData: { [orgId: string]: EncryptedOrganizationKeyData } = {}; + + orgs.forEach((org) => { + encOrgKeyData[org.id] = { + type: "organization", + key: org.key, + }; + }); + + providerOrgs.forEach((org) => { + encOrgKeyData[org.id] = { + type: "provider", + providerId: org.providerId, + key: org.key, + }; + }); + + await this.stateService.setDecryptedOrganizationKeys(null); + return await this.stateService.setEncryptedOrganizationKeys(encOrgKeyData); + } + + async getOrgKey(orgId: string): Promise { + if (orgId == null) { + return null; + } + + const orgKeys = await this.getOrgKeys(); + if (orgKeys == null || !orgKeys.has(orgId)) { + return null; + } + + return orgKeys.get(orgId); + } + + @sequentialize(() => "getOrgKeys") + async getOrgKeys(): Promise> { + const result: Map = new Map(); + const decryptedOrganizationKeys = await this.stateService.getDecryptedOrganizationKeys(); + if (decryptedOrganizationKeys != null && decryptedOrganizationKeys.size > 0) { + return decryptedOrganizationKeys as Map; + } + + const encOrgKeyData = await this.stateService.getEncryptedOrganizationKeys(); + if (encOrgKeyData == null) { + return result; + } + + let setKey = false; + + for (const orgId of Object.keys(encOrgKeyData)) { + if (result.has(orgId)) { + continue; + } + + const encOrgKey = BaseEncryptedOrganizationKey.fromData(encOrgKeyData[orgId]); + const decOrgKey = (await encOrgKey.decrypt(this)) as OrgKey; + result.set(orgId, decOrgKey); + + setKey = true; + } + + if (setKey) { + await this.stateService.setDecryptedOrganizationKeys(result); + } + + return result; + } + + async makeDataEncKey( + key: T + ): Promise<[SymmetricCryptoKey, EncString]> { + if (key == null) { + throw new Error("No key provided"); + } + + const newSymKey = await this.cryptoFunctionService.aesGenerateKey(512); + return this.buildProtectedSymmetricKey(key, newSymKey); + } + + async clearOrgKeys(memoryOnly?: boolean, userId?: string): Promise { + await this.stateService.setDecryptedOrganizationKeys(null, { userId: userId }); + if (!memoryOnly) { + await this.stateService.setEncryptedOrganizationKeys(null, { userId: userId }); + } + } + + async setProviderKeys(providers: ProfileProviderResponse[]): Promise { + const providerKeys: any = {}; + providers.forEach((provider) => { + providerKeys[provider.id] = provider.key; + }); + + await this.stateService.setDecryptedProviderKeys(null); + return await this.stateService.setEncryptedProviderKeys(providerKeys); + } + + async getProviderKey(providerId: string): Promise { + if (providerId == null) { + return null; + } + + const providerKeys = await this.getProviderKeys(); + if (providerKeys == null || !providerKeys.has(providerId)) { + return null; + } + + return providerKeys.get(providerId); + } + + @sequentialize(() => "getProviderKeys") + async getProviderKeys(): Promise> { + const providerKeys: Map = new Map(); + const decryptedProviderKeys = await this.stateService.getDecryptedProviderKeys(); + if (decryptedProviderKeys != null && decryptedProviderKeys.size > 0) { + return decryptedProviderKeys as Map; + } + + const encProviderKeys = await this.stateService.getEncryptedProviderKeys(); + if (encProviderKeys == null) { + return null; + } + + let setKey = false; + + for (const orgId in encProviderKeys) { + // eslint-disable-next-line + if (!encProviderKeys.hasOwnProperty(orgId)) { + continue; + } + + const decValue = await this.rsaDecrypt(encProviderKeys[orgId]); + providerKeys.set(orgId, new SymmetricCryptoKey(decValue) as ProviderKey); + setKey = true; + } + + if (setKey) { + await this.stateService.setDecryptedProviderKeys(providerKeys); + } + + return providerKeys; + } + + async clearProviderKeys(memoryOnly?: boolean, userId?: string): Promise { + await this.stateService.setDecryptedProviderKeys(null, { userId: userId }); + if (!memoryOnly) { + await this.stateService.setEncryptedProviderKeys(null, { userId: userId }); + } + } + + async getPublicKey(): Promise { + const inMemoryPublicKey = await this.stateService.getPublicKey(); + if (inMemoryPublicKey != null) { + return inMemoryPublicKey; + } + + const privateKey = await this.getPrivateKey(); + if (privateKey == null) { + return null; + } + + const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); + await this.stateService.setPublicKey(publicKey); + return publicKey; + } + + async makeOrgKey(): Promise<[EncString, T]> { + const shareKey = await this.cryptoFunctionService.aesGenerateKey(512); + const publicKey = await this.getPublicKey(); + const encShareKey = await this.rsaEncrypt(shareKey, publicKey); + return [encShareKey, new SymmetricCryptoKey(shareKey) as T]; + } + + async setPrivateKey(encPrivateKey: string): Promise { + if (encPrivateKey == null) { + return; + } + + await this.stateService.setDecryptedPrivateKey(null); + await this.stateService.setEncryptedPrivateKey(encPrivateKey); + } + + async getPrivateKey(): Promise { + const decryptedPrivateKey = await this.stateService.getDecryptedPrivateKey(); + if (decryptedPrivateKey != null) { + return decryptedPrivateKey; + } + + const encPrivateKey = await this.stateService.getEncryptedPrivateKey(); + if (encPrivateKey == null) { + return null; + } + + const privateKey = await this.encryptService.decryptToBytes( + new EncString(encPrivateKey), + await this.getUserKeyWithLegacySupport() + ); + await this.stateService.setDecryptedPrivateKey(privateKey); + return privateKey; + } + + async getFingerprint(fingerprintMaterial: string, publicKey?: Uint8Array): Promise { + if (publicKey == null) { + publicKey = await this.getPublicKey(); + } + if (publicKey === null) { + throw new Error("No public key available."); + } + const keyFingerprint = await this.cryptoFunctionService.hash(publicKey, "sha256"); + const userFingerprint = await this.cryptoFunctionService.hkdfExpand( + keyFingerprint, + fingerprintMaterial, + 32, + "sha256" + ); + return this.hashPhrase(userFingerprint); + } + + async makeKeyPair(key?: SymmetricCryptoKey): Promise<[string, EncString]> { + // Default to user key + key ||= await this.getUserKeyWithLegacySupport(); + + const keyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048); + const publicB64 = Utils.fromBufferToB64(keyPair[0]); + const privateEnc = await this.encryptService.encrypt(keyPair[1], key); + return [publicB64, privateEnc]; + } + + async clearKeyPair(memoryOnly?: boolean, userId?: string): Promise { + const keysToClear: Promise[] = [ + this.stateService.setDecryptedPrivateKey(null, { userId: userId }), + this.stateService.setPublicKey(null, { userId: userId }), + ]; + if (!memoryOnly) { + keysToClear.push(this.stateService.setEncryptedPrivateKey(null, { userId: userId })); + } + return Promise.all(keysToClear); + } + + async makePinKey(pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig): Promise { + const pinKey = await this.makeKey(pin, salt, kdf, kdfConfig); + return (await this.stretchKey(pinKey)) as PinKey; + } + + async clearPinKeys(userId?: string): Promise { + await this.stateService.setPinKeyEncryptedUserKey(null, { userId: userId }); + await this.stateService.setPinKeyEncryptedUserKeyEphemeral(null, { userId: userId }); + await this.stateService.setProtectedPin(null, { userId: userId }); + await this.clearDeprecatedKeys(KeySuffixOptions.Pin, userId); + } + + async decryptUserKeyWithPin( + pin: string, + salt: string, + kdf: KdfType, + kdfConfig: KdfConfig, + pinProtectedUserKey?: EncString + ): Promise { + pinProtectedUserKey ||= await this.stateService.getPinKeyEncryptedUserKey(); + pinProtectedUserKey ||= await this.stateService.getPinKeyEncryptedUserKeyEphemeral(); + if (!pinProtectedUserKey) { + throw new Error("No PIN protected key found."); + } + const pinKey = await this.makePinKey(pin, salt, kdf, kdfConfig); + const userKey = await this.encryptService.decryptToBytes(pinProtectedUserKey, pinKey); + return new SymmetricCryptoKey(userKey) as UserKey; + } + + // only for migration purposes + async decryptMasterKeyWithPin( + pin: string, + salt: string, + kdf: KdfType, + kdfConfig: KdfConfig, + pinProtectedMasterKey?: EncString + ): Promise { + if (!pinProtectedMasterKey) { + const pinProtectedMasterKeyString = await this.stateService.getEncryptedPinProtected(); + if (pinProtectedMasterKeyString == null) { + throw new Error("No PIN protected key found."); + } + pinProtectedMasterKey = new EncString(pinProtectedMasterKeyString); + } + const pinKey = await this.makePinKey(pin, salt, kdf, kdfConfig); + const masterKey = await this.encryptService.decryptToBytes(pinProtectedMasterKey, pinKey); + return new SymmetricCryptoKey(masterKey) as MasterKey; + } + + async makeSendKey(keyMaterial: Uint8Array): Promise { + const sendKey = await this.cryptoFunctionService.hkdf( + keyMaterial, + "bitwarden-send", + "send", + 64, + "sha256" + ); + return new SymmetricCryptoKey(sendKey); + } + + async makeCipherKey(): Promise { + const randomBytes = await this.cryptoFunctionService.aesGenerateKey(512); + return new SymmetricCryptoKey(randomBytes) as CipherKey; + } + + async clearKeys(userId?: string): Promise { + await this.clearUserKey(true, userId); + await this.clearMasterKeyHash(userId); + await this.clearOrgKeys(false, userId); + await this.clearProviderKeys(false, userId); + await this.clearKeyPair(false, userId); + await this.clearPinKeys(userId); + } + + async rsaEncrypt(data: Uint8Array, publicKey?: Uint8Array): Promise { + if (publicKey == null) { + publicKey = await this.getPublicKey(); + } + if (publicKey == null) { + throw new Error("Public key unavailable."); + } + + const encBytes = await this.cryptoFunctionService.rsaEncrypt(data, publicKey, "sha1"); + return new EncString(EncryptionType.Rsa2048_OaepSha1_B64, Utils.fromBufferToB64(encBytes)); + } + + async rsaDecrypt(encValue: string, privateKeyValue?: Uint8Array): Promise { + const headerPieces = encValue.split("."); + let encType: EncryptionType = null; + let encPieces: string[]; + + if (headerPieces.length === 1) { + encType = EncryptionType.Rsa2048_OaepSha256_B64; + encPieces = [headerPieces[0]]; + } else if (headerPieces.length === 2) { + try { + encType = parseInt(headerPieces[0], null); + encPieces = headerPieces[1].split("|"); + } catch (e) { + this.logService.error(e); + } + } + + switch (encType) { + case EncryptionType.Rsa2048_OaepSha256_B64: + case EncryptionType.Rsa2048_OaepSha1_B64: + case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64: // HmacSha256 types are deprecated + case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64: + break; + default: + throw new Error("encType unavailable."); + } + + if (encPieces == null || encPieces.length <= 0) { + throw new Error("encPieces unavailable."); + } + + const data = Utils.fromB64ToArray(encPieces[0]); + const privateKey = privateKeyValue ?? (await this.getPrivateKey()); + if (privateKey == null) { + throw new Error("No private key."); + } + + let alg: "sha1" | "sha256" = "sha1"; + switch (encType) { + case EncryptionType.Rsa2048_OaepSha256_B64: + case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64: + alg = "sha256"; + break; + case EncryptionType.Rsa2048_OaepSha1_B64: + case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64: + break; + default: + throw new Error("encType unavailable."); + } + + return this.cryptoFunctionService.rsaDecrypt(data, privateKey, alg); + } + + // EFForg/OpenWireless + // ref https://github.com/EFForg/OpenWireless/blob/master/app/js/diceware.js + async randomNumber(min: number, max: number): Promise { + let rval = 0; + const range = max - min + 1; + const bitsNeeded = Math.ceil(Math.log2(range)); + if (bitsNeeded > 53) { + throw new Error("We cannot generate numbers larger than 53 bits."); + } + + const bytesNeeded = Math.ceil(bitsNeeded / 8); + const mask = Math.pow(2, bitsNeeded) - 1; + // 7776 -> (2^13 = 8192) -1 == 8191 or 0x00001111 11111111 + + // Fill a byte array with N random numbers + const byteArray = new Uint8Array(await this.cryptoFunctionService.randomBytes(bytesNeeded)); + + let p = (bytesNeeded - 1) * 8; + for (let i = 0; i < bytesNeeded; i++) { + rval += byteArray[i] * Math.pow(2, p); + p -= 8; + } + + // Use & to apply the mask and reduce the number of recursive lookups + rval = rval & mask; + + if (rval >= range) { + // Integer out of acceptable range + return this.randomNumber(min, max); + } + + // Return an integer that falls within the range + return min + rval; + } + + // ---HELPERS--- + protected async validateUserKey(key: UserKey): Promise { + if (!key) { + return false; + } + + try { + const encPrivateKey = await this.stateService.getEncryptedPrivateKey(); + if (encPrivateKey == null) { + return false; + } + + const privateKey = await this.encryptService.decryptToBytes( + new EncString(encPrivateKey), + key + ); + await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); + } catch (e) { + return false; + } + + return true; + } + + /** + * Initialize all necessary crypto keys needed for a new account. + * Warning! This completely replaces any existing keys! + */ + async initAccount(): Promise<{ + userKey: UserKey; + publicKey: string; + privateKey: EncString; + }> { + const rawKey = await this.cryptoFunctionService.aesGenerateKey(512); + const userKey = new SymmetricCryptoKey(rawKey) as UserKey; + const [publicKey, privateKey] = await this.makeKeyPair(userKey); + await this.setUserKey(userKey); + await this.stateService.setEncryptedPrivateKey(privateKey.encryptedString); + + return { + userKey, + publicKey, + privateKey, + }; + } + + /** + * Generates any additional keys if needed. Additional keys are + * keys such as biometrics, auto, and pin keys. + * Useful to make sure other keys stay in sync when the user key + * has been rotated. + * @param key The user key + * @param userId The desired user + */ + protected async storeAdditionalKeys(key: UserKey, userId?: string) { + const storeAuto = await this.shouldStoreKey(KeySuffixOptions.Auto, userId); + if (storeAuto) { + await this.stateService.setUserKeyAutoUnlock(key.keyB64, { userId: userId }); + } else { + await this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); + } + await this.clearDeprecatedKeys(KeySuffixOptions.Auto, userId); + + const storePin = await this.shouldStoreKey(KeySuffixOptions.Pin, userId); + if (storePin) { + await this.storePinKey(key, userId); + // We can't always clear deprecated keys because the pin is only + // migrated once used to unlock + await this.clearDeprecatedKeys(KeySuffixOptions.Pin, userId); + } else { + await this.stateService.setPinKeyEncryptedUserKey(null, { userId: userId }); + await this.stateService.setPinKeyEncryptedUserKeyEphemeral(null, { userId: userId }); + } + } + + /** + * Stores the pin key if needed. If MP on Reset is enabled, stores the + * ephemeral version. + * @param key The user key + */ + protected async storePinKey(key: UserKey, userId?: string) { + const pin = await this.encryptService.decryptToUtf8( + new EncString(await this.stateService.getProtectedPin({ userId: userId })), + key + ); + const pinKey = await this.makePinKey( + pin, + await this.stateService.getEmail({ userId: userId }), + await this.stateService.getKdfType({ userId: userId }), + await this.stateService.getKdfConfig({ userId: userId }) + ); + const encPin = await this.encryptService.encrypt(key.key, pinKey); + + if ((await this.stateService.getPinKeyEncryptedUserKey({ userId: userId })) != null) { + await this.stateService.setPinKeyEncryptedUserKey(encPin, { userId: userId }); + } else { + await this.stateService.setPinKeyEncryptedUserKeyEphemeral(encPin, { userId: userId }); + } + } + + protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: string) { + let shouldStoreKey = false; + switch (keySuffix) { + case KeySuffixOptions.Auto: { + const vaultTimeout = await this.stateService.getVaultTimeout({ userId: userId }); + shouldStoreKey = vaultTimeout == null; + break; + } + case KeySuffixOptions.Pin: { + const protectedPin = await this.stateService.getProtectedPin({ userId: userId }); + shouldStoreKey = !!protectedPin; + break; + } + } + return shouldStoreKey; + } + + protected async getKeyFromStorage( + keySuffix: KeySuffixOptions, + userId?: string + ): Promise { + if (keySuffix === KeySuffixOptions.Auto) { + const userKey = await this.stateService.getUserKeyAutoUnlock({ userId: userId }); + if (userKey) { + return new SymmetricCryptoKey(Utils.fromB64ToArray(userKey)) as UserKey; + } + } + return null; + } + + protected async clearAllStoredUserKeys(userId?: string): Promise { + await this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); + await this.stateService.setPinKeyEncryptedUserKeyEphemeral(null, { userId: userId }); + } + + private async stretchKey(key: SymmetricCryptoKey): Promise { + const newKey = new Uint8Array(64); + const encKey = await this.cryptoFunctionService.hkdfExpand(key.key, "enc", 32, "sha256"); + const macKey = await this.cryptoFunctionService.hkdfExpand(key.key, "mac", 32, "sha256"); + newKey.set(new Uint8Array(encKey)); + newKey.set(new Uint8Array(macKey), 32); + return new SymmetricCryptoKey(newKey); + } + + private async hashPhrase(hash: Uint8Array, minimumEntropy = 64) { + const entropyPerWord = Math.log(EFFLongWordList.length) / Math.log(2); + let numWords = Math.ceil(minimumEntropy / entropyPerWord); + + const hashArr = Array.from(new Uint8Array(hash)); + const entropyAvailable = hashArr.length * 4; + if (numWords * entropyPerWord > entropyAvailable) { + throw new Error("Output entropy of hash function is too small"); + } + + const phrase: string[] = []; + let hashNumber = bigInt.fromArray(hashArr, 256); + while (numWords--) { + const remainder = hashNumber.mod(EFFLongWordList.length); + hashNumber = hashNumber.divide(EFFLongWordList.length); + phrase.push(EFFLongWordList[remainder as any]); + } + return phrase; + } + + private async buildProtectedSymmetricKey( + encryptionKey: SymmetricCryptoKey, + newSymKey: Uint8Array + ): Promise<[T, EncString]> { + let protectedSymKey: EncString = null; + if (encryptionKey.key.byteLength === 32) { + const stretchedEncryptionKey = await this.stretchKey(encryptionKey); + protectedSymKey = await this.encryptService.encrypt(newSymKey, stretchedEncryptionKey); + } else if (encryptionKey.key.byteLength === 64) { + protectedSymKey = await this.encryptService.encrypt(newSymKey, encryptionKey); + } else { + throw new Error("Invalid key size."); + } + return [new SymmetricCryptoKey(newSymKey) as T, protectedSymKey]; + } + + private async makeKey( + password: string, + salt: string, + kdf: KdfType, + kdfConfig: KdfConfig + ): Promise { + let key: Uint8Array = null; + if (kdf == null || kdf === KdfType.PBKDF2_SHA256) { + if (kdfConfig.iterations == null) { + kdfConfig.iterations = 5000; + } else if (kdfConfig.iterations < 5000) { + throw new Error("PBKDF2 iteration minimum is 5000."); + } + key = await this.cryptoFunctionService.pbkdf2(password, salt, "sha256", kdfConfig.iterations); + } else if (kdf == KdfType.Argon2id) { + if (kdfConfig.iterations == null) { + kdfConfig.iterations = DEFAULT_ARGON2_ITERATIONS; + } else if (kdfConfig.iterations < 2) { + throw new Error("Argon2 iteration minimum is 2."); + } + + if (kdfConfig.memory == null) { + kdfConfig.memory = DEFAULT_ARGON2_MEMORY; + } else if (kdfConfig.memory < 16) { + throw new Error("Argon2 memory minimum is 16 MB"); + } else if (kdfConfig.memory > 1024) { + throw new Error("Argon2 memory maximum is 1024 MB"); + } + + if (kdfConfig.parallelism == null) { + kdfConfig.parallelism = DEFAULT_ARGON2_PARALLELISM; + } else if (kdfConfig.parallelism < 1) { + throw new Error("Argon2 parallelism minimum is 1."); + } + + const saltHash = await this.cryptoFunctionService.hash(salt, "sha256"); + key = await this.cryptoFunctionService.argon2( + password, + saltHash, + kdfConfig.iterations, + kdfConfig.memory * 1024, // convert to KiB from MiB + kdfConfig.parallelism + ); + } else { + throw new Error("Unknown Kdf."); + } + return new SymmetricCryptoKey(key); + } + + // --LEGACY METHODS-- + // We previously used the master key for additional keys, but now we use the user key. + // These methods support migrating the old keys to the new ones. + // TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3475) + + async clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: string) { + if (keySuffix === KeySuffixOptions.Auto) { + await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId }); + } else if (keySuffix === KeySuffixOptions.Pin) { + await this.stateService.setEncryptedPinProtected(null, { userId: userId }); + await this.stateService.setDecryptedPinProtected(null, { userId: userId }); + } + } + + async migrateAutoKeyIfNeeded(userId?: string) { + const oldAutoKey = await this.stateService.getCryptoMasterKeyAuto({ userId: userId }); + if (!oldAutoKey) { + return; + } + // Decrypt + const masterKey = new SymmetricCryptoKey(Utils.fromB64ToArray(oldAutoKey)) as MasterKey; + if (await this.isLegacyUser(masterKey, userId)) { + // Legacy users don't have a user key, so no need to migrate. + // Instead, set the master key for additional isLegacyUser checks that will log the user out. + await this.setMasterKey(masterKey, userId); + return; + } + const encryptedUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({ + userId: userId, + }); + const userKey = await this.decryptUserKeyWithMasterKey( + masterKey, + new EncString(encryptedUserKey), + userId + ); + // Migrate + await this.stateService.setUserKeyAutoUnlock(userKey.keyB64, { userId: userId }); + await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId }); + // Set encrypted user key in case user immediately locks without syncing + await this.setMasterKeyEncryptedUserKey(encryptedUserKey); + } + + async decryptAndMigrateOldPinKey( + masterPasswordOnRestart: boolean, + pin: string, + email: string, + kdf: KdfType, + kdfConfig: KdfConfig, + oldPinKey: EncString + ): Promise { + // Decrypt + const masterKey = await this.decryptMasterKeyWithPin(pin, email, kdf, kdfConfig, oldPinKey); + const encUserKey = await this.stateService.getEncryptedCryptoSymmetricKey(); + const userKey = await this.decryptUserKeyWithMasterKey(masterKey, new EncString(encUserKey)); + // Migrate + const pinKey = await this.makePinKey(pin, email, kdf, kdfConfig); + const pinProtectedKey = await this.encryptService.encrypt(userKey.key, pinKey); + if (masterPasswordOnRestart) { + await this.stateService.setDecryptedPinProtected(null); + await this.stateService.setPinKeyEncryptedUserKeyEphemeral(pinProtectedKey); + } else { + await this.stateService.setEncryptedPinProtected(null); + await this.stateService.setPinKeyEncryptedUserKey(pinProtectedKey); + // We previously only set the protected pin if MP on Restart was enabled + // now we set it regardless + const encPin = await this.encryptService.encrypt(pin, userKey); + await this.stateService.setProtectedPin(encPin.encryptedString); + } + // This also clears the old Biometrics key since the new Biometrics key will + // be created when the user key is set. + await this.stateService.setCryptoMasterKeyBiometric(null); + return userKey; + } + + // --DEPRECATED METHODS-- + + /** + * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) + * and then call encryptService.encrypt + */ + async encrypt(plainValue: string | Uint8Array, key?: SymmetricCryptoKey): Promise { + key ||= await this.getUserKeyWithLegacySupport(); + return await this.encryptService.encrypt(plainValue, key); + } + + /** + * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) + * and then call encryptService.encryptToBytes + */ + async encryptToBytes(plainValue: Uint8Array, key?: SymmetricCryptoKey): Promise { + key ||= await this.getUserKeyWithLegacySupport(); + return this.encryptService.encryptToBytes(plainValue, key); + } + + /** + * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) + * and then call encryptService.decryptToBytes + */ + async decryptToBytes(encString: EncString, key?: SymmetricCryptoKey): Promise { + key ||= await this.getUserKeyWithLegacySupport(); + return this.encryptService.decryptToBytes(encString, key); + } + + /** + * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) + * and then call encryptService.decryptToUtf8 + */ + async decryptToUtf8(encString: EncString, key?: SymmetricCryptoKey): Promise { + key ||= await this.getUserKeyWithLegacySupport(); + return await this.encryptService.decryptToUtf8(encString, key); + } + + /** + * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) + * and then call encryptService.decryptToBytes + */ + async decryptFromBytes(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise { + if (encBuffer == null) { + throw new Error("No buffer provided for decryption."); + } + + key ||= await this.getUserKeyWithLegacySupport(); + + return this.encryptService.decryptToBytes(encBuffer, key); + } +} diff --git a/libs/common/src/services/cryptography/encrypt.service.implementation.ts b/libs/common/src/platform/services/cryptography/encrypt.service.implementation.ts similarity index 85% rename from libs/common/src/services/cryptography/encrypt.service.implementation.ts rename to libs/common/src/platform/services/cryptography/encrypt.service.implementation.ts index a5a4e03658d..2df24bb33fd 100644 --- a/libs/common/src/services/cryptography/encrypt.service.implementation.ts +++ b/libs/common/src/platform/services/cryptography/encrypt.service.implementation.ts @@ -1,11 +1,11 @@ -import { CryptoFunctionService } from "../../abstractions/cryptoFunction.service"; +import { EncryptionType } from "../../../enums"; +import { Utils } from "../../../platform/misc/utils"; +import { CryptoFunctionService } from "../../abstractions/crypto-function.service"; import { EncryptService } from "../../abstractions/encrypt.service"; import { LogService } from "../../abstractions/log.service"; -import { EncryptionType } from "../../enums"; -import { IEncrypted } from "../../interfaces/IEncrypted"; import { Decryptable } from "../../interfaces/decryptable.interface"; +import { Encrypted } from "../../interfaces/encrypted"; import { InitializerMetadata } from "../../interfaces/initializer-metadata.interface"; -import { Utils } from "../../misc/utils"; import { EncArrayBuffer } from "../../models/domain/enc-array-buffer"; import { EncString } from "../../models/domain/enc-string"; import { EncryptedObject } from "../../models/domain/encrypted-object"; @@ -18,7 +18,7 @@ export class EncryptServiceImplementation implements EncryptService { protected logMacFailures: boolean ) {} - async encrypt(plainValue: string | ArrayBuffer, key: SymmetricCryptoKey): Promise { + async encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise { if (key == null) { throw new Error("No encryption key provided."); } @@ -27,9 +27,9 @@ export class EncryptServiceImplementation implements EncryptService { return Promise.resolve(null); } - let plainBuf: ArrayBuffer; + let plainBuf: Uint8Array; if (typeof plainValue === "string") { - plainBuf = Utils.fromUtf8ToArray(plainValue).buffer; + plainBuf = Utils.fromUtf8ToArray(plainValue); } else { plainBuf = plainValue; } @@ -41,7 +41,7 @@ export class EncryptServiceImplementation implements EncryptService { return new EncString(encObj.key.encType, data, iv, mac); } - async encryptToBytes(plainValue: ArrayBuffer, key: SymmetricCryptoKey): Promise { + async encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise { if (key == null) { throw new Error("No encryption key provided."); } @@ -60,7 +60,7 @@ export class EncryptServiceImplementation implements EncryptService { } encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength + macLen); - return new EncArrayBuffer(encBytes.buffer); + return new EncArrayBuffer(encBytes); } async decryptToUtf8(encString: EncString, key: SymmetricCryptoKey): Promise { @@ -99,10 +99,10 @@ export class EncryptServiceImplementation implements EncryptService { } } - return await this.cryptoFunctionService.aesDecryptFast(fastParams); + return await this.cryptoFunctionService.aesDecryptFast(fastParams, "cbc"); } - async decryptToBytes(encThing: IEncrypted, key: SymmetricCryptoKey): Promise { + async decryptToBytes(encThing: Encrypted, key: SymmetricCryptoKey): Promise { if (key == null) { throw new Error("No encryption key provided."); } @@ -125,11 +125,7 @@ export class EncryptServiceImplementation implements EncryptService { const macData = new Uint8Array(encThing.ivBytes.byteLength + encThing.dataBytes.byteLength); macData.set(new Uint8Array(encThing.ivBytes), 0); macData.set(new Uint8Array(encThing.dataBytes), encThing.ivBytes.byteLength); - const computedMac = await this.cryptoFunctionService.hmac( - macData.buffer, - key.macKey, - "sha256" - ); + const computedMac = await this.cryptoFunctionService.hmac(macData, key.macKey, "sha256"); if (computedMac === null) { return null; } @@ -144,7 +140,8 @@ export class EncryptServiceImplementation implements EncryptService { const result = await this.cryptoFunctionService.aesDecrypt( encThing.dataBytes, encThing.ivBytes, - key.encKey + key.encKey, + "cbc" ); return result ?? null; @@ -161,7 +158,7 @@ export class EncryptServiceImplementation implements EncryptService { return await Promise.all(items.map((item) => item.decrypt(key))); } - private async aesEncrypt(data: ArrayBuffer, key: SymmetricCryptoKey): Promise { + private async aesEncrypt(data: Uint8Array, key: SymmetricCryptoKey): Promise { const obj = new EncryptedObject(); obj.key = key; obj.iv = await this.cryptoFunctionService.randomBytes(16); @@ -171,7 +168,7 @@ export class EncryptServiceImplementation implements EncryptService { const macData = new Uint8Array(obj.iv.byteLength + obj.data.byteLength); macData.set(new Uint8Array(obj.iv), 0); macData.set(new Uint8Array(obj.data), obj.iv.byteLength); - obj.mac = await this.cryptoFunctionService.hmac(macData.buffer, obj.key.macKey, "sha256"); + obj.mac = await this.cryptoFunctionService.hmac(macData, obj.key.macKey, "sha256"); } return obj; @@ -187,7 +184,7 @@ export class EncryptServiceImplementation implements EncryptService { * Transform into new key for the old encrypt-then-mac scheme if required, otherwise return the current key unchanged * @param encThing The encrypted object (e.g. encString or encArrayBuffer) that you want to decrypt */ - resolveLegacyKey(key: SymmetricCryptoKey, encThing: IEncrypted): SymmetricCryptoKey { + resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey { if ( encThing.encryptionType === EncryptionType.AesCbc128_HmacSha256_B64 && key.encType === EncryptionType.AesCbc256_B64 diff --git a/libs/common/src/services/cryptography/encrypt.worker.ts b/libs/common/src/platform/services/cryptography/encrypt.worker.ts similarity index 87% rename from libs/common/src/services/cryptography/encrypt.worker.ts rename to libs/common/src/platform/services/cryptography/encrypt.worker.ts index 0ee2914ad4b..047b7a9556a 100644 --- a/libs/common/src/services/cryptography/encrypt.worker.ts +++ b/libs/common/src/platform/services/cryptography/encrypt.worker.ts @@ -2,9 +2,9 @@ import { Jsonify } from "type-fest"; import { Decryptable } from "../../interfaces/decryptable.interface"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { ConsoleLogService } from "../../services/consoleLog.service"; -import { ContainerService } from "../../services/container.service"; -import { WebCryptoFunctionService } from "../../services/webCryptoFunction.service"; +import { ConsoleLogService } from "../console-log.service"; +import { ContainerService } from "../container.service"; +import { WebCryptoFunctionService } from "../web-crypto-function.service"; import { EncryptServiceImplementation } from "./encrypt.service.implementation"; import { getClassInitializer } from "./get-class-initializer"; diff --git a/libs/common/src/services/cryptography/get-class-initializer.ts b/libs/common/src/platform/services/cryptography/get-class-initializer.ts similarity index 83% rename from libs/common/src/services/cryptography/get-class-initializer.ts rename to libs/common/src/platform/services/cryptography/get-class-initializer.ts index 1bd710eb3df..3693e509ce5 100644 --- a/libs/common/src/services/cryptography/get-class-initializer.ts +++ b/libs/common/src/platform/services/cryptography/get-class-initializer.ts @@ -1,8 +1,8 @@ import { Jsonify } from "type-fest"; +import { Cipher } from "../../../vault/models/domain/cipher"; +import { CipherView } from "../../../vault/models/view/cipher.view"; import { InitializerMetadata } from "../../interfaces/initializer-metadata.interface"; -import { Cipher } from "../../vault/models/domain/cipher"; -import { CipherView } from "../../vault/models/view/cipher.view"; import { InitializerKey } from "./initializer-key"; diff --git a/libs/common/src/services/cryptography/initializer-key.ts b/libs/common/src/platform/services/cryptography/initializer-key.ts similarity index 100% rename from libs/common/src/services/cryptography/initializer-key.ts rename to libs/common/src/platform/services/cryptography/initializer-key.ts diff --git a/libs/common/src/services/cryptography/multithread-encrypt.service.implementation.ts b/libs/common/src/platform/services/cryptography/multithread-encrypt.service.implementation.ts similarity index 94% rename from libs/common/src/services/cryptography/multithread-encrypt.service.implementation.ts rename to libs/common/src/platform/services/cryptography/multithread-encrypt.service.implementation.ts index 4e7fa2ca48d..9db4709534e 100644 --- a/libs/common/src/services/cryptography/multithread-encrypt.service.implementation.ts +++ b/libs/common/src/platform/services/cryptography/multithread-encrypt.service.implementation.ts @@ -1,9 +1,9 @@ import { defaultIfEmpty, filter, firstValueFrom, fromEvent, map, Subject, takeUntil } from "rxjs"; import { Jsonify } from "type-fest"; +import { Utils } from "../../../platform/misc/utils"; import { Decryptable } from "../../interfaces/decryptable.interface"; import { InitializerMetadata } from "../../interfaces/initializer-metadata.interface"; -import { Utils } from "../../misc/utils"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { EncryptServiceImplementation } from "./encrypt.service.implementation"; @@ -35,7 +35,7 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple this.worker ??= new Worker( new URL( /* webpackChunkName: 'encrypt-worker' */ - "@bitwarden/common/services/cryptography/encrypt.worker.ts", + "@bitwarden/common/platform/services/cryptography/encrypt.worker.ts", import.meta.url ) ); diff --git a/libs/common/src/services/encrypt.service.spec.ts b/libs/common/src/platform/services/encrypt.service.spec.ts similarity index 90% rename from libs/common/src/services/encrypt.service.spec.ts rename to libs/common/src/platform/services/encrypt.service.spec.ts index 8df3c170bf6..aa3039a4708 100644 --- a/libs/common/src/services/encrypt.service.spec.ts +++ b/libs/common/src/platform/services/encrypt.service.spec.ts @@ -1,15 +1,14 @@ import { mockReset, mock } from "jest-mock-extended"; -import { makeStaticByteArray } from "../../spec"; -import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; +import { makeStaticByteArray } from "../../../spec"; +import { EncryptionType } from "../../enums"; +import { CsprngArray } from "../../types/csprng"; +import { CryptoFunctionService } from "../abstractions/crypto-function.service"; import { LogService } from "../abstractions/log.service"; -import { EncryptionType } from "../enums"; import { EncArrayBuffer } from "../models/domain/enc-array-buffer"; import { EncString } from "../models/domain/enc-string"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; -import { CsprngArray } from "../types/csprng"; - -import { EncryptServiceImplementation } from "./cryptography/encrypt.service.implementation"; +import { EncryptServiceImplementation } from "../services/cryptography/encrypt.service.implementation"; describe("EncryptService", () => { const cryptoFunctionService = mock(); @@ -38,10 +37,8 @@ describe("EncryptService", () => { describe("encrypts data", () => { beforeEach(() => { - cryptoFunctionService.randomBytes - .calledWith(16) - .mockResolvedValueOnce(iv.buffer as CsprngArray); - cryptoFunctionService.aesEncrypt.mockResolvedValue(encryptedData.buffer); + cryptoFunctionService.randomBytes.calledWith(16).mockResolvedValueOnce(iv as CsprngArray); + cryptoFunctionService.aesEncrypt.mockResolvedValue(encryptedData); }); it("using a key which supports mac", async () => { @@ -51,7 +48,7 @@ describe("EncryptService", () => { key.macKey = makeStaticByteArray(16, 20); - cryptoFunctionService.hmac.mockResolvedValue(mac.buffer); + cryptoFunctionService.hmac.mockResolvedValue(mac); const actual = await encryptService.encryptToBytes(plainValue, key); @@ -87,7 +84,7 @@ describe("EncryptService", () => { describe("decryptToBytes", () => { const encType = EncryptionType.AesCbc256_HmacSha256_B64; const key = new SymmetricCryptoKey(makeStaticByteArray(64, 100), encType); - const computedMac = new Uint8Array(1).buffer; + const computedMac = new Uint8Array(1); const encBuffer = new EncArrayBuffer(makeStaticByteArray(60, encType)); beforeEach(() => { @@ -107,9 +104,9 @@ describe("EncryptService", () => { }); it("decrypts data with provided key", async () => { - const decryptedBytes = makeStaticByteArray(10, 200).buffer; + const decryptedBytes = makeStaticByteArray(10, 200); - cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(1).buffer); + cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(1)); cryptoFunctionService.compare.mockResolvedValue(true); cryptoFunctionService.aesDecrypt.mockResolvedValueOnce(decryptedBytes); @@ -118,7 +115,8 @@ describe("EncryptService", () => { expect(cryptoFunctionService.aesDecrypt).toBeCalledWith( expect.toEqualBuffer(encBuffer.dataBytes), expect.toEqualBuffer(encBuffer.ivBytes), - expect.toEqualBuffer(key.encKey) + expect.toEqualBuffer(key.encKey), + "cbc" ); expect(actual).toEqualBuffer(decryptedBytes); diff --git a/libs/common/src/platform/services/environment.service.ts b/libs/common/src/platform/services/environment.service.ts new file mode 100644 index 00000000000..d85341a68c2 --- /dev/null +++ b/libs/common/src/platform/services/environment.service.ts @@ -0,0 +1,342 @@ +import { concatMap, Observable, ReplaySubject } from "rxjs"; + +import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; +import { + EnvironmentService as EnvironmentServiceAbstraction, + Region, + Urls, +} from "../abstractions/environment.service"; +import { StateService } from "../abstractions/state.service"; + +export class EnvironmentService implements EnvironmentServiceAbstraction { + private readonly urlsSubject = new ReplaySubject(1); + urls: Observable = this.urlsSubject.asObservable(); + selectedRegion?: Region; + initialized = false; + + protected baseUrl: string; + protected webVaultUrl: string; + protected apiUrl: string; + protected identityUrl: string; + protected iconsUrl: string; + protected notificationsUrl: string; + protected eventsUrl: string; + private keyConnectorUrl: string; + private scimUrl: string = null; + private cloudWebVaultUrl: string; + + readonly usUrls: Urls = { + base: null, + api: "https://api.bitwarden.com", + identity: "https://identity.bitwarden.com", + icons: "https://icons.bitwarden.net", + webVault: "https://vault.bitwarden.com", + notifications: "https://notifications.bitwarden.com", + events: "https://events.bitwarden.com", + scim: "https://scim.bitwarden.com", + }; + + readonly euUrls: Urls = { + base: null, + api: "https://api.bitwarden.eu", + identity: "https://identity.bitwarden.eu", + icons: "https://icons.bitwarden.eu", + webVault: "https://vault.bitwarden.eu", + notifications: "https://notifications.bitwarden.eu", + events: "https://events.bitwarden.eu", + scim: "https://scim.bitwarden.eu", + }; + + constructor(private stateService: StateService) { + this.stateService.activeAccount$ + .pipe( + concatMap(async () => { + if (!this.initialized) { + return; + } + await this.setUrlsFromStorage(); + }) + ) + .subscribe(); + } + + hasBaseUrl() { + return this.baseUrl != null; + } + + getNotificationsUrl() { + if (this.notificationsUrl != null) { + return this.notificationsUrl; + } + + if (this.baseUrl != null) { + return this.baseUrl + "/notifications"; + } + + return "https://notifications.bitwarden.com"; + } + + getWebVaultUrl() { + if (this.webVaultUrl != null) { + return this.webVaultUrl; + } + + if (this.baseUrl) { + return this.baseUrl; + } + return "https://vault.bitwarden.com"; + } + + getCloudWebVaultUrl() { + if (this.cloudWebVaultUrl != null) { + return this.cloudWebVaultUrl; + } + + return this.usUrls.webVault; + } + + setCloudWebVaultUrl(region: Region) { + switch (region) { + case Region.EU: + this.cloudWebVaultUrl = this.euUrls.webVault; + break; + case Region.US: + default: + this.cloudWebVaultUrl = this.usUrls.webVault; + break; + } + } + + getSendUrl() { + return this.getWebVaultUrl() === "https://vault.bitwarden.com" + ? "https://send.bitwarden.com/#" + : this.getWebVaultUrl() + "/#/send/"; + } + + getIconsUrl() { + if (this.iconsUrl != null) { + return this.iconsUrl; + } + + if (this.baseUrl) { + return this.baseUrl + "/icons"; + } + + return "https://icons.bitwarden.net"; + } + + getApiUrl() { + if (this.apiUrl != null) { + return this.apiUrl; + } + + if (this.baseUrl) { + return this.baseUrl + "/api"; + } + + return "https://api.bitwarden.com"; + } + + getIdentityUrl() { + if (this.identityUrl != null) { + return this.identityUrl; + } + + if (this.baseUrl) { + return this.baseUrl + "/identity"; + } + + return "https://identity.bitwarden.com"; + } + + getEventsUrl() { + if (this.eventsUrl != null) { + return this.eventsUrl; + } + + if (this.baseUrl) { + return this.baseUrl + "/events"; + } + + return "https://events.bitwarden.com"; + } + + getKeyConnectorUrl() { + return this.keyConnectorUrl; + } + + getScimUrl() { + if (this.scimUrl != null) { + return this.scimUrl + "/v2"; + } + + return this.getWebVaultUrl() === "https://vault.bitwarden.com" + ? "https://scim.bitwarden.com/v2" + : this.getWebVaultUrl() + "/scim/v2"; + } + + async setUrlsFromStorage(): Promise { + const region = await this.stateService.getRegion(); + const savedUrls = await this.stateService.getEnvironmentUrls(); + const envUrls = new EnvironmentUrls(); + + // In release `2023.5.0`, we set the `base` property of the environment URLs to the US web vault URL when a user clicked the "US" region. + // This check will detect these cases and convert them to the proper region instead. + // We are detecting this by checking for the presence of the web vault URL in the `base` and the absence of the `notifications` property. + // This is because the `notifications` will not be `null` in the web vault, and we don't want to migrate the URLs in that case. + if (savedUrls.base === "https://vault.bitwarden.com" && savedUrls.notifications == null) { + await this.setRegion(Region.US); + return; + } + + switch (region) { + case Region.EU: + await this.setRegion(Region.EU); + return; + case Region.US: + await this.setRegion(Region.US); + return; + case Region.SelfHosted: + case null: + default: + this.baseUrl = envUrls.base = savedUrls.base; + this.webVaultUrl = savedUrls.webVault; + this.apiUrl = envUrls.api = savedUrls.api; + this.identityUrl = envUrls.identity = savedUrls.identity; + this.iconsUrl = savedUrls.icons; + this.notificationsUrl = savedUrls.notifications; + this.eventsUrl = envUrls.events = savedUrls.events; + this.keyConnectorUrl = savedUrls.keyConnector; + await this.setRegion(Region.SelfHosted); + // scimUrl is not saved to storage + this.urlsSubject.next(); + break; + } + } + + async setUrls(urls: Urls): Promise { + urls.base = this.formatUrl(urls.base); + urls.webVault = this.formatUrl(urls.webVault); + urls.api = this.formatUrl(urls.api); + urls.identity = this.formatUrl(urls.identity); + urls.icons = this.formatUrl(urls.icons); + urls.notifications = this.formatUrl(urls.notifications); + urls.events = this.formatUrl(urls.events); + urls.keyConnector = this.formatUrl(urls.keyConnector); + + // scimUrl cannot be cleared + urls.scim = this.formatUrl(urls.scim) ?? this.scimUrl; + + await this.stateService.setEnvironmentUrls({ + base: urls.base, + api: urls.api, + identity: urls.identity, + webVault: urls.webVault, + icons: urls.icons, + notifications: urls.notifications, + events: urls.events, + keyConnector: urls.keyConnector, + // scimUrl is not saved to storage + }); + + this.baseUrl = urls.base; + this.webVaultUrl = urls.webVault; + this.apiUrl = urls.api; + this.identityUrl = urls.identity; + this.iconsUrl = urls.icons; + this.notificationsUrl = urls.notifications; + this.eventsUrl = urls.events; + this.keyConnectorUrl = urls.keyConnector; + this.scimUrl = urls.scim; + + await this.setRegion(Region.SelfHosted); + + this.urlsSubject.next(); + + return urls; + } + + getUrls() { + return { + base: this.baseUrl, + webVault: this.webVaultUrl, + cloudWebVault: this.cloudWebVaultUrl, + api: this.apiUrl, + identity: this.identityUrl, + icons: this.iconsUrl, + notifications: this.notificationsUrl, + events: this.eventsUrl, + keyConnector: this.keyConnectorUrl, + scim: this.scimUrl, + }; + } + + isEmpty(): boolean { + return ( + this.baseUrl == null && + this.webVaultUrl == null && + this.apiUrl == null && + this.identityUrl == null && + this.iconsUrl == null && + this.notificationsUrl == null && + this.eventsUrl == null + ); + } + + async setRegion(region: Region) { + this.selectedRegion = region; + await this.stateService.setRegion(region); + + if (region === Region.SelfHosted) { + // If user saves a self-hosted region with empty fields, default to US + if (this.isEmpty()) { + await this.setRegion(Region.US); + } + } else { + // If we are setting the region to EU or US, clear the self-hosted URLs + await this.stateService.setEnvironmentUrls(new EnvironmentUrls()); + if (region === Region.EU) { + this.setUrlsInternal(this.euUrls); + } else if (region === Region.US) { + this.setUrlsInternal(this.usUrls); + } + } + } + + private setUrlsInternal(urls: Urls) { + this.baseUrl = this.formatUrl(urls.base); + this.webVaultUrl = this.formatUrl(urls.webVault); + this.apiUrl = this.formatUrl(urls.api); + this.identityUrl = this.formatUrl(urls.identity); + this.iconsUrl = this.formatUrl(urls.icons); + this.notificationsUrl = this.formatUrl(urls.notifications); + this.eventsUrl = this.formatUrl(urls.events); + this.keyConnectorUrl = this.formatUrl(urls.keyConnector); + + // scimUrl cannot be cleared + this.scimUrl = this.formatUrl(urls.scim) ?? this.scimUrl; + this.urlsSubject.next(); + } + + private formatUrl(url: string): string { + if (url == null || url === "") { + return null; + } + + url = url.replace(/\/+$/g, ""); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://" + url; + } + + return url.trim(); + } + + isCloud(): boolean { + return [ + "https://api.bitwarden.com", + "https://vault.bitwarden.com/api", + "https://api.bitwarden.eu", + "https://vault.bitwarden.eu/api", + ].includes(this.getApiUrl()); + } +} diff --git a/libs/common/src/services/azureFileUpload.service.ts b/libs/common/src/platform/services/file-upload/azure-file-upload.service.ts similarity index 97% rename from libs/common/src/services/azureFileUpload.service.ts rename to libs/common/src/platform/services/file-upload/azure-file-upload.service.ts index 95a6bd88093..b0a505b89f5 100644 --- a/libs/common/src/services/azureFileUpload.service.ts +++ b/libs/common/src/platform/services/file-upload/azure-file-upload.service.ts @@ -1,6 +1,6 @@ -import { LogService } from "../abstractions/log.service"; -import { Utils } from "../misc/utils"; -import { EncArrayBuffer } from "../models/domain/enc-array-buffer"; +import { LogService } from "../../abstractions/log.service"; +import { Utils } from "../../misc/utils"; +import { EncArrayBuffer } from "../../models/domain/enc-array-buffer"; const MAX_SINGLE_BLOB_UPLOAD_SIZE = 256 * 1024 * 1024; // 256 MiB const MAX_BLOCKS_PER_BLOB = 50000; diff --git a/libs/common/src/services/bitwardenFileUpload.service.ts b/libs/common/src/platform/services/file-upload/bitwarden-file-upload.service.ts similarity index 86% rename from libs/common/src/services/bitwardenFileUpload.service.ts rename to libs/common/src/platform/services/file-upload/bitwarden-file-upload.service.ts index ee6f97aae3d..ab2cb490af9 100644 --- a/libs/common/src/services/bitwardenFileUpload.service.ts +++ b/libs/common/src/platform/services/file-upload/bitwarden-file-upload.service.ts @@ -1,5 +1,5 @@ -import { Utils } from "../misc/utils"; -import { EncArrayBuffer } from "../models/domain/enc-array-buffer"; +import { Utils } from "../../misc/utils"; +import { EncArrayBuffer } from "../../models/domain/enc-array-buffer"; export class BitwardenFileUploadService { async upload( diff --git a/libs/common/src/services/file-upload/file-upload.service.ts b/libs/common/src/platform/services/file-upload/file-upload.service.ts similarity index 89% rename from libs/common/src/services/file-upload/file-upload.service.ts rename to libs/common/src/platform/services/file-upload/file-upload.service.ts index c7188181258..750259da980 100644 --- a/libs/common/src/services/file-upload/file-upload.service.ts +++ b/libs/common/src/platform/services/file-upload/file-upload.service.ts @@ -1,13 +1,14 @@ +import { FileUploadType } from "../../../enums"; import { FileUploadApiMethods, FileUploadService as FileUploadServiceAbstraction, } from "../../abstractions/file-upload/file-upload.service"; import { LogService } from "../../abstractions/log.service"; -import { FileUploadType } from "../../enums"; import { EncArrayBuffer } from "../../models/domain/enc-array-buffer"; import { EncString } from "../../models/domain/enc-string"; -import { AzureFileUploadService } from "../azureFileUpload.service"; -import { BitwardenFileUploadService } from "../bitwardenFileUpload.service"; + +import { AzureFileUploadService } from "./azure-file-upload.service"; +import { BitwardenFileUploadService } from "./bitwarden-file-upload.service"; export class FileUploadService implements FileUploadServiceAbstraction { private azureFileUploadService: AzureFileUploadService; diff --git a/libs/common/src/services/i18n.service.ts b/libs/common/src/platform/services/i18n.service.ts similarity index 100% rename from libs/common/src/services/i18n.service.ts rename to libs/common/src/platform/services/i18n.service.ts diff --git a/libs/common/src/services/memoryStorage.service.ts b/libs/common/src/platform/services/memory-storage.service.ts similarity index 100% rename from libs/common/src/services/memoryStorage.service.ts rename to libs/common/src/platform/services/memory-storage.service.ts diff --git a/libs/common/src/services/noopMessaging.service.ts b/libs/common/src/platform/services/noop-messaging.service.ts similarity index 100% rename from libs/common/src/services/noopMessaging.service.ts rename to libs/common/src/platform/services/noop-messaging.service.ts diff --git a/libs/common/src/services/state.service.ts b/libs/common/src/platform/services/state.service.ts similarity index 85% rename from libs/common/src/services/state.service.ts rename to libs/common/src/platform/services/state.service.ts index dc32c688e30..d0983448d62 100644 --- a/libs/common/src/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -1,33 +1,52 @@ import { BehaviorSubject, concatMap } from "rxjs"; import { Jsonify, JsonValue } from "type-fest"; +import { EncryptedOrganizationKeyData } from "../../admin-console/models/data/encrypted-organization-key.data"; +import { OrganizationData } from "../../admin-console/models/data/organization.data"; +import { PolicyData } from "../../admin-console/models/data/policy.data"; +import { ProviderData } from "../../admin-console/models/data/provider.data"; +import { Policy } from "../../admin-console/models/domain/policy"; +import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; +import { EnvironmentUrls } from "../../auth/models/domain/environment-urls"; +import { ForceResetPasswordReason } from "../../auth/models/domain/force-reset-password-reason"; +import { KdfConfig } from "../../auth/models/domain/kdf-config"; +import { BiometricKey } from "../../auth/types/biometric-key"; +import { + HtmlStorageLocation, + KdfType, + StorageLocation, + ThemeType, + UriMatchType, +} from "../../enums"; +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { EventData } from "../../models/data/event.data"; +import { WindowState } from "../../models/domain/window-state"; +import { migrate } from "../../state-migrations"; +import { GeneratorOptions } from "../../tools/generator/generator-options"; +import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; +import { UsernameGeneratorOptions } from "../../tools/generator/username"; +import { SendData } from "../../tools/send/models/data/send.data"; +import { SendView } from "../../tools/send/models/view/send.view"; +import { CipherData } from "../../vault/models/data/cipher.data"; +import { CollectionData } from "../../vault/models/data/collection.data"; +import { FolderData } from "../../vault/models/data/folder.data"; +import { LocalData } from "../../vault/models/data/local.data"; +import { CipherView } from "../../vault/models/view/cipher.view"; +import { CollectionView } from "../../vault/models/view/collection.view"; +import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info"; import { LogService } from "../abstractions/log.service"; import { StateService as StateServiceAbstraction } from "../abstractions/state.service"; -import { StateMigrationService } from "../abstractions/stateMigration.service"; import { AbstractMemoryStorageService, AbstractStorageService, } from "../abstractions/storage.service"; -import { CollectionData } from "../admin-console/models/data/collection.data"; -import { EncryptedOrganizationKeyData } from "../admin-console/models/data/encrypted-organization-key.data"; -import { OrganizationData } from "../admin-console/models/data/organization.data"; -import { PolicyData } from "../admin-console/models/data/policy.data"; -import { ProviderData } from "../admin-console/models/data/provider.data"; -import { Policy } from "../admin-console/models/domain/policy"; -import { CollectionView } from "../admin-console/models/view/collection.view"; -import { EnvironmentUrls } from "../auth/models/domain/environment-urls"; -import { ForceResetPasswordReason } from "../auth/models/domain/force-reset-password-reason"; -import { KdfConfig } from "../auth/models/domain/kdf-config"; -import { BiometricKey } from "../auth/types/biometric-key"; -import { HtmlStorageLocation, KdfType, StorageLocation, ThemeType, UriMatchType } from "../enums"; -import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; -import { StateFactory } from "../factories/stateFactory"; +import { StateFactory } from "../factories/state-factory"; import { Utils } from "../misc/utils"; -import { EventData } from "../models/data/event.data"; import { ServerConfigData } from "../models/data/server-config.data"; import { Account, AccountData, + AccountDecryptionOptions, AccountSettings, AccountSettingsSettings, } from "../models/domain/account"; @@ -35,19 +54,16 @@ import { EncString } from "../models/domain/enc-string"; import { GlobalState } from "../models/domain/global-state"; import { State } from "../models/domain/state"; import { StorageOptions } from "../models/domain/storage-options"; -import { DeviceKey, SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; -import { WindowState } from "../models/domain/window-state"; -import { GeneratedPasswordHistory } from "../tools/generator/password"; -import { SendData } from "../tools/send/models/data/send.data"; -import { SendView } from "../tools/send/models/view/send.view"; -import { CipherData } from "../vault/models/data/cipher.data"; -import { FolderData } from "../vault/models/data/folder.data"; -import { LocalData } from "../vault/models/data/local.data"; -import { CipherView } from "../vault/models/view/cipher.view"; -import { AddEditCipherInfo } from "../vault/types/add-edit-cipher-info"; +import { + DeviceKey, + MasterKey, + SymmetricCryptoKey, + UserKey, +} from "../models/domain/symmetric-crypto-key"; const keys = { state: "state", + stateVersion: "stateVersion", global: "global", authenticatedAccounts: "authenticatedAccounts", activeUserId: "activeUserId", @@ -56,6 +72,9 @@ const keys = { }; const partialKeys = { + userAutoKey: "_user_auto", + userBiometricKey: "_user_biometric", + autoKey: "_masterkey_auto", biometricKey: "_masterkey_biometric", masterKey: "_masterkey", @@ -90,7 +109,6 @@ export class StateService< protected secureStorageService: AbstractStorageService, protected memoryStorageService: AbstractMemoryStorageService, protected logService: LogService, - protected stateMigrationService: StateMigrationService, protected stateFactory: StateFactory, protected useAccountCache: boolean = true ) { @@ -106,7 +124,7 @@ export class StateService< // FIXME: This should be refactored into AuthService or a similar service, // as checking for the existence of the crypto key is a low level // implementation detail. - this.activeAccountUnlockedSubject.next((await this.getCryptoMasterKey()) != null); + this.activeAccountUnlockedSubject.next((await this.getUserKey()) != null); }) ) .subscribe(); @@ -117,9 +135,7 @@ export class StateService< return; } - if (await this.stateMigrationService.needsMigration()) { - await this.stateMigrationService.migrate(); - } + await migrate(this.storageService, this.logService); await this.state().then(async (state) => { if (state == null) { @@ -173,7 +189,7 @@ export class StateService< } async addAccount(account: TAccount) { - account = await this.setAccountEnvironmentUrls(account); + account = await this.setAccountEnvironment(account); await this.updateState(async (state) => { state.authenticatedAccounts.push(account.profile.userId); await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts); @@ -509,6 +525,9 @@ export class StateService< ); } + /** + * @deprecated Do not save the Master Key. Use the User Symmetric Key instead + */ async getCryptoMasterKey(options?: StorageOptions): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) @@ -516,6 +535,9 @@ export class StateService< return account?.keys?.cryptoMasterKey; } + /** + * @deprecated Do not save the Master Key. Use the User Symmetric Key instead + */ async setCryptoMasterKey(value: SymmetricCryptoKey, options?: StorageOptions): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) @@ -536,6 +558,203 @@ export class StateService< } } + /** + * user key used to encrypt/decrypt data + */ + async getUserKey(options?: StorageOptions): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); + return account?.keys?.userKey as UserKey; + } + + /** + * user key used to encrypt/decrypt data + */ + async setUserKey(value: UserKey, options?: StorageOptions): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); + account.keys.userKey = value; + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); + + if (options?.userId == this.activeAccountSubject.getValue()) { + const nextValue = value != null; + + // Avoid emitting if we are already unlocked + if (this.activeAccountUnlockedSubject.getValue() != nextValue) { + this.activeAccountUnlockedSubject.next(nextValue); + } + } + } + + /** + * User's master key derived from MP, saved only if we decrypted with MP + */ + async getMasterKey(options?: StorageOptions): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); + return account?.keys?.masterKey; + } + + /** + * User's master key derived from MP, saved only if we decrypted with MP + */ + async setMasterKey(value: MasterKey, options?: StorageOptions): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); + account.keys.masterKey = value; + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); + } + + /** + * The master key encrypted User symmetric key, saved on every auth + * so we can unlock with MP offline + */ + async getMasterKeyEncryptedUserKey(options?: StorageOptions): Promise { + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) + )?.keys.masterKeyEncryptedUserKey; + } + + /** + * The master key encrypted User symmetric key, saved on every auth + * so we can unlock with MP offline + */ + async setMasterKeyEncryptedUserKey(value: string, options?: StorageOptions): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + account.keys.masterKeyEncryptedUserKey = value; + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + } + + /** + * user key when using the "never" option of vault timeout + */ + async getUserKeyAutoUnlock(options?: StorageOptions): Promise { + options = this.reconcileOptions( + this.reconcileOptions(options, { keySuffix: "auto" }), + await this.defaultSecureStorageOptions() + ); + if (options?.userId == null) { + return null; + } + return await this.secureStorageService.get( + `${options.userId}${partialKeys.userAutoKey}`, + options + ); + } + + /** + * user key when using the "never" option of vault timeout + */ + async setUserKeyAutoUnlock(value: string, options?: StorageOptions): Promise { + options = this.reconcileOptions( + this.reconcileOptions(options, { keySuffix: "auto" }), + await this.defaultSecureStorageOptions() + ); + if (options?.userId == null) { + return; + } + await this.saveSecureStorageKey(partialKeys.userAutoKey, value, options); + } + + /** + * User's encrypted symmetric key when using biometrics + */ + async getUserKeyBiometric(options?: StorageOptions): Promise { + options = this.reconcileOptions( + this.reconcileOptions(options, { keySuffix: "biometric" }), + await this.defaultSecureStorageOptions() + ); + if (options?.userId == null) { + return null; + } + return await this.secureStorageService.get( + `${options.userId}${partialKeys.userBiometricKey}`, + options + ); + } + + async hasUserKeyBiometric(options?: StorageOptions): Promise { + options = this.reconcileOptions( + this.reconcileOptions(options, { keySuffix: "biometric" }), + await this.defaultSecureStorageOptions() + ); + if (options?.userId == null) { + return false; + } + return await this.secureStorageService.has( + `${options.userId}${partialKeys.userBiometricKey}`, + options + ); + } + + async setUserKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise { + options = this.reconcileOptions( + this.reconcileOptions(options, { keySuffix: "biometric" }), + await this.defaultSecureStorageOptions() + ); + if (options?.userId == null) { + return; + } + await this.saveSecureStorageKey(partialKeys.userBiometricKey, value, options); + } + + async getPinKeyEncryptedUserKey(options?: StorageOptions): Promise { + return EncString.fromJSON( + (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) + ?.settings?.pinKeyEncryptedUserKey + ); + } + + async setPinKeyEncryptedUserKey(value: EncString, options?: StorageOptions): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + account.settings.pinKeyEncryptedUserKey = value?.encryptedString; + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + } + + async getPinKeyEncryptedUserKeyEphemeral(options?: StorageOptions): Promise { + return EncString.fromJSON( + (await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))) + ?.settings?.pinKeyEncryptedUserKeyEphemeral + ); + } + + async setPinKeyEncryptedUserKeyEphemeral( + value: EncString, + options?: StorageOptions + ): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); + account.settings.pinKeyEncryptedUserKeyEphemeral = value?.encryptedString; + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); + } + + /** + * @deprecated Use UserKeyAuto instead + */ async getCryptoMasterKeyAuto(options?: StorageOptions): Promise { options = this.reconcileOptions( this.reconcileOptions(options, { keySuffix: "auto" }), @@ -550,6 +769,9 @@ export class StateService< ); } + /** + * @deprecated Use UserKeyAuto instead + */ async setCryptoMasterKeyAuto(value: string, options?: StorageOptions): Promise { options = this.reconcileOptions( this.reconcileOptions(options, { keySuffix: "auto" }), @@ -561,6 +783,9 @@ export class StateService< await this.saveSecureStorageKey(partialKeys.autoKey, value, options); } + /** + * @deprecated I don't see where this is even used + */ async getCryptoMasterKeyB64(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); if (options?.userId == null) { @@ -572,6 +797,9 @@ export class StateService< ); } + /** + * @deprecated I don't see where this is even used + */ async setCryptoMasterKeyB64(value: string, options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); if (options?.userId == null) { @@ -580,6 +808,9 @@ export class StateService< await this.saveSecureStorageKey(partialKeys.masterKey, value, options); } + /** + * @deprecated Use UserKeyBiometric instead + */ async getCryptoMasterKeyBiometric(options?: StorageOptions): Promise { options = this.reconcileOptions( this.reconcileOptions(options, { keySuffix: "biometric" }), @@ -594,6 +825,9 @@ export class StateService< ); } + /** + * @deprecated Use UserKeyBiometric instead + */ async hasCryptoMasterKeyBiometric(options?: StorageOptions): Promise { options = this.reconcileOptions( this.reconcileOptions(options, { keySuffix: "biometric" }), @@ -608,6 +842,9 @@ export class StateService< ); } + /** + * @deprecated Use UserKeyBiometric instead + */ async setCryptoMasterKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise { options = this.reconcileOptions( this.reconcileOptions(options, { keySuffix: "biometric" }), @@ -655,6 +892,9 @@ export class StateService< ); } + /** + * @deprecated Use UserKey instead + */ async getDecryptedCryptoSymmetricKey(options?: StorageOptions): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) @@ -662,6 +902,9 @@ export class StateService< return account?.keys?.cryptoSymmetricKey?.decrypted; } + /** + * @deprecated Use UserKey instead + */ async setDecryptedCryptoSymmetricKey( value: SymmetricCryptoKey, options?: StorageOptions @@ -722,12 +965,18 @@ export class StateService< ); } + /** + * @deprecated Use getPinKeyEncryptedUserKeyEphemeral instead + */ async getDecryptedPinProtected(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) )?.settings?.pinProtected?.decrypted; } + /** + * @deprecated Use setPinKeyEncryptedUserKeyEphemeral instead + */ async setDecryptedPinProtected(value: EncString, options?: StorageOptions): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) @@ -757,13 +1006,13 @@ export class StateService< ); } - async getDecryptedPrivateKey(options?: StorageOptions): Promise { + async getDecryptedPrivateKey(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) )?.keys?.privateKey.decrypted; } - async setDecryptedPrivateKey(value: ArrayBuffer, options?: StorageOptions): Promise { + async setDecryptedPrivateKey(value: Uint8Array, options?: StorageOptions): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); @@ -1063,10 +1312,104 @@ export class StateService< const account = await this.getAccount(options); - return account?.keys?.deviceKey as DeviceKey; + const existingDeviceKey = account?.keys?.deviceKey; + + // Must manually instantiate the SymmetricCryptoKey class from the JSON object + if (existingDeviceKey != null) { + return SymmetricCryptoKey.fromJSON(existingDeviceKey) as DeviceKey; + } else { + return null; + } + } + + async setDeviceKey(value: DeviceKey | null, options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); + + if (options?.userId == null) { + return; + } + + const account = await this.getAccount(options); + + account.keys.deviceKey = value?.toJSON() ?? null; + + await this.saveAccount(account, options); + } + + async getAdminAuthRequest(options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); + + if (options?.userId == null) { + return null; + } + + const account = await this.getAccount(options); + + return account?.adminAuthRequest + ? AdminAuthRequestStorable.fromJSON(account.adminAuthRequest) + : null; + } + + async setAdminAuthRequest( + adminAuthRequest: AdminAuthRequestStorable, + options?: StorageOptions + ): Promise { + options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); + + if (options?.userId == null) { + return; + } + + const account = await this.getAccount(options); + + account.adminAuthRequest = adminAuthRequest?.toJSON(); + + await this.saveAccount(account, options); + } + + async getShouldTrustDevice(options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); + + if (options?.userId == null) { + return null; + } + + const account = await this.getAccount(options); + + return account?.settings?.trustDeviceChoiceForDecryption ?? null; + } + + async setShouldTrustDevice(value: boolean, options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); + if (options?.userId == null) { + return; + } + + const account = await this.getAccount(options); + + account.settings.trustDeviceChoiceForDecryption = value; + + await this.saveAccount(account, options); + } + + async getAccountDecryptionOptions( + options?: StorageOptions + ): Promise { + options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); + + if (options?.userId == null) { + return null; + } + + const account = await this.getAccount(options); + + return account?.decryptionOptions as AccountDecryptionOptions; } - async setDeviceKey(value: DeviceKey, options?: StorageOptions): Promise { + async setAccountDecryptionOptions( + value: AccountDecryptionOptions, + options?: StorageOptions + ): Promise { options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); if (options?.userId == null) { @@ -1075,7 +1418,7 @@ export class StateService< const account = await this.getAccount(options); - account.keys.deviceKey = value; + account.decryptionOptions = value; await this.saveAccount(account, options); } @@ -1360,12 +1703,18 @@ export class StateService< ); } + /** + * @deprecated Use UserKey instead + */ async getEncryptedCryptoSymmetricKey(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) )?.keys.cryptoSymmetricKey.encrypted; } + /** + * @deprecated Use UserKey instead + */ async setEncryptedCryptoSymmetricKey(value: string, options?: StorageOptions): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultOnDiskOptions()) @@ -1592,6 +1941,28 @@ export class StateService< ); } + async getRegion(options?: StorageOptions): Promise { + if ((await this.state())?.activeUserId == null) { + options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); + return (await this.getGlobals(options)).region ?? null; + } + options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); + return (await this.getAccount(options))?.settings?.region ?? null; + } + + async setRegion(value: string, options?: StorageOptions): Promise { + // Global values are set on each change and the current global settings are passed to any newly authed accounts. + // This is to allow setting region values before an account is active, while still allowing individual accounts to have their own region. + const globals = await this.getGlobals( + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + globals.region = value; + await this.saveGlobals( + globals, + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + } + async getEquivalentDomains(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) @@ -1627,6 +1998,24 @@ export class StateService< ); } + async getEverHadUserKey(options?: StorageOptions): Promise { + return ( + (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) + ?.profile?.everHadUserKey ?? false + ); + } + + async setEverHadUserKey(value: boolean, options?: StorageOptions): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + account.profile.everHadUserKey = value; + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + } + async getEverBeenUnlocked(options?: StorageOptions): Promise { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))) @@ -1980,13 +2369,16 @@ export class StateService< ); } - async getPasswordGenerationOptions(options?: StorageOptions): Promise { + async getPasswordGenerationOptions(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) )?.settings?.passwordGenerationOptions; } - async setPasswordGenerationOptions(value: any, options?: StorageOptions): Promise { + async setPasswordGenerationOptions( + value: PasswordGeneratorOptions, + options?: StorageOptions + ): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()) ); @@ -1997,13 +2389,16 @@ export class StateService< ); } - async getUsernameGenerationOptions(options?: StorageOptions): Promise { + async getUsernameGenerationOptions(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) )?.settings?.usernameGenerationOptions; } - async setUsernameGenerationOptions(value: any, options?: StorageOptions): Promise { + async setUsernameGenerationOptions( + value: UsernameGeneratorOptions, + options?: StorageOptions + ): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()) ); @@ -2014,13 +2409,13 @@ export class StateService< ); } - async getGeneratorOptions(options?: StorageOptions): Promise { + async getGeneratorOptions(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) )?.settings?.generatorOptions; } - async setGeneratorOptions(value: any, options?: StorageOptions): Promise { + async setGeneratorOptions(value: GeneratorOptions, options?: StorageOptions): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()) ); @@ -2069,14 +2464,14 @@ export class StateService< ); } - async getPublicKey(options?: StorageOptions): Promise { + async getPublicKey(options?: StorageOptions): Promise { const keys = ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) )?.keys; return keys?.publicKey; } - async setPublicKey(value: ArrayBuffer, options?: StorageOptions): Promise { + async setPublicKey(value: Uint8Array, options?: StorageOptions): Promise { const account = await this.getAccount( this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); @@ -2201,6 +2596,26 @@ export class StateService< ); } + async getUserSsoOrganizationIdentifier(options?: StorageOptions): Promise { + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) + )?.loginState?.ssoOrganizationIdentifier; + } + + async setUserSsoOrganizationIdentifier( + value: string | null, + options?: StorageOptions + ): Promise { + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + account.loginState.ssoOrganizationIdentifier = value; + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + } + async getTheme(options?: StorageOptions): Promise { return ( await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) @@ -2315,16 +2730,6 @@ export class StateService< ); } - async getStateVersion(): Promise { - return (await this.getGlobals(await this.defaultOnDiskLocalOptions())).stateVersion ?? 1; - } - - async setStateVersion(value: number): Promise { - const globals = await this.getGlobals(await this.defaultOnDiskOptions()); - globals.stateVersion = value; - await this.saveGlobals(globals, await this.defaultOnDiskOptions()); - } - async getWindow(): Promise { const globals = await this.getGlobals(await this.defaultOnDiskOptions()); return globals?.window != null && Object.keys(globals.window).length > 0 @@ -2429,7 +2834,11 @@ export class StateService< globals = await this.getGlobalsFromDisk(options); } - return globals ?? this.createGlobals(); + if (globals == null) { + globals = this.createGlobals(); + } + + return globals; } protected async saveGlobals(globals: TGlobalState, options: StorageOptions) { @@ -2578,8 +2987,9 @@ export class StateService< await this.defaultOnDiskLocalOptions() ) ); - // EnvironmentUrls are set before authenticating and should override whatever is stored from any previous session + // EnvironmentUrls and region are set before authenticating and should override whatever is stored from any previous session const environmentUrls = account.settings.environmentUrls; + const region = account.settings.region; if (storedAccount?.settings != null) { account.settings = storedAccount.settings; } else if (await this.storageService.has(keys.tempAccountSettings)) { @@ -2587,6 +2997,8 @@ export class StateService< await this.storageService.remove(keys.tempAccountSettings); } account.settings.environmentUrls = environmentUrls; + account.settings.region = region; + if ( account.settings.vaultTimeoutAction === VaultTimeoutAction.LogOut && account.settings.vaultTimeout != null @@ -2614,6 +3026,7 @@ export class StateService< ); if (storedAccount?.settings != null) { storedAccount.settings.environmentUrls = account.settings.environmentUrls; + storedAccount.settings.region = account.settings.region; account.settings = storedAccount.settings; } await this.storageService.save( @@ -2636,6 +3049,7 @@ export class StateService< ); if (storedAccount?.settings != null) { storedAccount.settings.environmentUrls = account.settings.environmentUrls; + storedAccount.settings.region = account.settings.region; account.settings = storedAccount.settings; } await this.storageService.save( @@ -2750,6 +3164,8 @@ export class StateService< protected async removeAccountFromSecureStorage(userId: string = null): Promise { userId = userId ?? (await this.state())?.activeUserId; + await this.setUserKeyAutoUnlock(null, { userId: userId }); + await this.setUserKeyBiometric(null, { userId: userId }); await this.setCryptoMasterKeyAuto(null, { userId: userId }); await this.setCryptoMasterKeyBiometric(null, { userId: userId }); await this.setCryptoMasterKeyB64(null, { userId: userId }); @@ -2775,16 +3191,19 @@ export class StateService< } } - // settings persist even on reset, and are not effected by this method + // settings persist even on reset, and are not affected by this method protected resetAccount(account: TAccount) { const persistentAccountInformation = { settings: account.settings, keys: { deviceKey: account.keys.deviceKey }, + adminAuthRequest: account.adminAuthRequest, }; return Object.assign(this.createAccount(), persistentAccountInformation); } - protected async setAccountEnvironmentUrls(account: TAccount): Promise { + // The environment urls and region are selected before login and are transferred here to an authenticated account + protected async setAccountEnvironment(account: TAccount): Promise { + account.settings.region = await this.getGlobalRegion(); account.settings.environmentUrls = await this.getGlobalEnvironmentUrls(); return account; } @@ -2794,6 +3213,11 @@ export class StateService< return (await this.getGlobals(options)).environmentUrls ?? new EnvironmentUrls(); } + protected async getGlobalRegion(options?: StorageOptions): Promise { + options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); + return (await this.getGlobals(options)).region ?? null; + } + protected async clearDecryptedDataForActiveUser(): Promise { await this.updateState(async (state) => { const userId = state?.activeUserId; @@ -2900,7 +3324,7 @@ export class StateService< } } - private deleteDiskCache(key: string) { + protected deleteDiskCache(key: string) { if (this.useAccountCache) { delete this.accountDiskCache.value[key]; this.accountDiskCache.next(this.accountDiskCache.value); diff --git a/libs/common/src/services/system.service.ts b/libs/common/src/platform/services/system.service.ts similarity index 90% rename from libs/common/src/services/system.service.ts rename to libs/common/src/platform/services/system.service.ts index 4e1dad9af57..ac7e46948e3 100644 --- a/libs/common/src/services/system.service.ts +++ b/libs/common/src/platform/services/system.service.ts @@ -1,11 +1,11 @@ import { firstValueFrom } from "rxjs"; +import { AuthService } from "../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { MessagingService } from "../abstractions/messaging.service"; -import { PlatformUtilsService } from "../abstractions/platformUtils.service"; +import { PlatformUtilsService } from "../abstractions/platform-utils.service"; import { StateService } from "../abstractions/state.service"; import { SystemService as SystemServiceAbstraction } from "../abstractions/system.service"; -import { AuthService } from "../auth/abstractions/auth.service"; -import { AuthenticationStatus } from "../auth/enums/authentication-status"; import { Utils } from "../misc/utils"; export class SystemService implements SystemServiceAbstraction { @@ -39,8 +39,8 @@ export class SystemService implements SystemServiceAbstraction { } // User has set a PIN, with ask for master password on restart, to protect their vault - const decryptedPinProtected = await this.stateService.getDecryptedPinProtected(); - if (decryptedPinProtected != null) { + const ephemeralPin = await this.stateService.getPinKeyEncryptedUserKeyEphemeral(); + if (ephemeralPin != null) { return; } diff --git a/libs/common/src/services/translation.service.ts b/libs/common/src/platform/services/translation.service.ts similarity index 100% rename from libs/common/src/services/translation.service.ts rename to libs/common/src/platform/services/translation.service.ts diff --git a/libs/common/src/services/validation.service.ts b/libs/common/src/platform/services/validation.service.ts similarity index 89% rename from libs/common/src/services/validation.service.ts rename to libs/common/src/platform/services/validation.service.ts index 8c0c94d9d46..e3f18bc9467 100644 --- a/libs/common/src/services/validation.service.ts +++ b/libs/common/src/platform/services/validation.service.ts @@ -1,7 +1,7 @@ +import { ErrorResponse } from "../../models/response/error.response"; import { I18nService } from "../abstractions/i18n.service"; -import { PlatformUtilsService } from "../abstractions/platformUtils.service"; +import { PlatformUtilsService } from "../abstractions/platform-utils.service"; import { ValidationService as ValidationServiceAbstraction } from "../abstractions/validation.service"; -import { ErrorResponse } from "../models/response/error.response"; export class ValidationService implements ValidationServiceAbstraction { constructor( diff --git a/libs/common/src/services/web-crypto-function.service.spec.ts b/libs/common/src/platform/services/web-crypto-function.service.spec.ts similarity index 85% rename from libs/common/src/services/web-crypto-function.service.spec.ts rename to libs/common/src/platform/services/web-crypto-function.service.spec.ts index 519ba2eddbd..986aecb4c40 100644 --- a/libs/common/src/services/web-crypto-function.service.spec.ts +++ b/libs/common/src/platform/services/web-crypto-function.service.spec.ts @@ -1,11 +1,12 @@ // eslint-disable-next-line no-restricted-imports import { Substitute } from "@fluffy-spoon/substitute"; -import { PlatformUtilsService } from "../abstractions/platformUtils.service"; -import { Utils } from "../misc/utils"; +import { Utils } from "../../platform/misc/utils"; +import { PlatformUtilsService } from "../abstractions/platform-utils.service"; +import { DecryptParameters } from "../models/domain/decrypt-parameters"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; -import { WebCryptoFunctionService } from "./webCryptoFunction.service"; +import { WebCryptoFunctionService } from "./web-crypto-function.service"; const RsaPublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl0Vawl/toXzkEvB82FEtqHP" + @@ -160,7 +161,7 @@ describe("WebCrypto Function Service", () => { const a = new Uint8Array(2); a[0] = 1; a[1] = 2; - const equal = await cryptoFunctionService.compare(a.buffer, a.buffer); + const equal = await cryptoFunctionService.compare(a, a); expect(equal).toBe(true); }); @@ -172,7 +173,7 @@ describe("WebCrypto Function Service", () => { const b = new Uint8Array(2); b[0] = 3; b[1] = 4; - const equal = await cryptoFunctionService.compare(a.buffer, b.buffer); + const equal = await cryptoFunctionService.compare(a, b); expect(equal).toBe(false); }); @@ -183,7 +184,7 @@ describe("WebCrypto Function Service", () => { a[1] = 2; const b = new Uint8Array(2); b[0] = 3; - const equal = await cryptoFunctionService.compare(a.buffer, b.buffer); + const equal = await cryptoFunctionService.compare(a, b); expect(equal).toBe(false); }); }); @@ -200,7 +201,7 @@ describe("WebCrypto Function Service", () => { const a = new Uint8Array(2); a[0] = 1; a[1] = 2; - const aByteString = Utils.fromBufferToByteString(a.buffer); + const aByteString = Utils.fromBufferToByteString(a); const equal = await cryptoFunctionService.compareFast(aByteString, aByteString); expect(equal).toBe(true); }); @@ -210,11 +211,11 @@ describe("WebCrypto Function Service", () => { const a = new Uint8Array(2); a[0] = 1; a[1] = 2; - const aByteString = Utils.fromBufferToByteString(a.buffer); + const aByteString = Utils.fromBufferToByteString(a); const b = new Uint8Array(2); b[0] = 3; b[1] = 4; - const bByteString = Utils.fromBufferToByteString(b.buffer); + const bByteString = Utils.fromBufferToByteString(b); const equal = await cryptoFunctionService.compareFast(aByteString, bByteString); expect(equal).toBe(false); }); @@ -224,22 +225,22 @@ describe("WebCrypto Function Service", () => { const a = new Uint8Array(2); a[0] = 1; a[1] = 2; - const aByteString = Utils.fromBufferToByteString(a.buffer); + const aByteString = Utils.fromBufferToByteString(a); const b = new Uint8Array(2); b[0] = 3; - const bByteString = Utils.fromBufferToByteString(b.buffer); + const bByteString = Utils.fromBufferToByteString(b); const equal = await cryptoFunctionService.compareFast(aByteString, bByteString); expect(equal).toBe(false); }); }); - describe("aesEncrypt", () => { + describe("aesEncrypt CBC mode", () => { it("should successfully encrypt data", async () => { const cryptoFunctionService = getWebCryptoFunctionService(); const iv = makeStaticByteArray(16); const key = makeStaticByteArray(32); const data = Utils.fromUtf8ToArray("EncryptMe!"); - const encValue = await cryptoFunctionService.aesEncrypt(data.buffer, iv.buffer, key.buffer); + const encValue = await cryptoFunctionService.aesEncrypt(data, iv, key); expect(Utils.fromBufferToB64(encValue)).toBe("ByUF8vhyX4ddU9gcooznwA=="); }); @@ -249,12 +250,12 @@ describe("WebCrypto Function Service", () => { const key = makeStaticByteArray(32); const value = "EncryptMe!"; const data = Utils.fromUtf8ToArray(value); - const encValue = await cryptoFunctionService.aesEncrypt(data.buffer, iv.buffer, key.buffer); + const encValue = await cryptoFunctionService.aesEncrypt(data, iv, key); const encData = Utils.fromBufferToB64(encValue); - const b64Iv = Utils.fromBufferToB64(iv.buffer); - const symKey = new SymmetricCryptoKey(key.buffer); + const b64Iv = Utils.fromBufferToB64(iv); + const symKey = new SymmetricCryptoKey(key); const params = cryptoFunctionService.aesDecryptFastParameters(encData, b64Iv, null, symKey); - const decValue = await cryptoFunctionService.aesDecryptFast(params); + const decValue = await cryptoFunctionService.aesDecryptFast(params, "cbc"); expect(decValue).toBe(value); }); @@ -264,31 +265,54 @@ describe("WebCrypto Function Service", () => { const key = makeStaticByteArray(32); const value = "EncryptMe!"; const data = Utils.fromUtf8ToArray(value); - const encValue = await cryptoFunctionService.aesEncrypt(data.buffer, iv.buffer, key.buffer); - const decValue = await cryptoFunctionService.aesDecrypt(encValue, iv.buffer, key.buffer); + const encValue = new Uint8Array(await cryptoFunctionService.aesEncrypt(data, iv, key)); + const decValue = await cryptoFunctionService.aesDecrypt(encValue, iv, key, "cbc"); expect(Utils.fromBufferToUtf8(decValue)).toBe(value); }); }); - describe("aesDecryptFast", () => { + describe("aesDecryptFast CBC mode", () => { it("should successfully decrypt data", async () => { const cryptoFunctionService = getWebCryptoFunctionService(); - const iv = Utils.fromBufferToB64(makeStaticByteArray(16).buffer); - const symKey = new SymmetricCryptoKey(makeStaticByteArray(32).buffer); + const iv = Utils.fromBufferToB64(makeStaticByteArray(16)); + const symKey = new SymmetricCryptoKey(makeStaticByteArray(32)); const data = "ByUF8vhyX4ddU9gcooznwA=="; const params = cryptoFunctionService.aesDecryptFastParameters(data, iv, null, symKey); - const decValue = await cryptoFunctionService.aesDecryptFast(params); + const decValue = await cryptoFunctionService.aesDecryptFast(params, "cbc"); expect(decValue).toBe("EncryptMe!"); }); }); - describe("aesDecrypt", () => { + describe("aesDecryptFast ECB mode", () => { + it("should successfully decrypt data", async () => { + const cryptoFunctionService = getWebCryptoFunctionService(); + const key = makeStaticByteArray(32); + const data = Utils.fromB64ToArray("z5q2XSxYCdQFdI+qK2yLlw=="); + const params = new DecryptParameters(); + params.encKey = Utils.fromBufferToByteString(key); + params.data = Utils.fromBufferToByteString(data); + const decValue = await cryptoFunctionService.aesDecryptFast(params, "ecb"); + expect(decValue).toBe("EncryptMe!"); + }); + }); + + describe("aesDecrypt CBC mode", () => { it("should successfully decrypt data", async () => { const cryptoFunctionService = getWebCryptoFunctionService(); const iv = makeStaticByteArray(16); const key = makeStaticByteArray(32); const data = Utils.fromB64ToArray("ByUF8vhyX4ddU9gcooznwA=="); - const decValue = await cryptoFunctionService.aesDecrypt(data.buffer, iv.buffer, key.buffer); + const decValue = await cryptoFunctionService.aesDecrypt(data, iv, key, "cbc"); + expect(Utils.fromBufferToUtf8(decValue)).toBe("EncryptMe!"); + }); + }); + + describe("aesDecrypt ECB mode", () => { + it("should successfully decrypt data", async () => { + const cryptoFunctionService = getWebCryptoFunctionService(); + const key = makeStaticByteArray(32); + const data = Utils.fromB64ToArray("z5q2XSxYCdQFdI+qK2yLlw=="); + const decValue = await cryptoFunctionService.aesDecrypt(data, null, key, "ecb"); expect(Utils.fromBufferToUtf8(decValue)).toBe("EncryptMe!"); }); }); @@ -300,8 +324,8 @@ describe("WebCrypto Function Service", () => { const privKey = Utils.fromB64ToArray(RsaPrivateKey); const value = "EncryptMe!"; const data = Utils.fromUtf8ToArray(value); - const encValue = await cryptoFunctionService.rsaEncrypt(data.buffer, pubKey.buffer, "sha1"); - const decValue = await cryptoFunctionService.rsaDecrypt(encValue, privKey.buffer, "sha1"); + const encValue = new Uint8Array(await cryptoFunctionService.rsaEncrypt(data, pubKey, "sha1")); + const decValue = await cryptoFunctionService.rsaDecrypt(encValue, privKey, "sha1"); expect(Utils.fromBufferToUtf8(decValue)).toBe(value); }); }); @@ -316,7 +340,7 @@ describe("WebCrypto Function Service", () => { "zFOIEPF2S1zgperEP23M01mr4dWVdYN18B32YF67xdJHMbFhp5dkQwv9CmscoWq7OE5HIfOb+JAh7BEZb+CmKhM3yWJvoR/D" + "/5jcercUtK2o+XrzNrL4UQ7yLZcFz6Bfwb/j6ICYvqd/YJwXNE6dwlL57OfwJyCdw2rRYf0/qI00t9u8Iitw==" ); - const decValue = await cryptoFunctionService.rsaDecrypt(data.buffer, privKey.buffer, "sha1"); + const decValue = await cryptoFunctionService.rsaDecrypt(data, privKey, "sha1"); expect(Utils.fromBufferToUtf8(decValue)).toBe("EncryptMe!"); }); }); @@ -325,7 +349,7 @@ describe("WebCrypto Function Service", () => { it("should successfully extract key", async () => { const cryptoFunctionService = getWebCryptoFunctionService(); const privKey = Utils.fromB64ToArray(RsaPrivateKey); - const publicKey = await cryptoFunctionService.rsaExtractPublicKey(privKey.buffer); + const publicKey = await cryptoFunctionService.rsaExtractPublicKey(privKey); expect(Utils.fromBufferToB64(publicKey)).toBe(RsaPublicKey); }); }); @@ -354,6 +378,20 @@ describe("WebCrypto Function Service", () => { ).toBeTruthy(); }); }); + + describe("aesGenerateKey", () => { + it.each([128, 192, 256, 512])("Should make a key of %s bits long", async (length) => { + const cryptoFunctionService = getWebCryptoFunctionService(); + const key = await cryptoFunctionService.aesGenerateKey(length); + expect(key.byteLength * 8).toBe(length); + }); + + it("should not repeat itself for 512 length special case", async () => { + const cryptoFunctionService = getWebCryptoFunctionService(); + const key = await cryptoFunctionService.aesGenerateKey(512); + expect(key.slice(0, 32)).not.toEqual(key.slice(32, 64)); + }); + }); }); function testPbkdf2( @@ -390,8 +428,8 @@ function testPbkdf2( it("should create valid " + algorithm + " key from array buffer input", async () => { const cryptoFunctionService = getWebCryptoFunctionService(); const key = await cryptoFunctionService.pbkdf2( - Utils.fromUtf8ToArray(regularPassword).buffer, - Utils.fromUtf8ToArray(regularEmail).buffer, + Utils.fromUtf8ToArray(regularPassword), + Utils.fromUtf8ToArray(regularEmail), algorithm, 5000 ); @@ -437,8 +475,8 @@ function testHkdf( const cryptoFunctionService = getWebCryptoFunctionService(); const key = await cryptoFunctionService.hkdf( ikm, - Utils.fromUtf8ToArray(regularSalt).buffer, - Utils.fromUtf8ToArray(regularInfo).buffer, + Utils.fromUtf8ToArray(regularSalt), + Utils.fromUtf8ToArray(regularInfo), 32, algorithm ); @@ -496,10 +534,7 @@ function testHash( it("should create valid " + algorithm + " hash from array buffer input", async () => { const cryptoFunctionService = getWebCryptoFunctionService(); - const hash = await cryptoFunctionService.hash( - Utils.fromUtf8ToArray(regularValue).buffer, - algorithm - ); + const hash = await cryptoFunctionService.hash(Utils.fromUtf8ToArray(regularValue), algorithm); expect(Utils.fromBufferToHex(hash)).toBe(regularHash); }); } @@ -508,8 +543,8 @@ function testHmac(algorithm: "sha1" | "sha256" | "sha512", mac: string) { it("should create valid " + algorithm + " hmac", async () => { const cryptoFunctionService = getWebCryptoFunctionService(); const computedMac = await cryptoFunctionService.hmac( - Utils.fromUtf8ToArray("SignMe!!").buffer, - Utils.fromUtf8ToArray("secretkey").buffer, + Utils.fromUtf8ToArray("SignMe!!"), + Utils.fromUtf8ToArray("secretkey"), algorithm ); expect(Utils.fromBufferToHex(computedMac)).toBe(mac); @@ -519,14 +554,14 @@ function testHmac(algorithm: "sha1" | "sha256" | "sha512", mac: string) { function testHmacFast(algorithm: "sha1" | "sha256" | "sha512", mac: string) { it("should create valid " + algorithm + " hmac", async () => { const cryptoFunctionService = getWebCryptoFunctionService(); - const keyByteString = Utils.fromBufferToByteString(Utils.fromUtf8ToArray("secretkey").buffer); - const dataByteString = Utils.fromBufferToByteString(Utils.fromUtf8ToArray("SignMe!!").buffer); + const keyByteString = Utils.fromBufferToByteString(Utils.fromUtf8ToArray("secretkey")); + const dataByteString = Utils.fromBufferToByteString(Utils.fromUtf8ToArray("SignMe!!")); const computedMac = await cryptoFunctionService.hmacFast( dataByteString, keyByteString, algorithm ); - expect(Utils.fromBufferToHex(Utils.fromByteStringToArray(computedMac).buffer)).toBe(mac); + expect(Utils.fromBufferToHex(Utils.fromByteStringToArray(computedMac))).toBe(mac); }); } @@ -535,7 +570,9 @@ function testRsaGenerateKeyPair(length: 1024 | 2048 | 4096) { "should successfully generate a " + length + " bit key pair", async () => { const cryptoFunctionService = getWebCryptoFunctionService(); - const keyPair = await cryptoFunctionService.rsaGenerateKeyPair(length); + const keyPair = (await cryptoFunctionService.rsaGenerateKeyPair(length)).map( + (k) => new Uint8Array(k) + ); expect(keyPair[0] == null || keyPair[1] == null).toBe(false); const publicKey = await cryptoFunctionService.rsaExtractPublicKey(keyPair[1]); expect(Utils.fromBufferToB64(keyPair[0])).toBe(Utils.fromBufferToB64(publicKey)); diff --git a/libs/common/src/services/webCryptoFunction.service.ts b/libs/common/src/platform/services/web-crypto-function.service.ts similarity index 69% rename from libs/common/src/services/webCryptoFunction.service.ts rename to libs/common/src/platform/services/web-crypto-function.service.ts index 748adafedf0..93b8ba26664 100644 --- a/libs/common/src/services/webCryptoFunction.service.ts +++ b/libs/common/src/platform/services/web-crypto-function.service.ts @@ -1,11 +1,11 @@ import * as argon2 from "argon2-browser"; import * as forge from "node-forge"; -import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; -import { Utils } from "../misc/utils"; +import { Utils } from "../../platform/misc/utils"; +import { CsprngArray } from "../../types/csprng"; +import { CryptoFunctionService } from "../abstractions/crypto-function.service"; import { DecryptParameters } from "../models/domain/decrypt-parameters"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; -import { CsprngArray } from "../types/csprng"; export class WebCryptoFunctionService implements CryptoFunctionService { private crypto: Crypto; @@ -20,11 +20,11 @@ export class WebCryptoFunctionService implements CryptoFunctionService { } async pbkdf2( - password: string | ArrayBuffer, - salt: string | ArrayBuffer, + password: string | Uint8Array, + salt: string | Uint8Array, algorithm: "sha256" | "sha512", iterations: number - ): Promise { + ): Promise { const wcLen = algorithm === "sha256" ? 256 : 512; const passwordBuf = this.toBuf(password); const saltBuf = this.toBuf(salt); @@ -43,16 +43,17 @@ export class WebCryptoFunctionService implements CryptoFunctionService { false, ["deriveBits"] ); - return await this.subtle.deriveBits(pbkdf2Params, impKey, wcLen); + const buffer = await this.subtle.deriveBits(pbkdf2Params as any, impKey, wcLen); + return new Uint8Array(buffer); } async argon2( - password: string | ArrayBuffer, - salt: string | ArrayBuffer, + password: string | Uint8Array, + salt: string | Uint8Array, iterations: number, memory: number, parallelism: number - ): Promise { + ): Promise { if (!this.wasmSupported) { throw "Webassembly support is required for the Argon2 KDF feature."; } @@ -69,16 +70,17 @@ export class WebCryptoFunctionService implements CryptoFunctionService { hashLen: 32, type: argon2.ArgonType.Argon2id, }); + argon2.unloadRuntime(); return result.hash; } async hkdf( - ikm: ArrayBuffer, - salt: string | ArrayBuffer, - info: string | ArrayBuffer, + ikm: Uint8Array, + salt: string | Uint8Array, + info: string | Uint8Array, outputByteSize: number, algorithm: "sha256" | "sha512" - ): Promise { + ): Promise { const saltBuf = this.toBuf(salt); const infoBuf = this.toBuf(info); @@ -92,16 +94,17 @@ export class WebCryptoFunctionService implements CryptoFunctionService { const impKey = await this.subtle.importKey("raw", ikm, { name: "HKDF" } as any, false, [ "deriveBits", ]); - return await this.subtle.deriveBits(hkdfParams as any, impKey, outputByteSize * 8); + const buffer = await this.subtle.deriveBits(hkdfParams as any, impKey, outputByteSize * 8); + return new Uint8Array(buffer); } // ref: https://tools.ietf.org/html/rfc5869 async hkdfExpand( - prk: ArrayBuffer, - info: string | ArrayBuffer, + prk: Uint8Array, + info: string | Uint8Array, outputByteSize: number, algorithm: "sha256" | "sha512" - ): Promise { + ): Promise { const hashLen = algorithm === "sha256" ? 32 : 64; if (outputByteSize > 255 * hashLen) { throw new Error("outputByteSize is too large."); @@ -121,49 +124,54 @@ export class WebCryptoFunctionService implements CryptoFunctionService { t.set(previousT); t.set(infoArr, previousT.length); t.set([i + 1], t.length - 1); - previousT = new Uint8Array(await this.hmac(t.buffer, prk, algorithm)); + previousT = new Uint8Array(await this.hmac(t, prk, algorithm)); okm.set(previousT, runningOkmLength); runningOkmLength += previousT.length; if (runningOkmLength >= outputByteSize) { break; } } - return okm.slice(0, outputByteSize).buffer; + return okm.slice(0, outputByteSize); } async hash( - value: string | ArrayBuffer, + value: string | Uint8Array, algorithm: "sha1" | "sha256" | "sha512" | "md5" - ): Promise { + ): Promise { if (algorithm === "md5") { const md = algorithm === "md5" ? forge.md.md5.create() : forge.md.sha1.create(); const valueBytes = this.toByteString(value); md.update(valueBytes, "raw"); - return Utils.fromByteStringToArray(md.digest().data).buffer; + return Utils.fromByteStringToArray(md.digest().data); } const valueBuf = this.toBuf(value); - return await this.subtle.digest({ name: this.toWebCryptoAlgorithm(algorithm) }, valueBuf); + const buffer = await this.subtle.digest( + { name: this.toWebCryptoAlgorithm(algorithm) }, + valueBuf + ); + return new Uint8Array(buffer); } async hmac( - value: ArrayBuffer, - key: ArrayBuffer, + value: Uint8Array, + key: Uint8Array, algorithm: "sha1" | "sha256" | "sha512" - ): Promise { + ): Promise { const signingAlgorithm = { name: "HMAC", hash: { name: this.toWebCryptoAlgorithm(algorithm) }, }; const impKey = await this.subtle.importKey("raw", key, signingAlgorithm, false, ["sign"]); - return await this.subtle.sign(signingAlgorithm, impKey, value); + const buffer = await this.subtle.sign(signingAlgorithm, impKey, value); + return new Uint8Array(buffer); } // Safely compare two values in a way that protects against timing attacks (Double HMAC Verification). // ref: https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/february/double-hmac-verification/ // ref: https://paragonie.com/blog/2015/11/preventing-timing-attacks-on-string-comparison-with-double-hmac-strategy - async compare(a: ArrayBuffer, b: ArrayBuffer): Promise { + async compare(a: Uint8Array, b: Uint8Array): Promise { const macKey = await this.randomBytes(32); const signingAlgorithm = { name: "HMAC", @@ -218,11 +226,12 @@ export class WebCryptoFunctionService implements CryptoFunctionService { return equals; } - async aesEncrypt(data: ArrayBuffer, iv: ArrayBuffer, key: ArrayBuffer): Promise { + async aesEncrypt(data: Uint8Array, iv: Uint8Array, key: Uint8Array): Promise { const impKey = await this.subtle.importKey("raw", key, { name: "AES-CBC" } as any, false, [ "encrypt", ]); - return await this.subtle.encrypt({ name: "AES-CBC", iv: iv }, impKey, data); + const buffer = await this.subtle.encrypt({ name: "AES-CBC", iv: iv }, impKey, data); + return new Uint8Array(buffer); } aesDecryptFastParameters( @@ -264,28 +273,49 @@ export class WebCryptoFunctionService implements CryptoFunctionService { return p; } - aesDecryptFast(parameters: DecryptParameters): Promise { - const dataBuffer = forge.util.createBuffer(parameters.data); - const decipher = forge.cipher.createDecipher("AES-CBC", parameters.encKey); - decipher.start({ iv: parameters.iv }); + aesDecryptFast(parameters: DecryptParameters, mode: "cbc" | "ecb"): Promise { + const decipher = (forge as any).cipher.createDecipher( + this.toWebCryptoAesMode(mode), + parameters.encKey + ); + const options = {} as any; + if (mode === "cbc") { + options.iv = parameters.iv; + } + const dataBuffer = (forge as any).util.createBuffer(parameters.data); + decipher.start(options); decipher.update(dataBuffer); decipher.finish(); const val = decipher.output.toString(); return Promise.resolve(val); } - async aesDecrypt(data: ArrayBuffer, iv: ArrayBuffer, key: ArrayBuffer): Promise { + async aesDecrypt( + data: Uint8Array, + iv: Uint8Array, + key: Uint8Array, + mode: "cbc" | "ecb" + ): Promise { + if (mode === "ecb") { + // Web crypto does not support AES-ECB mode, so we need to do this in forge. + const params = new DecryptParameters(); + params.data = this.toByteString(data); + params.encKey = this.toByteString(key); + const result = await this.aesDecryptFast(params, "ecb"); + return Utils.fromByteStringToArray(result); + } const impKey = await this.subtle.importKey("raw", key, { name: "AES-CBC" } as any, false, [ "decrypt", ]); - return await this.subtle.decrypt({ name: "AES-CBC", iv: iv }, impKey, data); + const buffer = await this.subtle.decrypt({ name: "AES-CBC", iv: iv }, impKey, data); + return new Uint8Array(buffer); } async rsaEncrypt( - data: ArrayBuffer, - publicKey: ArrayBuffer, + data: Uint8Array, + publicKey: Uint8Array, algorithm: "sha1" | "sha256" - ): Promise { + ): Promise { // Note: Edge browser requires that we specify name and hash for both key import and decrypt. // We cannot use the proper types here. const rsaParams = { @@ -293,14 +323,15 @@ export class WebCryptoFunctionService implements CryptoFunctionService { hash: { name: this.toWebCryptoAlgorithm(algorithm) }, }; const impKey = await this.subtle.importKey("spki", publicKey, rsaParams, false, ["encrypt"]); - return await this.subtle.encrypt(rsaParams, impKey, data); + const buffer = await this.subtle.encrypt(rsaParams, impKey, data); + return new Uint8Array(buffer); } async rsaDecrypt( - data: ArrayBuffer, - privateKey: ArrayBuffer, + data: Uint8Array, + privateKey: Uint8Array, algorithm: "sha1" | "sha256" - ): Promise { + ): Promise { // Note: Edge browser requires that we specify name and hash for both key import and decrypt. // We cannot use the proper types here. const rsaParams = { @@ -308,10 +339,11 @@ export class WebCryptoFunctionService implements CryptoFunctionService { hash: { name: this.toWebCryptoAlgorithm(algorithm) }, }; const impKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, false, ["decrypt"]); - return await this.subtle.decrypt(rsaParams, impKey, data); + const buffer = await this.subtle.decrypt(rsaParams, impKey, data); + return new Uint8Array(buffer); } - async rsaExtractPublicKey(privateKey: ArrayBuffer): Promise { + async rsaExtractPublicKey(privateKey: Uint8Array): Promise { const rsaParams = { name: "RSA-OAEP", // Have to specify some algorithm @@ -331,10 +363,28 @@ export class WebCryptoFunctionService implements CryptoFunctionService { const impPublicKey = await this.subtle.importKey("jwk", jwkPublicKeyParams, rsaParams, true, [ "encrypt", ]); - return await this.subtle.exportKey("spki", impPublicKey); + const buffer = await this.subtle.exportKey("spki", impPublicKey); + return new Uint8Array(buffer); } - async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[ArrayBuffer, ArrayBuffer]> { + async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise { + if (bitLength === 512) { + // 512 bit keys are not supported in WebCrypto, so we concat two 256 bit keys + const key1 = await this.aesGenerateKey(256); + const key2 = await this.aesGenerateKey(256); + return new Uint8Array([...key1, ...key2]) as CsprngArray; + } + const aesParams = { + name: "AES-CBC", + length: bitLength, + }; + + const key = await this.subtle.generateKey(aesParams, true, ["encrypt", "decrypt"]); + const rawKey = await this.subtle.exportKey("raw", key); + return new Uint8Array(rawKey) as CsprngArray; + } + + async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> { const rsaParams = { name: "RSA-OAEP", modulusLength: length, @@ -342,32 +392,29 @@ export class WebCryptoFunctionService implements CryptoFunctionService { // Have to specify some algorithm hash: { name: this.toWebCryptoAlgorithm("sha1") }, }; - const keyPair = (await this.subtle.generateKey(rsaParams, true, [ - "encrypt", - "decrypt", - ])) as CryptoKeyPair; + const keyPair = await this.subtle.generateKey(rsaParams, true, ["encrypt", "decrypt"]); const publicKey = await this.subtle.exportKey("spki", keyPair.publicKey); const privateKey = await this.subtle.exportKey("pkcs8", keyPair.privateKey); - return [publicKey, privateKey]; + return [new Uint8Array(publicKey), new Uint8Array(privateKey)]; } randomBytes(length: number): Promise { const arr = new Uint8Array(length); this.crypto.getRandomValues(arr); - return Promise.resolve(arr.buffer as CsprngArray); + return Promise.resolve(arr as CsprngArray); } - private toBuf(value: string | ArrayBuffer): ArrayBuffer { - let buf: ArrayBuffer; + private toBuf(value: string | Uint8Array): Uint8Array { + let buf: Uint8Array; if (typeof value === "string") { - buf = Utils.fromUtf8ToArray(value).buffer; + buf = Utils.fromUtf8ToArray(value); } else { buf = value; } return buf; } - private toByteString(value: string | ArrayBuffer): string { + private toByteString(value: string | Uint8Array): string { let bytes: string; if (typeof value === "string") { bytes = forge.util.encodeUtf8(value); @@ -384,6 +431,10 @@ export class WebCryptoFunctionService implements CryptoFunctionService { return algorithm === "sha1" ? "SHA-1" : algorithm === "sha256" ? "SHA-256" : "SHA-512"; } + private toWebCryptoAesMode(mode: "cbc" | "ecb"): string { + return mode === "cbc" ? "AES-CBC" : "AES-ECB"; + } + // ref: https://stackoverflow.com/a/47880734/1090359 private checkIfWasmSupported(): boolean { try { diff --git a/libs/common/src/services/account/avatar-update.service.ts b/libs/common/src/services/account/avatar-update.service.ts index 7687116682c..4457ee5457a 100644 --- a/libs/common/src/services/account/avatar-update.service.ts +++ b/libs/common/src/services/account/avatar-update.service.ts @@ -2,9 +2,9 @@ import { BehaviorSubject, Observable } from "rxjs"; import { AvatarUpdateService as AvatarUpdateServiceAbstraction } from "../../abstractions/account/avatar-update.service"; import { ApiService } from "../../abstractions/api.service"; -import { StateService } from "../../abstractions/state.service"; import { UpdateAvatarRequest } from "../../models/request/update-avatar.request"; import { ProfileResponse } from "../../models/response/profile.response"; +import { StateService } from "../../platform/abstractions/state.service"; export class AvatarUpdateService implements AvatarUpdateServiceAbstraction { private _avatarUpdate$ = new BehaviorSubject(null); diff --git a/libs/common/src/services/anonymousHub.service.ts b/libs/common/src/services/anonymousHub.service.ts index dc1db5c9305..31b394ae587 100644 --- a/libs/common/src/services/anonymousHub.service.ts +++ b/libs/common/src/services/anonymousHub.service.ts @@ -1,4 +1,3 @@ -import { Injectable } from "@angular/core"; import { HttpTransportType, HubConnection, @@ -8,16 +7,15 @@ import { import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack"; import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymousHub.service"; -import { EnvironmentService } from "../abstractions/environment.service"; -import { LogService } from "../abstractions/log.service"; import { AuthService } from "../auth/abstractions/auth.service"; +import { EnvironmentService } from "../platform/abstractions/environment.service"; +import { LogService } from "../platform/abstractions/log.service"; import { AuthRequestPushNotification, NotificationResponse, } from "./../models/response/notification.response"; -@Injectable() export class AnonymousHubService implements AnonymousHubServiceAbstraction { private anonHubConnection: HubConnection; private url: string; diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 59e3755a04f..9f658a0955e 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1,9 +1,5 @@ import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service"; -import { AppIdService } from "../abstractions/appId.service"; -import { EnvironmentService } from "../abstractions/environment.service"; -import { PlatformUtilsService } from "../abstractions/platformUtils.service"; import { OrganizationConnectionType } from "../admin-console/enums"; -import { CollectionRequest } from "../admin-console/models/request/collection.request"; import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request"; import { OrganizationSponsorshipRedeemRequest } from "../admin-console/models/request/organization/organization-sponsorship-redeem.request"; import { OrganizationConnectionRequest } from "../admin-console/models/request/organization-connection.request"; @@ -18,10 +14,6 @@ import { ProviderUserConfirmRequest } from "../admin-console/models/request/prov import { ProviderUserInviteRequest } from "../admin-console/models/request/provider/provider-user-invite.request"; import { ProviderUserUpdateRequest } from "../admin-console/models/request/provider/provider-user-update.request"; import { SelectionReadOnlyRequest } from "../admin-console/models/request/selection-read-only.request"; -import { - CollectionAccessDetailsResponse, - CollectionResponse, -} from "../admin-console/models/response/collection.response"; import { OrganizationConnectionConfigApis, OrganizationConnectionResponse, @@ -112,7 +104,6 @@ import { SubscriptionResponse } from "../billing/models/response/subscription.re import { TaxInfoResponse } from "../billing/models/response/tax-info.response"; import { TaxRateResponse } from "../billing/models/response/tax-rate.response"; import { DeviceType } from "../enums"; -import { Utils } from "../misc/utils"; import { CollectionBulkDeleteRequest } from "../models/request/collection-bulk-delete.request"; import { DeleteRecoverRequest } from "../models/request/delete-recover.request"; import { EventRequest } from "../models/request/event.request"; @@ -135,18 +126,28 @@ import { EventResponse } from "../models/response/event.response"; import { ListResponse } from "../models/response/list.response"; import { ProfileResponse } from "../models/response/profile.response"; import { UserKeyResponse } from "../models/response/user-key.response"; +import { AppIdService } from "../platform/abstractions/app-id.service"; +import { EnvironmentService } from "../platform/abstractions/environment.service"; +import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { Utils } from "../platform/misc/utils"; import { AttachmentRequest } from "../vault/models/request/attachment.request"; import { CipherBulkDeleteRequest } from "../vault/models/request/cipher-bulk-delete.request"; import { CipherBulkMoveRequest } from "../vault/models/request/cipher-bulk-move.request"; +import { CipherBulkRestoreRequest } from "../vault/models/request/cipher-bulk-restore.request"; import { CipherBulkShareRequest } from "../vault/models/request/cipher-bulk-share.request"; import { CipherCollectionsRequest } from "../vault/models/request/cipher-collections.request"; import { CipherCreateRequest } from "../vault/models/request/cipher-create.request"; import { CipherPartialRequest } from "../vault/models/request/cipher-partial.request"; import { CipherShareRequest } from "../vault/models/request/cipher-share.request"; import { CipherRequest } from "../vault/models/request/cipher.request"; +import { CollectionRequest } from "../vault/models/request/collection.request"; import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response"; import { AttachmentResponse } from "../vault/models/response/attachment.response"; import { CipherResponse } from "../vault/models/response/cipher.response"; +import { + CollectionAccessDetailsResponse, + CollectionResponse, +} from "../vault/models/response/collection.response"; import { SyncResponse } from "../vault/models/response/sync.response"; /** @@ -250,10 +251,15 @@ export class ApiService implements ApiServiceAbstraction { } } + // TODO: PM-3519: Create and move to AuthRequest Api service async postAuthRequest(request: PasswordlessCreateAuthRequest): Promise { const r = await this.send("POST", "/auth-requests/", request, false, true); return new AuthRequestResponse(r); } + async postAdminAuthRequest(request: PasswordlessCreateAuthRequest): Promise { + const r = await this.send("POST", "/auth-requests/admin-request", request, true, true); + return new AuthRequestResponse(r); + } async getAuthResponse(id: string, accessCode: string): Promise { const path = `/auth-requests/${id}/response?code=${accessCode}`; @@ -614,12 +620,19 @@ export class ApiService implements ApiServiceAbstraction { } async putRestoreManyCiphers( - request: CipherBulkDeleteRequest + request: CipherBulkRestoreRequest ): Promise> { const r = await this.send("PUT", "/ciphers/restore", request, true, true); return new ListResponse(r, CipherResponse); } + async putRestoreManyCiphersAdmin( + request: CipherBulkRestoreRequest + ): Promise> { + const r = await this.send("PUT", "/ciphers/restore-admin", request, true, true); + return new ListResponse(r, CipherResponse); + } + // Attachments APIs async getAttachmentData( @@ -881,7 +894,7 @@ export class ApiService implements ApiServiceAbstraction { // Plan APIs async getPlans(): Promise> { - const r = await this.send("GET", "/plans/", null, false, true); + const r = await this.send("GET", "/plans/all", null, false, true); return new ListResponse(r, PlanResponse); } @@ -1572,7 +1585,9 @@ export class ApiService implements ApiServiceAbstraction { // Key Connector - async getUserKeyFromKeyConnector(keyConnectorUrl: string): Promise { + async getMasterKeyFromKeyConnector( + keyConnectorUrl: string + ): Promise { const authHeader = await this.getActiveBearerToken(); const response = await this.fetch( diff --git a/libs/common/src/services/audit.service.ts b/libs/common/src/services/audit.service.ts index 8581026f64d..e282b09e627 100644 --- a/libs/common/src/services/audit.service.ts +++ b/libs/common/src/services/audit.service.ts @@ -1,10 +1,10 @@ import { ApiService } from "../abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "../abstractions/audit.service"; -import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; -import { throttle } from "../misc/throttle"; -import { Utils } from "../misc/utils"; import { BreachAccountResponse } from "../models/response/breach-account.response"; import { ErrorResponse } from "../models/response/error.response"; +import { CryptoFunctionService } from "../platform/abstractions/crypto-function.service"; +import { throttle } from "../platform/misc/throttle"; +import { Utils } from "../platform/misc/utils"; const PwnedPasswordsApi = "https://api.pwnedpasswords.com/range/"; diff --git a/libs/common/src/services/config/config-api.service.ts b/libs/common/src/services/config/config-api.service.ts deleted file mode 100644 index 743f48a4967..00000000000 --- a/libs/common/src/services/config/config-api.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiService } from "../../abstractions/api.service"; -import { ConfigApiServiceAbstraction as ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; -import { ServerConfigResponse } from "../../models/response/server-config.response"; - -export class ConfigApiService implements ConfigApiServiceAbstraction { - constructor(private apiService: ApiService) {} - - async get(): Promise { - const r = await this.apiService.send("GET", "/config", null, false, true); - return new ServerConfigResponse(r); - } -} diff --git a/libs/common/src/services/config/config.service.ts b/libs/common/src/services/config/config.service.ts deleted file mode 100644 index dba5d1ca09c..00000000000 --- a/libs/common/src/services/config/config.service.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Injectable, OnDestroy } from "@angular/core"; -import { BehaviorSubject, Subject, concatMap, from, takeUntil, timer } from "rxjs"; - -import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; -import { ConfigServiceAbstraction } from "../../abstractions/config/config.service.abstraction"; -import { ServerConfig } from "../../abstractions/config/server-config"; -import { EnvironmentService } from "../../abstractions/environment.service"; -import { StateService } from "../../abstractions/state.service"; -import { AuthService } from "../../auth/abstractions/auth.service"; -import { AuthenticationStatus } from "../../auth/enums/authentication-status"; -import { FeatureFlag } from "../../enums/feature-flag.enum"; -import { ServerConfigData } from "../../models/data/server-config.data"; - -@Injectable() -export class ConfigService implements ConfigServiceAbstraction, OnDestroy { - protected _serverConfig = new BehaviorSubject(null); - serverConfig$ = this._serverConfig.asObservable(); - private destroy$ = new Subject(); - - constructor( - private stateService: StateService, - private configApiService: ConfigApiServiceAbstraction, - private authService: AuthService, - private environmentService: EnvironmentService - ) { - // Re-fetch the server config every hour - timer(0, 1000 * 3600) - .pipe(concatMap(() => from(this.fetchServerConfig()))) - .subscribe((serverConfig) => { - this._serverConfig.next(serverConfig); - }); - - this.environmentService.urls.pipe(takeUntil(this.destroy$)).subscribe(() => { - this.fetchServerConfig(); - }); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - async fetchServerConfig(): Promise { - try { - const response = await this.configApiService.get(); - - if (response != null) { - const data = new ServerConfigData(response); - const serverConfig = new ServerConfig(data); - this._serverConfig.next(serverConfig); - if ((await this.authService.getAuthStatus()) === AuthenticationStatus.LoggedOut) { - return serverConfig; - } - await this.stateService.setServerConfig(data); - } - } catch { - return null; - } - } - - async getFeatureFlagBool(key: FeatureFlag, defaultValue = false): Promise { - return await this.getFeatureFlag(key, defaultValue); - } - - async getFeatureFlagString(key: FeatureFlag, defaultValue = ""): Promise { - return await this.getFeatureFlag(key, defaultValue); - } - - async getFeatureFlagNumber(key: FeatureFlag, defaultValue = 0): Promise { - return await this.getFeatureFlag(key, defaultValue); - } - - private async getFeatureFlag(key: FeatureFlag, defaultValue: T): Promise { - const serverConfig = await this.buildServerConfig(); - if ( - serverConfig == null || - serverConfig.featureStates == null || - serverConfig.featureStates[key] == null - ) { - return defaultValue; - } - return serverConfig.featureStates[key] as T; - } - - private async buildServerConfig(): Promise { - const data = await this.stateService.getServerConfig(); - const domain = data ? new ServerConfig(data) : this._serverConfig.getValue(); - - if (domain == null || !domain.isValid() || domain.expiresSoon()) { - const value = await this.fetchServerConfig(); - return value ?? domain; - } - - return domain; - } -} diff --git a/libs/common/src/services/crypto.service.spec.ts b/libs/common/src/services/crypto.service.spec.ts deleted file mode 100644 index cc0d3ba212a..00000000000 --- a/libs/common/src/services/crypto.service.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { mock, mockReset } from "jest-mock-extended"; - -import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; -import { EncryptService } from "../abstractions/encrypt.service"; -import { LogService } from "../abstractions/log.service"; -import { PlatformUtilsService } from "../abstractions/platformUtils.service"; -import { StateService } from "../abstractions/state.service"; -import { CryptoService } from "../services/crypto.service"; - -describe("cryptoService", () => { - let cryptoService: CryptoService; - - const cryptoFunctionService = mock(); - const encryptService = mock(); - const platformUtilService = mock(); - const logService = mock(); - const stateService = mock(); - - beforeEach(() => { - mockReset(cryptoFunctionService); - mockReset(encryptService); - mockReset(platformUtilService); - mockReset(logService); - mockReset(stateService); - - cryptoService = new CryptoService( - cryptoFunctionService, - encryptService, - platformUtilService, - logService, - stateService - ); - }); - - it("instantiates", () => { - expect(cryptoService).not.toBeFalsy(); - }); -}); diff --git a/libs/common/src/services/crypto.service.ts b/libs/common/src/services/crypto.service.ts deleted file mode 100644 index bfbae2952ab..00000000000 --- a/libs/common/src/services/crypto.service.ts +++ /dev/null @@ -1,850 +0,0 @@ -import * as bigInt from "big-integer"; - -import { CryptoService as CryptoServiceAbstraction } from "../abstractions/crypto.service"; -import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; -import { EncryptService } from "../abstractions/encrypt.service"; -import { LogService } from "../abstractions/log.service"; -import { PlatformUtilsService } from "../abstractions/platformUtils.service"; -import { StateService } from "../abstractions/state.service"; -import { EncryptedOrganizationKeyData } from "../admin-console/models/data/encrypted-organization-key.data"; -import { BaseEncryptedOrganizationKey } from "../admin-console/models/domain/encrypted-organization-key"; -import { ProfileOrganizationResponse } from "../admin-console/models/response/profile-organization.response"; -import { ProfileProviderOrganizationResponse } from "../admin-console/models/response/profile-provider-organization.response"; -import { ProfileProviderResponse } from "../admin-console/models/response/profile-provider.response"; -import { KdfConfig } from "../auth/models/domain/kdf-config"; -import { - DEFAULT_ARGON2_ITERATIONS, - DEFAULT_ARGON2_MEMORY, - DEFAULT_ARGON2_PARALLELISM, - EncryptionType, - HashPurpose, - KdfType, - KeySuffixOptions, -} from "../enums"; -import { sequentialize } from "../misc/sequentialize"; -import { Utils } from "../misc/utils"; -import { EFFLongWordList } from "../misc/wordlist"; -import { EncArrayBuffer } from "../models/domain/enc-array-buffer"; -import { EncString } from "../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; - -export class CryptoService implements CryptoServiceAbstraction { - constructor( - protected cryptoFunctionService: CryptoFunctionService, - protected encryptService: EncryptService, - protected platformUtilService: PlatformUtilsService, - protected logService: LogService, - protected stateService: StateService - ) {} - - async setKey(key: SymmetricCryptoKey, userId?: string): Promise { - await this.stateService.setCryptoMasterKey(key, { userId: userId }); - await this.storeKey(key, userId); - } - - async setKeyHash(keyHash: string): Promise { - await this.stateService.setKeyHash(keyHash); - } - - async setEncKey(encKey: string): Promise { - if (encKey == null) { - return; - } - - await this.stateService.setDecryptedCryptoSymmetricKey(null); - await this.stateService.setEncryptedCryptoSymmetricKey(encKey); - } - - async setEncPrivateKey(encPrivateKey: string): Promise { - if (encPrivateKey == null) { - return; - } - - await this.stateService.setDecryptedPrivateKey(null); - await this.stateService.setEncryptedPrivateKey(encPrivateKey); - } - - async setOrgKeys( - orgs: ProfileOrganizationResponse[] = [], - providerOrgs: ProfileProviderOrganizationResponse[] = [] - ): Promise { - const encOrgKeyData: { [orgId: string]: EncryptedOrganizationKeyData } = {}; - - orgs.forEach((org) => { - encOrgKeyData[org.id] = { - type: "organization", - key: org.key, - }; - }); - - providerOrgs.forEach((org) => { - encOrgKeyData[org.id] = { - type: "provider", - providerId: org.providerId, - key: org.key, - }; - }); - - await this.stateService.setDecryptedOrganizationKeys(null); - return await this.stateService.setEncryptedOrganizationKeys(encOrgKeyData); - } - - async setProviderKeys(providers: ProfileProviderResponse[]): Promise { - const providerKeys: any = {}; - providers.forEach((provider) => { - providerKeys[provider.id] = provider.key; - }); - - await this.stateService.setDecryptedProviderKeys(null); - return await this.stateService.setEncryptedProviderKeys(providerKeys); - } - - async getKey(keySuffix?: KeySuffixOptions, userId?: string): Promise { - const inMemoryKey = await this.stateService.getCryptoMasterKey({ userId: userId }); - - if (inMemoryKey != null) { - return inMemoryKey; - } - - keySuffix ||= KeySuffixOptions.Auto; - const symmetricKey = await this.getKeyFromStorage(keySuffix, userId); - - if (symmetricKey != null) { - // TODO: Refactor here so get key doesn't also set key - this.setKey(symmetricKey, userId); - } - - return symmetricKey; - } - - async getKeyFromStorage( - keySuffix: KeySuffixOptions, - userId?: string - ): Promise { - const key = await this.retrieveKeyFromStorage(keySuffix, userId); - if (key != null) { - const symmetricKey = new SymmetricCryptoKey(Utils.fromB64ToArray(key).buffer); - - if (!(await this.validateKey(symmetricKey))) { - this.logService.warning("Wrong key, throwing away stored key"); - await this.clearSecretKeyStore(userId); - return null; - } - - return symmetricKey; - } - return null; - } - - async getKeyHash(): Promise { - return await this.stateService.getKeyHash(); - } - - async compareAndUpdateKeyHash(masterPassword: string, key: SymmetricCryptoKey): Promise { - const storedKeyHash = await this.getKeyHash(); - if (masterPassword != null && storedKeyHash != null) { - const localKeyHash = await this.hashPassword( - masterPassword, - key, - HashPurpose.LocalAuthorization - ); - if (localKeyHash != null && storedKeyHash === localKeyHash) { - return true; - } - - // TODO: remove serverKeyHash check in 1-2 releases after everyone's keyHash has been updated - const serverKeyHash = await this.hashPassword( - masterPassword, - key, - HashPurpose.ServerAuthorization - ); - if (serverKeyHash != null && storedKeyHash === serverKeyHash) { - await this.setKeyHash(localKeyHash); - return true; - } - } - - return false; - } - - @sequentialize(() => "getEncKey") - getEncKey(key: SymmetricCryptoKey = null): Promise { - return this.getEncKeyHelper(key); - } - - async getPublicKey(): Promise { - const inMemoryPublicKey = await this.stateService.getPublicKey(); - if (inMemoryPublicKey != null) { - return inMemoryPublicKey; - } - - const privateKey = await this.getPrivateKey(); - if (privateKey == null) { - return null; - } - - const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); - await this.stateService.setPublicKey(publicKey); - return publicKey; - } - - async getPrivateKey(): Promise { - const decryptedPrivateKey = await this.stateService.getDecryptedPrivateKey(); - if (decryptedPrivateKey != null) { - return decryptedPrivateKey; - } - - const encPrivateKey = await this.stateService.getEncryptedPrivateKey(); - if (encPrivateKey == null) { - return null; - } - - const privateKey = await this.decryptToBytes(new EncString(encPrivateKey), null); - await this.stateService.setDecryptedPrivateKey(privateKey); - return privateKey; - } - - async getFingerprint(userId: string, publicKey?: ArrayBuffer): Promise { - if (publicKey == null) { - publicKey = await this.getPublicKey(); - } - if (publicKey === null) { - throw new Error("No public key available."); - } - const keyFingerprint = await this.cryptoFunctionService.hash(publicKey, "sha256"); - const userFingerprint = await this.cryptoFunctionService.hkdfExpand( - keyFingerprint, - userId, - 32, - "sha256" - ); - return this.hashPhrase(userFingerprint); - } - - @sequentialize(() => "getOrgKeys") - async getOrgKeys(): Promise> { - const result: Map = new Map(); - const decryptedOrganizationKeys = await this.stateService.getDecryptedOrganizationKeys(); - if (decryptedOrganizationKeys != null && decryptedOrganizationKeys.size > 0) { - return decryptedOrganizationKeys; - } - - const encOrgKeyData = await this.stateService.getEncryptedOrganizationKeys(); - if (encOrgKeyData == null) { - return null; - } - - let setKey = false; - - for (const orgId of Object.keys(encOrgKeyData)) { - if (result.has(orgId)) { - continue; - } - - const encOrgKey = BaseEncryptedOrganizationKey.fromData(encOrgKeyData[orgId]); - const decOrgKey = await encOrgKey.decrypt(this); - result.set(orgId, decOrgKey); - - setKey = true; - } - - if (setKey) { - await this.stateService.setDecryptedOrganizationKeys(result); - } - - return result; - } - - async getOrgKey(orgId: string): Promise { - if (orgId == null) { - return null; - } - - const orgKeys = await this.getOrgKeys(); - if (orgKeys == null || !orgKeys.has(orgId)) { - return null; - } - - return orgKeys.get(orgId); - } - - @sequentialize(() => "getProviderKeys") - async getProviderKeys(): Promise> { - const providerKeys: Map = new Map(); - const decryptedProviderKeys = await this.stateService.getDecryptedProviderKeys(); - if (decryptedProviderKeys != null && decryptedProviderKeys.size > 0) { - return decryptedProviderKeys; - } - - const encProviderKeys = await this.stateService.getEncryptedProviderKeys(); - if (encProviderKeys == null) { - return null; - } - - let setKey = false; - - for (const orgId in encProviderKeys) { - // eslint-disable-next-line - if (!encProviderKeys.hasOwnProperty(orgId)) { - continue; - } - - const decValue = await this.rsaDecrypt(encProviderKeys[orgId]); - providerKeys.set(orgId, new SymmetricCryptoKey(decValue)); - setKey = true; - } - - if (setKey) { - await this.stateService.setDecryptedProviderKeys(providerKeys); - } - - return providerKeys; - } - - async getProviderKey(providerId: string): Promise { - if (providerId == null) { - return null; - } - - const providerKeys = await this.getProviderKeys(); - if (providerKeys == null || !providerKeys.has(providerId)) { - return null; - } - - return providerKeys.get(providerId); - } - - async hasKey(): Promise { - return ( - (await this.hasKeyInMemory()) || - (await this.hasKeyStored(KeySuffixOptions.Auto)) || - (await this.hasKeyStored(KeySuffixOptions.Biometric)) - ); - } - - async hasKeyInMemory(userId?: string): Promise { - return (await this.stateService.getCryptoMasterKey({ userId: userId })) != null; - } - - async hasKeyStored(keySuffix: KeySuffixOptions, userId?: string): Promise { - switch (keySuffix) { - case KeySuffixOptions.Auto: - return (await this.stateService.getCryptoMasterKeyAuto({ userId: userId })) != null; - case KeySuffixOptions.Biometric: - return (await this.stateService.hasCryptoMasterKeyBiometric({ userId: userId })) === true; - default: - return false; - } - } - - async hasEncKey(): Promise { - return (await this.stateService.getEncryptedCryptoSymmetricKey()) != null; - } - - async clearKey(clearSecretStorage = true, userId?: string): Promise { - await this.stateService.setCryptoMasterKey(null, { userId: userId }); - if (clearSecretStorage) { - await this.clearSecretKeyStore(userId); - } - } - - async clearStoredKey(keySuffix: KeySuffixOptions) { - keySuffix === KeySuffixOptions.Auto - ? await this.stateService.setCryptoMasterKeyAuto(null) - : await this.stateService.setCryptoMasterKeyBiometric(null); - } - - async clearKeyHash(userId?: string): Promise { - return await this.stateService.setKeyHash(null, { userId: userId }); - } - - async clearEncKey(memoryOnly?: boolean, userId?: string): Promise { - await this.stateService.setDecryptedCryptoSymmetricKey(null, { userId: userId }); - if (!memoryOnly) { - await this.stateService.setEncryptedCryptoSymmetricKey(null, { userId: userId }); - } - } - - async clearKeyPair(memoryOnly?: boolean, userId?: string): Promise { - const keysToClear: Promise[] = [ - this.stateService.setDecryptedPrivateKey(null, { userId: userId }), - this.stateService.setPublicKey(null, { userId: userId }), - ]; - if (!memoryOnly) { - keysToClear.push(this.stateService.setEncryptedPrivateKey(null, { userId: userId })); - } - return Promise.all(keysToClear); - } - - async clearOrgKeys(memoryOnly?: boolean, userId?: string): Promise { - await this.stateService.setDecryptedOrganizationKeys(null, { userId: userId }); - if (!memoryOnly) { - await this.stateService.setEncryptedOrganizationKeys(null, { userId: userId }); - } - } - - async clearProviderKeys(memoryOnly?: boolean, userId?: string): Promise { - await this.stateService.setDecryptedProviderKeys(null, { userId: userId }); - if (!memoryOnly) { - await this.stateService.setEncryptedProviderKeys(null, { userId: userId }); - } - } - - async clearPinProtectedKey(userId?: string): Promise { - return await this.stateService.setEncryptedPinProtected(null, { userId: userId }); - } - - async clearKeys(userId?: string): Promise { - await this.clearKey(true, userId); - await this.clearKeyHash(userId); - await this.clearOrgKeys(false, userId); - await this.clearProviderKeys(false, userId); - await this.clearEncKey(false, userId); - await this.clearKeyPair(false, userId); - await this.clearPinProtectedKey(userId); - } - - async toggleKey(): Promise { - const key = await this.getKey(); - - await this.setKey(key); - } - - async makeKey( - password: string, - salt: string, - kdf: KdfType, - kdfConfig: KdfConfig - ): Promise { - let key: ArrayBuffer = null; - if (kdf == null || kdf === KdfType.PBKDF2_SHA256) { - if (kdfConfig.iterations == null) { - kdfConfig.iterations = 5000; - } else if (kdfConfig.iterations < 5000) { - throw new Error("PBKDF2 iteration minimum is 5000."); - } - key = await this.cryptoFunctionService.pbkdf2(password, salt, "sha256", kdfConfig.iterations); - } else if (kdf == KdfType.Argon2id) { - if (kdfConfig.iterations == null) { - kdfConfig.iterations = DEFAULT_ARGON2_ITERATIONS; - } else if (kdfConfig.iterations < 2) { - throw new Error("Argon2 iteration minimum is 2."); - } - - if (kdfConfig.memory == null) { - kdfConfig.memory = DEFAULT_ARGON2_MEMORY; - } else if (kdfConfig.memory < 16) { - throw new Error("Argon2 memory minimum is 16 MB"); - } else if (kdfConfig.memory > 1024) { - throw new Error("Argon2 memory maximum is 1024 MB"); - } - - if (kdfConfig.parallelism == null) { - kdfConfig.parallelism = DEFAULT_ARGON2_PARALLELISM; - } else if (kdfConfig.parallelism < 1) { - throw new Error("Argon2 parallelism minimum is 1."); - } - - const saltHash = await this.cryptoFunctionService.hash(salt, "sha256"); - key = await this.cryptoFunctionService.argon2( - password, - saltHash, - kdfConfig.iterations, - kdfConfig.memory * 1024, // convert to KiB from MiB - kdfConfig.parallelism - ); - } else { - throw new Error("Unknown Kdf."); - } - return new SymmetricCryptoKey(key); - } - - async makeKeyFromPin( - pin: string, - salt: string, - kdf: KdfType, - kdfConfig: KdfConfig, - protectedKeyCs: EncString = null - ): Promise { - if (protectedKeyCs == null) { - const pinProtectedKey = await this.stateService.getEncryptedPinProtected(); - if (pinProtectedKey == null) { - throw new Error("No PIN protected key found."); - } - protectedKeyCs = new EncString(pinProtectedKey); - } - const pinKey = await this.makePinKey(pin, salt, kdf, kdfConfig); - const decKey = await this.decryptToBytes(protectedKeyCs, pinKey); - return new SymmetricCryptoKey(decKey); - } - - async makeShareKey(): Promise<[EncString, SymmetricCryptoKey]> { - const shareKey = await this.cryptoFunctionService.randomBytes(64); - const publicKey = await this.getPublicKey(); - const encShareKey = await this.rsaEncrypt(shareKey, publicKey); - return [encShareKey, new SymmetricCryptoKey(shareKey)]; - } - - async makeKeyPair(key?: SymmetricCryptoKey): Promise<[string, EncString]> { - const keyPair = await this.cryptoFunctionService.rsaGenerateKeyPair(2048); - const publicB64 = Utils.fromBufferToB64(keyPair[0]); - const privateEnc = await this.encrypt(keyPair[1], key); - return [publicB64, privateEnc]; - } - - async makePinKey( - pin: string, - salt: string, - kdf: KdfType, - kdfConfig: KdfConfig - ): Promise { - const pinKey = await this.makeKey(pin, salt, kdf, kdfConfig); - return await this.stretchKey(pinKey); - } - - async makeSendKey(keyMaterial: ArrayBuffer): Promise { - const sendKey = await this.cryptoFunctionService.hkdf( - keyMaterial, - "bitwarden-send", - "send", - 64, - "sha256" - ); - return new SymmetricCryptoKey(sendKey); - } - - async hashPassword( - password: string, - key: SymmetricCryptoKey, - hashPurpose?: HashPurpose - ): Promise { - if (key == null) { - key = await this.getKey(); - } - if (password == null || key == null) { - throw new Error("Invalid parameters."); - } - - const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1; - const hash = await this.cryptoFunctionService.pbkdf2(key.key, password, "sha256", iterations); - return Utils.fromBufferToB64(hash); - } - - async makeEncKey(key: SymmetricCryptoKey): Promise<[SymmetricCryptoKey, EncString]> { - const theKey = await this.getKeyForUserEncryption(key); - const encKey = await this.cryptoFunctionService.randomBytes(64); - return this.buildEncKey(theKey, encKey); - } - - async remakeEncKey( - key: SymmetricCryptoKey, - encKey?: SymmetricCryptoKey - ): Promise<[SymmetricCryptoKey, EncString]> { - if (encKey == null) { - encKey = await this.getEncKey(); - } - return this.buildEncKey(key, encKey.key); - } - - /** - * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) - * and then call encryptService.encrypt - */ - async encrypt(plainValue: string | ArrayBuffer, key?: SymmetricCryptoKey): Promise { - key = await this.getKeyForUserEncryption(key); - return await this.encryptService.encrypt(plainValue, key); - } - - /** - * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) - * and then call encryptService.encryptToBytes - */ - async encryptToBytes(plainValue: ArrayBuffer, key?: SymmetricCryptoKey): Promise { - key = await this.getKeyForUserEncryption(key); - return this.encryptService.encryptToBytes(plainValue, key); - } - - async rsaEncrypt(data: ArrayBuffer, publicKey?: ArrayBuffer): Promise { - if (publicKey == null) { - publicKey = await this.getPublicKey(); - } - if (publicKey == null) { - throw new Error("Public key unavailable."); - } - - const encBytes = await this.cryptoFunctionService.rsaEncrypt(data, publicKey, "sha1"); - return new EncString(EncryptionType.Rsa2048_OaepSha1_B64, Utils.fromBufferToB64(encBytes)); - } - - async rsaDecrypt(encValue: string, privateKeyValue?: ArrayBuffer): Promise { - const headerPieces = encValue.split("."); - let encType: EncryptionType = null; - let encPieces: string[]; - - if (headerPieces.length === 1) { - encType = EncryptionType.Rsa2048_OaepSha256_B64; - encPieces = [headerPieces[0]]; - } else if (headerPieces.length === 2) { - try { - encType = parseInt(headerPieces[0], null); - encPieces = headerPieces[1].split("|"); - } catch (e) { - this.logService.error(e); - } - } - - switch (encType) { - case EncryptionType.Rsa2048_OaepSha256_B64: - case EncryptionType.Rsa2048_OaepSha1_B64: - case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64: // HmacSha256 types are deprecated - case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64: - break; - default: - throw new Error("encType unavailable."); - } - - if (encPieces == null || encPieces.length <= 0) { - throw new Error("encPieces unavailable."); - } - - const data = Utils.fromB64ToArray(encPieces[0]).buffer; - const privateKey = privateKeyValue ?? (await this.getPrivateKey()); - if (privateKey == null) { - throw new Error("No private key."); - } - - let alg: "sha1" | "sha256" = "sha1"; - switch (encType) { - case EncryptionType.Rsa2048_OaepSha256_B64: - case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64: - alg = "sha256"; - break; - case EncryptionType.Rsa2048_OaepSha1_B64: - case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64: - break; - default: - throw new Error("encType unavailable."); - } - - return this.cryptoFunctionService.rsaDecrypt(data, privateKey, alg); - } - - /** - * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) - * and then call encryptService.decryptToBytes - */ - async decryptToBytes(encString: EncString, key?: SymmetricCryptoKey): Promise { - const keyForEnc = await this.getKeyForUserEncryption(key); - return this.encryptService.decryptToBytes(encString, keyForEnc); - } - - /** - * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) - * and then call encryptService.decryptToUtf8 - */ - async decryptToUtf8(encString: EncString, key?: SymmetricCryptoKey): Promise { - key = await this.getKeyForUserEncryption(key); - return await this.encryptService.decryptToUtf8(encString, key); - } - - /** - * @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey) - * and then call encryptService.decryptToBytes - */ - async decryptFromBytes(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise { - if (encBuffer == null) { - throw new Error("No buffer provided for decryption."); - } - - key = await this.getKeyForUserEncryption(key); - - return this.encryptService.decryptToBytes(encBuffer, key); - } - - // EFForg/OpenWireless - // ref https://github.com/EFForg/OpenWireless/blob/master/app/js/diceware.js - async randomNumber(min: number, max: number): Promise { - let rval = 0; - const range = max - min + 1; - const bitsNeeded = Math.ceil(Math.log2(range)); - if (bitsNeeded > 53) { - throw new Error("We cannot generate numbers larger than 53 bits."); - } - - const bytesNeeded = Math.ceil(bitsNeeded / 8); - const mask = Math.pow(2, bitsNeeded) - 1; - // 7776 -> (2^13 = 8192) -1 == 8191 or 0x00001111 11111111 - - // Fill a byte array with N random numbers - const byteArray = new Uint8Array(await this.cryptoFunctionService.randomBytes(bytesNeeded)); - - let p = (bytesNeeded - 1) * 8; - for (let i = 0; i < bytesNeeded; i++) { - rval += byteArray[i] * Math.pow(2, p); - p -= 8; - } - - // Use & to apply the mask and reduce the number of recursive lookups - rval = rval & mask; - - if (rval >= range) { - // Integer out of acceptable range - return this.randomNumber(min, max); - } - - // Return an integer that falls within the range - return min + rval; - } - - async validateKey(key: SymmetricCryptoKey) { - try { - const encPrivateKey = await this.stateService.getEncryptedPrivateKey(); - const encKey = await this.getEncKeyHelper(key); - if (encPrivateKey == null || encKey == null) { - return false; - } - - const privateKey = await this.decryptToBytes(new EncString(encPrivateKey), encKey); - await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); - } catch (e) { - return false; - } - - return true; - } - - // ---HELPERS--- - - protected async storeKey(key: SymmetricCryptoKey, userId?: string) { - const storeAuto = await this.shouldStoreKey(KeySuffixOptions.Auto, userId); - - if (storeAuto) { - await this.storeAutoKey(key, userId); - } else { - await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId }); - } - } - - protected async storeAutoKey(key: SymmetricCryptoKey, userId?: string) { - await this.stateService.setCryptoMasterKeyAuto(key.keyB64, { userId: userId }); - } - - protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: string) { - let shouldStoreKey = false; - if (keySuffix === KeySuffixOptions.Auto) { - const vaultTimeout = await this.stateService.getVaultTimeout({ userId: userId }); - shouldStoreKey = vaultTimeout == null; - } else if (keySuffix === KeySuffixOptions.Biometric) { - const biometricUnlock = await this.stateService.getBiometricUnlock({ userId: userId }); - shouldStoreKey = biometricUnlock && this.platformUtilService.supportsSecureStorage(); - } - return shouldStoreKey; - } - - protected async retrieveKeyFromStorage(keySuffix: KeySuffixOptions, userId?: string) { - return keySuffix === KeySuffixOptions.Auto - ? await this.stateService.getCryptoMasterKeyAuto({ userId: userId }) - : await this.stateService.getCryptoMasterKeyBiometric({ userId: userId }); - } - - async getKeyForUserEncryption(key?: SymmetricCryptoKey): Promise { - if (key != null) { - return key; - } - - const encKey = await this.getEncKey(); - if (encKey != null) { - return encKey; - } - - // Legacy support: encryption used to be done with the user key (derived from master password). - // Users who have not migrated will have a null encKey and must use the user key instead. - return await this.getKey(); - } - - private async stretchKey(key: SymmetricCryptoKey): Promise { - const newKey = new Uint8Array(64); - const encKey = await this.cryptoFunctionService.hkdfExpand(key.key, "enc", 32, "sha256"); - const macKey = await this.cryptoFunctionService.hkdfExpand(key.key, "mac", 32, "sha256"); - newKey.set(new Uint8Array(encKey)); - newKey.set(new Uint8Array(macKey), 32); - return new SymmetricCryptoKey(newKey.buffer); - } - - private async hashPhrase(hash: ArrayBuffer, minimumEntropy = 64) { - const entropyPerWord = Math.log(EFFLongWordList.length) / Math.log(2); - let numWords = Math.ceil(minimumEntropy / entropyPerWord); - - const hashArr = Array.from(new Uint8Array(hash)); - const entropyAvailable = hashArr.length * 4; - if (numWords * entropyPerWord > entropyAvailable) { - throw new Error("Output entropy of hash function is too small"); - } - - const phrase: string[] = []; - let hashNumber = bigInt.fromArray(hashArr, 256); - while (numWords--) { - const remainder = hashNumber.mod(EFFLongWordList.length); - hashNumber = hashNumber.divide(EFFLongWordList.length); - phrase.push(EFFLongWordList[remainder as any]); - } - return phrase; - } - - private async buildEncKey( - key: SymmetricCryptoKey, - encKey: ArrayBuffer - ): Promise<[SymmetricCryptoKey, EncString]> { - let encKeyEnc: EncString = null; - if (key.key.byteLength === 32) { - const newKey = await this.stretchKey(key); - encKeyEnc = await this.encrypt(encKey, newKey); - } else if (key.key.byteLength === 64) { - encKeyEnc = await this.encrypt(encKey, key); - } else { - throw new Error("Invalid key size."); - } - return [new SymmetricCryptoKey(encKey), encKeyEnc]; - } - - private async clearSecretKeyStore(userId?: string): Promise { - await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId }); - await this.stateService.setCryptoMasterKeyBiometric(null, { userId: userId }); - } - - private async getEncKeyHelper(key: SymmetricCryptoKey = null): Promise { - const inMemoryKey = await this.stateService.getDecryptedCryptoSymmetricKey(); - if (inMemoryKey != null) { - return inMemoryKey; - } - - const encKey = await this.stateService.getEncryptedCryptoSymmetricKey(); - if (encKey == null) { - return null; - } - - if (key == null) { - key = await this.getKey(); - } - if (key == null) { - return null; - } - - let decEncKey: ArrayBuffer; - const encKeyCipher = new EncString(encKey); - if (encKeyCipher.encryptionType === EncryptionType.AesCbc256_B64) { - decEncKey = await this.decryptToBytes(encKeyCipher, key); - } else if (encKeyCipher.encryptionType === EncryptionType.AesCbc256_HmacSha256_B64) { - const newKey = await this.stretchKey(key); - decEncKey = await this.decryptToBytes(encKeyCipher, newKey); - } else { - throw new Error("Unsupported encKey type."); - } - if (decEncKey == null) { - return null; - } - const symmetricCryptoKey = new SymmetricCryptoKey(decEncKey); - await this.stateService.setDecryptedCryptoSymmetricKey(symmetricCryptoKey); - return symmetricCryptoKey; - } -} diff --git a/libs/common/src/services/device-crypto.service.implementation.ts b/libs/common/src/services/device-crypto.service.implementation.ts deleted file mode 100644 index ba50e300b42..00000000000 --- a/libs/common/src/services/device-crypto.service.implementation.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { AppIdService } from "../abstractions/appId.service"; -import { CryptoService } from "../abstractions/crypto.service"; -import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; -import { DeviceCryptoServiceAbstraction } from "../abstractions/device-crypto.service.abstraction"; -import { DevicesApiServiceAbstraction } from "../abstractions/devices/devices-api.service.abstraction"; -import { DeviceResponse } from "../abstractions/devices/responses/device.response"; -import { EncryptService } from "../abstractions/encrypt.service"; -import { StateService } from "../abstractions/state.service"; -import { DeviceKey, SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; -import { CsprngArray } from "../types/csprng"; - -export class DeviceCryptoService implements DeviceCryptoServiceAbstraction { - constructor( - protected cryptoFunctionService: CryptoFunctionService, - protected cryptoService: CryptoService, - protected encryptService: EncryptService, - protected stateService: StateService, - protected appIdService: AppIdService, - protected devicesApiService: DevicesApiServiceAbstraction - ) {} - - async trustDevice(): Promise { - // Attempt to get user symmetric key - const userSymKey: SymmetricCryptoKey = await this.cryptoService.getEncKey(); - - // If user symmetric key is not found, throw error - if (!userSymKey) { - throw new Error("User symmetric key not found"); - } - - // Generate deviceKey - const deviceKey = await this.makeDeviceKey(); - - // Generate asymmetric RSA key pair: devicePrivateKey, devicePublicKey - const [devicePublicKey, devicePrivateKey] = await this.cryptoFunctionService.rsaGenerateKeyPair( - 2048 - ); - - const [ - devicePublicKeyEncryptedUserSymKey, - userSymKeyEncryptedDevicePublicKey, - deviceKeyEncryptedDevicePrivateKey, - ] = await Promise.all([ - // Encrypt user symmetric key with the DevicePublicKey - this.cryptoService.rsaEncrypt(userSymKey.encKey, devicePublicKey), - - // Encrypt devicePublicKey with user symmetric key - this.encryptService.encrypt(devicePublicKey, userSymKey), - - // Encrypt devicePrivateKey with deviceKey - this.encryptService.encrypt(devicePrivateKey, deviceKey), - ]); - - // Send encrypted keys to server - const deviceIdentifier = await this.appIdService.getAppId(); - return this.devicesApiService.updateTrustedDeviceKeys( - deviceIdentifier, - devicePublicKeyEncryptedUserSymKey.encryptedString, - userSymKeyEncryptedDevicePublicKey.encryptedString, - deviceKeyEncryptedDevicePrivateKey.encryptedString - ); - } - - async getDeviceKey(): Promise { - // Check if device key is already stored - const existingDeviceKey = await this.stateService.getDeviceKey(); - - if (existingDeviceKey != null) { - return existingDeviceKey; - } else { - return this.makeDeviceKey(); - } - } - - private async makeDeviceKey(): Promise { - // Create 512-bit device key - const randomBytes: CsprngArray = await this.cryptoFunctionService.randomBytes(64); - const deviceKey = new SymmetricCryptoKey(randomBytes) as DeviceKey; - - // Save device key in secure storage - await this.stateService.setDeviceKey(deviceKey); - - return deviceKey; - } -} diff --git a/libs/common/src/services/device-crypto.service.spec.ts b/libs/common/src/services/device-crypto.service.spec.ts deleted file mode 100644 index 7e14961cc2c..00000000000 --- a/libs/common/src/services/device-crypto.service.spec.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { mock, mockReset } from "jest-mock-extended"; - -import { AppIdService } from "../abstractions/appId.service"; -import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; -import { DevicesApiServiceAbstraction } from "../abstractions/devices/devices-api.service.abstraction"; -import { DeviceResponse } from "../abstractions/devices/responses/device.response"; -import { EncryptService } from "../abstractions/encrypt.service"; -import { StateService } from "../abstractions/state.service"; -import { EncryptionType } from "../enums/encryption-type.enum"; -import { EncString } from "../models/domain/enc-string"; -import { DeviceKey, SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; -import { CryptoService } from "../services/crypto.service"; -import { CsprngArray } from "../types/csprng"; - -import { DeviceCryptoService } from "./device-crypto.service.implementation"; - -describe("deviceCryptoService", () => { - let deviceCryptoService: DeviceCryptoService; - - const cryptoFunctionService = mock(); - const cryptoService = mock(); - const encryptService = mock(); - const stateService = mock(); - const appIdService = mock(); - const devicesApiService = mock(); - - beforeEach(() => { - mockReset(cryptoFunctionService); - mockReset(encryptService); - mockReset(stateService); - mockReset(appIdService); - mockReset(devicesApiService); - - deviceCryptoService = new DeviceCryptoService( - cryptoFunctionService, - cryptoService, - encryptService, - stateService, - appIdService, - devicesApiService - ); - }); - - it("instantiates", () => { - expect(deviceCryptoService).not.toBeFalsy(); - }); - - describe("Trusted Device Encryption", () => { - const deviceKeyBytesLength = 64; - const userSymKeyBytesLength = 64; - - describe("getDeviceKey", () => { - let mockRandomBytes: CsprngArray; - let mockDeviceKey: SymmetricCryptoKey; - let existingDeviceKey: DeviceKey; - let stateSvcGetDeviceKeySpy: jest.SpyInstance; - let makeDeviceKeySpy: jest.SpyInstance; - - beforeEach(() => { - mockRandomBytes = new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray; - mockDeviceKey = new SymmetricCryptoKey(mockRandomBytes); - existingDeviceKey = new SymmetricCryptoKey( - new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray - ) as DeviceKey; - - stateSvcGetDeviceKeySpy = jest.spyOn(stateService, "getDeviceKey"); - makeDeviceKeySpy = jest.spyOn(deviceCryptoService as any, "makeDeviceKey"); - }); - - it("gets a device key when there is not an existing device key", async () => { - stateSvcGetDeviceKeySpy.mockResolvedValue(null); - makeDeviceKeySpy.mockResolvedValue(mockDeviceKey); - - const deviceKey = await deviceCryptoService.getDeviceKey(); - - expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1); - expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1); - - expect(deviceKey).not.toBeNull(); - expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); - expect(deviceKey).toEqual(mockDeviceKey); - }); - - it("returns the existing device key without creating a new one when there is an existing device key", async () => { - stateSvcGetDeviceKeySpy.mockResolvedValue(existingDeviceKey); - - const deviceKey = await deviceCryptoService.getDeviceKey(); - - expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1); - expect(makeDeviceKeySpy).not.toHaveBeenCalled(); - - expect(deviceKey).not.toBeNull(); - expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); - expect(deviceKey).toEqual(existingDeviceKey); - }); - }); - - describe("makeDeviceKey", () => { - it("creates a new non-null 64 byte device key, securely stores it, and returns it", async () => { - const mockRandomBytes = new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray; - - const cryptoFuncSvcRandomBytesSpy = jest - .spyOn(cryptoFunctionService, "randomBytes") - .mockResolvedValue(mockRandomBytes); - - const stateSvcSetDeviceKeySpy = jest.spyOn(stateService, "setDeviceKey"); - - // TypeScript will allow calling private methods if the object is of type 'any' - // This is a hacky workaround, but it allows for cleaner tests - const deviceKey = await (deviceCryptoService as any).makeDeviceKey(); - - expect(cryptoFuncSvcRandomBytesSpy).toHaveBeenCalledTimes(1); - expect(cryptoFuncSvcRandomBytesSpy).toHaveBeenCalledWith(deviceKeyBytesLength); - - expect(deviceKey).not.toBeNull(); - expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); - - expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledTimes(1); - expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledWith(deviceKey); - }); - }); - - describe("trustDevice", () => { - let mockDeviceKeyRandomBytes: CsprngArray; - let mockDeviceKey: DeviceKey; - - let mockUserSymKeyRandomBytes: CsprngArray; - let mockUserSymKey: SymmetricCryptoKey; - - const deviceRsaKeyLength = 2048; - let mockDeviceRsaKeyPair: [ArrayBuffer, ArrayBuffer]; - let mockDevicePrivateKey: ArrayBuffer; - let mockDevicePublicKey: ArrayBuffer; - let mockDevicePublicKeyEncryptedUserSymKey: EncString; - let mockUserSymKeyEncryptedDevicePublicKey: EncString; - let mockDeviceKeyEncryptedDevicePrivateKey: EncString; - - const mockDeviceResponse: DeviceResponse = new DeviceResponse({ - Id: "mockId", - Name: "mockName", - Identifier: "mockIdentifier", - Type: "mockType", - CreationDate: "mockCreationDate", - }); - - const mockDeviceId = "mockDeviceId"; - - let makeDeviceKeySpy: jest.SpyInstance; - let rsaGenerateKeyPairSpy: jest.SpyInstance; - let cryptoSvcGetEncKeySpy: jest.SpyInstance; - let cryptoSvcRsaEncryptSpy: jest.SpyInstance; - let encryptServiceEncryptSpy: jest.SpyInstance; - let appIdServiceGetAppIdSpy: jest.SpyInstance; - let devicesApiServiceUpdateTrustedDeviceKeysSpy: jest.SpyInstance; - - beforeEach(() => { - // Setup all spies and default return values for the happy path - - mockDeviceKeyRandomBytes = new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray; - mockDeviceKey = new SymmetricCryptoKey(mockDeviceKeyRandomBytes) as DeviceKey; - - mockUserSymKeyRandomBytes = new Uint8Array(userSymKeyBytesLength).buffer as CsprngArray; - mockUserSymKey = new SymmetricCryptoKey(mockUserSymKeyRandomBytes); - - mockDeviceRsaKeyPair = [ - new ArrayBuffer(deviceRsaKeyLength), - new ArrayBuffer(deviceRsaKeyLength), - ]; - - mockDevicePublicKey = mockDeviceRsaKeyPair[0]; - mockDevicePrivateKey = mockDeviceRsaKeyPair[1]; - - mockDevicePublicKeyEncryptedUserSymKey = new EncString( - EncryptionType.Rsa2048_OaepSha1_B64, - "mockDevicePublicKeyEncryptedUserSymKey" - ); - - mockUserSymKeyEncryptedDevicePublicKey = new EncString( - EncryptionType.AesCbc256_HmacSha256_B64, - "mockUserSymKeyEncryptedDevicePublicKey" - ); - - mockDeviceKeyEncryptedDevicePrivateKey = new EncString( - EncryptionType.AesCbc256_HmacSha256_B64, - "mockDeviceKeyEncryptedDevicePrivateKey" - ); - - // TypeScript will allow calling private methods if the object is of type 'any' - makeDeviceKeySpy = jest - .spyOn(deviceCryptoService as any, "makeDeviceKey") - .mockResolvedValue(mockDeviceKey); - - rsaGenerateKeyPairSpy = jest - .spyOn(cryptoFunctionService, "rsaGenerateKeyPair") - .mockResolvedValue(mockDeviceRsaKeyPair); - - cryptoSvcGetEncKeySpy = jest - .spyOn(cryptoService, "getEncKey") - .mockResolvedValue(mockUserSymKey); - - cryptoSvcRsaEncryptSpy = jest - .spyOn(cryptoService, "rsaEncrypt") - .mockResolvedValue(mockDevicePublicKeyEncryptedUserSymKey); - - encryptServiceEncryptSpy = jest - .spyOn(encryptService, "encrypt") - .mockImplementation((plainValue, key) => { - if (plainValue === mockDevicePublicKey && key === mockUserSymKey) { - return Promise.resolve(mockUserSymKeyEncryptedDevicePublicKey); - } - if (plainValue === mockDevicePrivateKey && key === mockDeviceKey) { - return Promise.resolve(mockDeviceKeyEncryptedDevicePrivateKey); - } - }); - - appIdServiceGetAppIdSpy = jest - .spyOn(appIdService, "getAppId") - .mockResolvedValue(mockDeviceId); - - devicesApiServiceUpdateTrustedDeviceKeysSpy = jest - .spyOn(devicesApiService, "updateTrustedDeviceKeys") - .mockResolvedValue(mockDeviceResponse); - }); - - it("calls the required methods with the correct arguments and returns a DeviceResponse", async () => { - const response = await deviceCryptoService.trustDevice(); - - expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1); - expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1); - expect(cryptoSvcGetEncKeySpy).toHaveBeenCalledTimes(1); - - expect(cryptoSvcRsaEncryptSpy).toHaveBeenCalledTimes(1); - expect(encryptServiceEncryptSpy).toHaveBeenCalledTimes(2); - - expect(appIdServiceGetAppIdSpy).toHaveBeenCalledTimes(1); - expect(devicesApiServiceUpdateTrustedDeviceKeysSpy).toHaveBeenCalledTimes(1); - expect(devicesApiServiceUpdateTrustedDeviceKeysSpy).toHaveBeenCalledWith( - mockDeviceId, - mockDevicePublicKeyEncryptedUserSymKey.encryptedString, - mockUserSymKeyEncryptedDevicePublicKey.encryptedString, - mockDeviceKeyEncryptedDevicePrivateKey.encryptedString - ); - - expect(response).toBeInstanceOf(DeviceResponse); - expect(response).toEqual(mockDeviceResponse); - }); - - it("throws specific error if user symmetric key is not found", async () => { - // setup the spy to return null - cryptoSvcGetEncKeySpy.mockResolvedValue(null); - // check if the expected error is thrown - await expect(deviceCryptoService.trustDevice()).rejects.toThrow( - "User symmetric key not found" - ); - - // reset the spy - cryptoSvcGetEncKeySpy.mockReset(); - - // setup the spy to return undefined - cryptoSvcGetEncKeySpy.mockResolvedValue(undefined); - // check if the expected error is thrown - await expect(deviceCryptoService.trustDevice()).rejects.toThrow( - "User symmetric key not found" - ); - }); - - const methodsToTestForErrorsOrInvalidReturns = [ - { - method: "makeDeviceKey", - spy: () => makeDeviceKeySpy, - errorText: "makeDeviceKey error", - }, - { - method: "rsaGenerateKeyPair", - spy: () => rsaGenerateKeyPairSpy, - errorText: "rsaGenerateKeyPair error", - }, - { - method: "getEncKey", - spy: () => cryptoSvcGetEncKeySpy, - errorText: "getEncKey error", - }, - { - method: "rsaEncrypt", - spy: () => cryptoSvcRsaEncryptSpy, - errorText: "rsaEncrypt error", - }, - { - method: "encryptService.encrypt", - spy: () => encryptServiceEncryptSpy, - errorText: "encryptService.encrypt error", - }, - ]; - - describe.each(methodsToTestForErrorsOrInvalidReturns)( - "trustDevice error handling and invalid return testing", - ({ method, spy, errorText }) => { - // ensures that error propagation works correctly - it(`throws an error if ${method} fails`, async () => { - const methodSpy = spy(); - methodSpy.mockRejectedValue(new Error(errorText)); - await expect(deviceCryptoService.trustDevice()).rejects.toThrow(errorText); - }); - - test.each([null, undefined])( - `throws an error if ${method} returns %s`, - async (invalidValue) => { - const methodSpy = spy(); - methodSpy.mockResolvedValue(invalidValue); - await expect(deviceCryptoService.trustDevice()).rejects.toThrow(); - } - ); - } - ); - }); - }); -}); diff --git a/libs/common/src/services/devices/devices-api.service.implementation.ts b/libs/common/src/services/devices/devices-api.service.implementation.ts deleted file mode 100644 index aa0d0f0c297..00000000000 --- a/libs/common/src/services/devices/devices-api.service.implementation.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { DevicesApiServiceAbstraction } from "../../abstractions/devices/devices-api.service.abstraction"; -import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; -import { Utils } from "../../misc/utils"; -import { ApiService } from "../api.service"; - -import { TrustedDeviceKeysRequest } from "./requests/trusted-device-keys.request"; - -export class DevicesApiServiceImplementation implements DevicesApiServiceAbstraction { - constructor(private apiService: ApiService) {} - - async getKnownDevice(email: string, deviceIdentifier: string): Promise { - const r = await this.apiService.send( - "GET", - "/devices/knowndevice", - null, - false, - true, - null, - (headers) => { - headers.set("X-Device-Identifier", deviceIdentifier); - headers.set("X-Request-Email", Utils.fromUtf8ToUrlB64(email)); - } - ); - return r as boolean; - } - - /** - * Get device by identifier - * @param deviceIdentifier - client generated id (not device id in DB) - */ - async getDeviceByIdentifier(deviceIdentifier: string): Promise { - const r = await this.apiService.send( - "GET", - `/devices/identifier/${deviceIdentifier}`, - null, - true, - true - ); - return new DeviceResponse(r); - } - - async updateTrustedDeviceKeys( - deviceIdentifier: string, - devicePublicKeyEncryptedUserSymKey: string, - userSymKeyEncryptedDevicePublicKey: string, - deviceKeyEncryptedDevicePrivateKey: string - ): Promise { - const request = new TrustedDeviceKeysRequest( - devicePublicKeyEncryptedUserSymKey, - userSymKeyEncryptedDevicePublicKey, - deviceKeyEncryptedDevicePrivateKey - ); - - const result = await this.apiService.send( - "PUT", - `/devices/${deviceIdentifier}/keys`, - request, - true, - true - ); - - return new DeviceResponse(result); - } -} diff --git a/libs/common/src/services/devices/devices.service.implementation.ts b/libs/common/src/services/devices/devices.service.implementation.ts new file mode 100644 index 00000000000..fe6e2a37d2a --- /dev/null +++ b/libs/common/src/services/devices/devices.service.implementation.ts @@ -0,0 +1,68 @@ +import { Observable, defer, map } from "rxjs"; + +import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction"; +import { DeviceResponse } from "../../abstractions/devices/responses/device.response"; +import { DeviceView } from "../../abstractions/devices/views/device.view"; +import { DevicesApiServiceAbstraction } from "../../auth/abstractions/devices-api.service.abstraction"; +import { ListResponse } from "../../models/response/list.response"; + +/** + * @class DevicesServiceImplementation + * @implements {DevicesServiceAbstraction} + * @description Observable based data store service for Devices. + * note: defer is used to convert the promises to observables and to ensure + * that observables are created for each subscription + * (i.e., promsise --> observables are cold until subscribed to) + */ +export class DevicesServiceImplementation implements DevicesServiceAbstraction { + constructor(private devicesApiService: DevicesApiServiceAbstraction) {} + + /** + * @description Gets the list of all devices. + */ + getDevices$(): Observable> { + return defer(() => this.devicesApiService.getDevices()).pipe( + map((deviceResponses: ListResponse) => { + return deviceResponses.data.map((deviceResponse: DeviceResponse) => { + return new DeviceView(deviceResponse); + }); + }) + ); + } + + /** + * @description Gets the device with the specified identifier. + */ + getDeviceByIdentifier$(deviceIdentifier: string): Observable { + return defer(() => this.devicesApiService.getDeviceByIdentifier(deviceIdentifier)).pipe( + map((deviceResponse: DeviceResponse) => new DeviceView(deviceResponse)) + ); + } + + /** + * @description Checks if a device is known for a user by user's email and device's identifier. + */ + isDeviceKnownForUser$(email: string, deviceIdentifier: string): Observable { + return defer(() => this.devicesApiService.getKnownDevice(email, deviceIdentifier)); + } + + /** + * @description Updates the keys for the specified device. + */ + + updateTrustedDeviceKeys$( + deviceIdentifier: string, + devicePublicKeyEncryptedUserKey: string, + userKeyEncryptedDevicePublicKey: string, + deviceKeyEncryptedDevicePrivateKey: string + ): Observable { + return defer(() => + this.devicesApiService.updateTrustedDeviceKeys( + deviceIdentifier, + devicePublicKeyEncryptedUserKey, + userKeyEncryptedDevicePublicKey, + deviceKeyEncryptedDevicePrivateKey + ) + ).pipe(map((deviceResponse: DeviceResponse) => new DeviceView(deviceResponse))); + } +} diff --git a/libs/common/src/services/environment.service.ts b/libs/common/src/services/environment.service.ts deleted file mode 100644 index 2c6df478ebe..00000000000 --- a/libs/common/src/services/environment.service.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { concatMap, Observable, Subject } from "rxjs"; - -import { - EnvironmentService as EnvironmentServiceAbstraction, - Urls, -} from "../abstractions/environment.service"; -import { StateService } from "../abstractions/state.service"; -import { EnvironmentUrls } from "../auth/models/domain/environment-urls"; - -export class EnvironmentService implements EnvironmentServiceAbstraction { - private readonly urlsSubject = new Subject(); - urls: Observable = this.urlsSubject; - - protected baseUrl: string; - protected webVaultUrl: string; - protected apiUrl: string; - protected identityUrl: string; - protected iconsUrl: string; - protected notificationsUrl: string; - protected eventsUrl: string; - private keyConnectorUrl: string; - private scimUrl: string = null; - - constructor(private stateService: StateService) { - this.stateService.activeAccount$ - .pipe( - concatMap(async () => { - await this.setUrlsFromStorage(); - }) - ) - .subscribe(); - } - - hasBaseUrl() { - return this.baseUrl != null; - } - - getNotificationsUrl() { - if (this.notificationsUrl != null) { - return this.notificationsUrl; - } - - if (this.baseUrl != null) { - return this.baseUrl + "/notifications"; - } - - return "https://notifications.bitwarden.com"; - } - - getWebVaultUrl() { - if (this.webVaultUrl != null) { - return this.webVaultUrl; - } - - if (this.baseUrl) { - return this.baseUrl; - } - return "https://vault.bitwarden.com"; - } - - getSendUrl() { - return this.getWebVaultUrl() === "https://vault.bitwarden.com" - ? "https://send.bitwarden.com/#" - : this.getWebVaultUrl() + "/#/send/"; - } - - getIconsUrl() { - if (this.iconsUrl != null) { - return this.iconsUrl; - } - - if (this.baseUrl) { - return this.baseUrl + "/icons"; - } - - return "https://icons.bitwarden.net"; - } - - getApiUrl() { - if (this.apiUrl != null) { - return this.apiUrl; - } - - if (this.baseUrl) { - return this.baseUrl + "/api"; - } - - return "https://api.bitwarden.com"; - } - - getIdentityUrl() { - if (this.identityUrl != null) { - return this.identityUrl; - } - - if (this.baseUrl) { - return this.baseUrl + "/identity"; - } - - return "https://identity.bitwarden.com"; - } - - getEventsUrl() { - if (this.eventsUrl != null) { - return this.eventsUrl; - } - - if (this.baseUrl) { - return this.baseUrl + "/events"; - } - - return "https://events.bitwarden.com"; - } - - getKeyConnectorUrl() { - return this.keyConnectorUrl; - } - - getScimUrl() { - if (this.scimUrl != null) { - return this.scimUrl + "/v2"; - } - - return this.getWebVaultUrl() === "https://vault.bitwarden.com" - ? "https://scim.bitwarden.com/v2" - : this.getWebVaultUrl() + "/scim/v2"; - } - - async setUrlsFromStorage(): Promise { - const urls: any = await this.stateService.getEnvironmentUrls(); - const envUrls = new EnvironmentUrls(); - - this.baseUrl = envUrls.base = urls.base; - this.webVaultUrl = urls.webVault; - this.apiUrl = envUrls.api = urls.api; - this.identityUrl = envUrls.identity = urls.identity; - this.iconsUrl = urls.icons; - this.notificationsUrl = urls.notifications; - this.eventsUrl = envUrls.events = urls.events; - this.keyConnectorUrl = urls.keyConnector; - // scimUrl is not saved to storage - } - - async setUrls(urls: Urls): Promise { - urls.base = this.formatUrl(urls.base); - urls.webVault = this.formatUrl(urls.webVault); - urls.api = this.formatUrl(urls.api); - urls.identity = this.formatUrl(urls.identity); - urls.icons = this.formatUrl(urls.icons); - urls.notifications = this.formatUrl(urls.notifications); - urls.events = this.formatUrl(urls.events); - urls.keyConnector = this.formatUrl(urls.keyConnector); - - // scimUrl cannot be cleared - urls.scim = this.formatUrl(urls.scim) ?? this.scimUrl; - - await this.stateService.setEnvironmentUrls({ - base: urls.base, - api: urls.api, - identity: urls.identity, - webVault: urls.webVault, - icons: urls.icons, - notifications: urls.notifications, - events: urls.events, - keyConnector: urls.keyConnector, - // scimUrl is not saved to storage - }); - - this.baseUrl = urls.base; - this.webVaultUrl = urls.webVault; - this.apiUrl = urls.api; - this.identityUrl = urls.identity; - this.iconsUrl = urls.icons; - this.notificationsUrl = urls.notifications; - this.eventsUrl = urls.events; - this.keyConnectorUrl = urls.keyConnector; - this.scimUrl = urls.scim; - - this.urlsSubject.next(urls); - - return urls; - } - - getUrls() { - return { - base: this.baseUrl, - webVault: this.webVaultUrl, - api: this.apiUrl, - identity: this.identityUrl, - icons: this.iconsUrl, - notifications: this.notificationsUrl, - events: this.eventsUrl, - keyConnector: this.keyConnectorUrl, - scim: this.scimUrl, - }; - } - - private formatUrl(url: string): string { - if (url == null || url === "") { - return null; - } - - url = url.replace(/\/+$/g, ""); - if (!url.startsWith("http://") && !url.startsWith("https://")) { - url = "https://" + url; - } - - return url.trim(); - } - - isCloud(): boolean { - return ["https://api.bitwarden.com", "https://vault.bitwarden.com/api"].includes( - this.getApiUrl() - ); - } - - isSelfHosted(): boolean { - return ![ - "http://vault.bitwarden.com", - "https://vault.bitwarden.com", - "http://vault.bitwarden.eu", - "https://vault.bitwarden.eu", - "http://vault.qa.bitwarden.pw", - "https://vault.qa.bitwarden.pw", - ].includes(this.getWebVaultUrl()); - } -} diff --git a/libs/common/src/services/event/event-collection.service.ts b/libs/common/src/services/event/event-collection.service.ts index 4f16b887788..d85d333be7f 100644 --- a/libs/common/src/services/event/event-collection.service.ts +++ b/libs/common/src/services/event/event-collection.service.ts @@ -1,9 +1,9 @@ import { EventCollectionService as EventCollectionServiceAbstraction } from "../../abstractions/event/event-collection.service"; import { EventUploadService } from "../../abstractions/event/event-upload.service"; -import { StateService } from "../../abstractions/state.service"; import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; import { EventType } from "../../enums"; import { EventData } from "../../models/data/event.data"; +import { StateService } from "../../platform/abstractions/state.service"; import { CipherService } from "../../vault/abstractions/cipher.service"; export class EventCollectionService implements EventCollectionServiceAbstraction { diff --git a/libs/common/src/services/event/event-upload.service.ts b/libs/common/src/services/event/event-upload.service.ts index ca118ea7e8f..99c7f394fdf 100644 --- a/libs/common/src/services/event/event-upload.service.ts +++ b/libs/common/src/services/event/event-upload.service.ts @@ -1,8 +1,8 @@ import { ApiService } from "../../abstractions/api.service"; import { EventUploadService as EventUploadServiceAbstraction } from "../../abstractions/event/event-upload.service"; -import { LogService } from "../../abstractions/log.service"; -import { StateService } from "../../abstractions/state.service"; import { EventRequest } from "../../models/request/event.request"; +import { LogService } from "../../platform/abstractions/log.service"; +import { StateService } from "../../platform/abstractions/state.service"; export class EventUploadService implements EventUploadServiceAbstraction { private inited = false; diff --git a/libs/common/src/services/notifications.service.ts b/libs/common/src/services/notifications.service.ts index 1f83b87fea9..79b120c737c 100644 --- a/libs/common/src/services/notifications.service.ts +++ b/libs/common/src/services/notifications.service.ts @@ -2,12 +2,7 @@ import * as signalR from "@microsoft/signalr"; import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack"; import { ApiService } from "../abstractions/api.service"; -import { AppIdService } from "../abstractions/appId.service"; -import { EnvironmentService } from "../abstractions/environment.service"; -import { LogService } from "../abstractions/log.service"; -import { MessagingService } from "../abstractions/messaging.service"; import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service"; -import { StateService } from "../abstractions/state.service"; import { AuthService } from "../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../auth/enums/authentication-status"; import { NotificationType } from "../enums"; @@ -17,6 +12,11 @@ import { SyncFolderNotification, SyncSendNotification, } from "../models/response/notification.response"; +import { AppIdService } from "../platform/abstractions/app-id.service"; +import { EnvironmentService } from "../platform/abstractions/environment.service"; +import { LogService } from "../platform/abstractions/log.service"; +import { MessagingService } from "../platform/abstractions/messaging.service"; +import { StateService } from "../platform/abstractions/state.service"; import { SyncService } from "../vault/abstractions/sync/sync.service.abstraction"; export class NotificationsService implements NotificationsServiceAbstraction { diff --git a/libs/common/src/services/organization-domain/org-domain-api.service.spec.ts b/libs/common/src/services/organization-domain/org-domain-api.service.spec.ts index 7fa6ed7855a..69cca7206f2 100644 --- a/libs/common/src/services/organization-domain/org-domain-api.service.spec.ts +++ b/libs/common/src/services/organization-domain/org-domain-api.service.spec.ts @@ -2,10 +2,10 @@ import { mock } from "jest-mock-extended"; import { lastValueFrom } from "rxjs"; import { ApiService } from "../../abstractions/api.service"; -import { I18nService } from "../../abstractions/i18n.service"; import { OrganizationDomainSsoDetailsResponse } from "../../abstractions/organization-domain/responses/organization-domain-sso-details.response"; import { OrganizationDomainResponse } from "../../abstractions/organization-domain/responses/organization-domain.response"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { OrgDomainApiService } from "./org-domain-api.service"; import { OrgDomainService } from "./org-domain.service"; diff --git a/libs/common/src/services/organization-domain/org-domain.service.spec.ts b/libs/common/src/services/organization-domain/org-domain.service.spec.ts index 3bc8ae770f1..89e75267357 100644 --- a/libs/common/src/services/organization-domain/org-domain.service.spec.ts +++ b/libs/common/src/services/organization-domain/org-domain.service.spec.ts @@ -1,9 +1,9 @@ import { mock, mockReset } from "jest-mock-extended"; import { lastValueFrom } from "rxjs"; -import { I18nService } from "../../abstractions/i18n.service"; import { OrganizationDomainResponse } from "../../abstractions/organization-domain/responses/organization-domain.response"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { OrgDomainService } from "./org-domain.service"; diff --git a/libs/common/src/services/organization-domain/org-domain.service.ts b/libs/common/src/services/organization-domain/org-domain.service.ts index 617bd3698d6..1e2112eec50 100644 --- a/libs/common/src/services/organization-domain/org-domain.service.ts +++ b/libs/common/src/services/organization-domain/org-domain.service.ts @@ -1,9 +1,9 @@ import { BehaviorSubject } from "rxjs"; -import { I18nService } from "../../abstractions/i18n.service"; import { OrgDomainInternalServiceAbstraction } from "../../abstractions/organization-domain/org-domain.service.abstraction"; import { OrganizationDomainResponse } from "../../abstractions/organization-domain/responses/organization-domain.response"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; export class OrgDomainService implements OrgDomainInternalServiceAbstraction { protected _orgDomains$: BehaviorSubject = new BehaviorSubject([]); diff --git a/libs/common/src/services/organization-user/organization-user.service.implementation.ts b/libs/common/src/services/organization-user/organization-user.service.implementation.ts index 991dca69ab0..624295d8287 100644 --- a/libs/common/src/services/organization-user/organization-user.service.implementation.ts +++ b/libs/common/src/services/organization-user/organization-user.service.implementation.ts @@ -206,6 +206,19 @@ export class OrganizationUserServiceImplementation implements OrganizationUserSe return new ListResponse(r, OrganizationUserBulkResponse); } + async putOrganizationUserBulkEnableSecretsManager( + organizationId: string, + ids: string[] + ): Promise { + await this.apiService.send( + "PUT", + "/organizations/" + organizationId + "/users/enable-secrets-manager", + new OrganizationUserBulkRequest(ids), + true, + false + ); + } + putOrganizationUser( organizationId: string, id: string, diff --git a/libs/common/src/services/policy.service.spec.ts b/libs/common/src/services/policy.service.spec.ts index b806a4502c4..83e8f18b349 100644 --- a/libs/common/src/services/policy.service.spec.ts +++ b/libs/common/src/services/policy.service.spec.ts @@ -2,8 +2,6 @@ import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { BehaviorSubject, firstValueFrom } from "rxjs"; -import { CryptoService } from "../abstractions/crypto.service"; -import { EncryptService } from "../abstractions/encrypt.service"; import { OrganizationService } from "../admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserStatusType, PolicyType } from "../admin-console/enums"; import { PermissionsApi } from "../admin-console/models/api/permissions.api"; @@ -16,9 +14,10 @@ import { ResetPasswordPolicyOptions } from "../admin-console/models/domain/reset import { PolicyResponse } from "../admin-console/models/response/policy.response"; import { PolicyService } from "../admin-console/services/policy/policy.service"; import { ListResponse } from "../models/response/list.response"; - -import { ContainerService } from "./container.service"; -import { StateService } from "./state.service"; +import { CryptoService } from "../platform/abstractions/crypto.service"; +import { EncryptService } from "../platform/abstractions/encrypt.service"; +import { ContainerService } from "../platform/services/container.service"; +import { StateService } from "../platform/services/state.service"; describe("PolicyService", () => { let policyService: PolicyService; diff --git a/libs/common/src/services/search.service.ts b/libs/common/src/services/search.service.ts index f7baabfbdd2..c1d9dc7fb75 100644 --- a/libs/common/src/services/search.service.ts +++ b/libs/common/src/services/search.service.ts @@ -1,9 +1,9 @@ import * as lunr from "lunr"; -import { I18nService } from "../abstractions/i18n.service"; -import { LogService } from "../abstractions/log.service"; import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service"; import { FieldType, UriMatchType } from "../enums"; +import { I18nService } from "../platform/abstractions/i18n.service"; +import { LogService } from "../platform/abstractions/log.service"; import { SendView } from "../tools/send/models/view/send.view"; import { CipherType } from "../vault/enums/cipher-type"; import { CipherView } from "../vault/models/view/cipher.view"; diff --git a/libs/common/src/services/settings.service.spec.ts b/libs/common/src/services/settings.service.spec.ts index 1f23252e243..141022ca3c0 100644 --- a/libs/common/src/services/settings.service.spec.ts +++ b/libs/common/src/services/settings.service.spec.ts @@ -2,12 +2,12 @@ import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { BehaviorSubject, firstValueFrom } from "rxjs"; -import { CryptoService } from "../abstractions/crypto.service"; -import { EncryptService } from "../abstractions/encrypt.service"; +import { CryptoService } from "../platform/abstractions/crypto.service"; +import { EncryptService } from "../platform/abstractions/encrypt.service"; +import { ContainerService } from "../platform/services/container.service"; +import { StateService } from "../platform/services/state.service"; -import { ContainerService } from "./container.service"; import { SettingsService } from "./settings.service"; -import { StateService } from "./state.service"; describe("SettingsService", () => { let settingsService: SettingsService; diff --git a/libs/common/src/services/settings.service.ts b/libs/common/src/services/settings.service.ts index 5bd7d9c2fb9..f3002a7bfd0 100644 --- a/libs/common/src/services/settings.service.ts +++ b/libs/common/src/services/settings.service.ts @@ -1,9 +1,9 @@ import { BehaviorSubject, concatMap } from "rxjs"; import { SettingsService as SettingsServiceAbstraction } from "../abstractions/settings.service"; -import { StateService } from "../abstractions/state.service"; -import { Utils } from "../misc/utils"; -import { AccountSettingsSettings } from "../models/domain/account"; +import { StateService } from "../platform/abstractions/state.service"; +import { Utils } from "../platform/misc/utils"; +import { AccountSettingsSettings } from "../platform/models/domain/account"; export class SettingsService implements SettingsServiceAbstraction { protected _settings: BehaviorSubject = new BehaviorSubject({}); diff --git a/libs/common/src/services/state-migration.service.spec.ts b/libs/common/src/services/state-migration.service.spec.ts deleted file mode 100644 index 13727c96dd6..00000000000 --- a/libs/common/src/services/state-migration.service.spec.ts +++ /dev/null @@ -1,216 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; -import { MockProxy, any, mock } from "jest-mock-extended"; - -import { AbstractStorageService } from "../abstractions/storage.service"; -import { StateVersion } from "../enums"; -import { StateFactory } from "../factories/stateFactory"; -import { Account } from "../models/domain/account"; -import { GlobalState } from "../models/domain/global-state"; - -import { StateMigrationService } from "./stateMigration.service"; - -const userId = "USER_ID"; - -// Note: each test calls the private migration method for that migration, -// so that we don't accidentally run all following migrations as well - -describe("State Migration Service", () => { - let storageService: MockProxy; - let secureStorageService: SubstituteOf; - let stateFactory: SubstituteOf; - - let stateMigrationService: StateMigrationService; - - beforeEach(() => { - storageService = mock(); - secureStorageService = Substitute.for(); - stateFactory = Substitute.for(); - - stateMigrationService = new StateMigrationService( - storageService, - secureStorageService, - stateFactory - ); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("StateVersion 3 to 4 migration", () => { - beforeEach(() => { - const globalVersion3: Partial = { - stateVersion: StateVersion.Three, - }; - - storageService.get.calledWith("global", any()).mockResolvedValue(globalVersion3); - storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]); - }); - - it("clears everBeenUnlocked", async () => { - const accountVersion3: Account = { - profile: { - apiKeyClientId: null, - convertAccountToKeyConnector: null, - email: "EMAIL", - emailVerified: true, - everBeenUnlocked: true, - hasPremiumPersonally: false, - kdfIterations: 100000, - kdfType: 0, - keyHash: "KEY_HASH", - lastSync: "LAST_SYNC", - userId: userId, - usesKeyConnector: false, - forcePasswordResetReason: null, - }, - }; - - const expectedAccountVersion4: Account = { - profile: { - ...accountVersion3.profile, - }, - }; - delete expectedAccountVersion4.profile.everBeenUnlocked; - - storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion3); - - await (stateMigrationService as any).migrateStateFrom3To4(); - - expect(storageService.save).toHaveBeenCalledTimes(2); - expect(storageService.save).toHaveBeenCalledWith(userId, expectedAccountVersion4, any()); - }); - - it("updates StateVersion number", async () => { - await (stateMigrationService as any).migrateStateFrom3To4(); - - expect(storageService.save).toHaveBeenCalledWith( - "global", - { stateVersion: StateVersion.Four }, - any() - ); - expect(storageService.save).toHaveBeenCalledTimes(1); - }); - }); - - describe("StateVersion 4 to 5 migration", () => { - it("migrates organization keys to new format", async () => { - const accountVersion4 = new Account({ - keys: { - organizationKeys: { - encrypted: { - orgOneId: "orgOneEncKey", - orgTwoId: "orgTwoEncKey", - orgThreeId: "orgThreeEncKey", - }, - }, - }, - } as any); - - const expectedAccount = new Account({ - keys: { - organizationKeys: { - encrypted: { - orgOneId: { - type: "organization", - key: "orgOneEncKey", - }, - orgTwoId: { - type: "organization", - key: "orgTwoEncKey", - }, - orgThreeId: { - type: "organization", - key: "orgThreeEncKey", - }, - }, - } as any, - } as any, - }); - - const migratedAccount = await (stateMigrationService as any).migrateAccountFrom4To5( - accountVersion4 - ); - - expect(migratedAccount).toEqual(expectedAccount); - }); - }); - - describe("StateVersion 5 to 6 migration", () => { - it("deletes account.keys.legacyEtmKey value", async () => { - const accountVersion5 = new Account({ - keys: { - legacyEtmKey: "legacy key", - }, - } as any); - - const migratedAccount = await (stateMigrationService as any).migrateAccountFrom5To6( - accountVersion5 - ); - - expect(migratedAccount.keys.legacyEtmKey).toBeUndefined(); - }); - }); - - describe("StateVersion 6 to 7 migration", () => { - it("should delete global.noAutoPromptBiometrics value", async () => { - storageService.get - .calledWith("global", any()) - .mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true }); - storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([]); - - await stateMigrationService.migrate(); - - expect(storageService.save).toHaveBeenCalledWith( - "global", - { - stateVersion: StateVersion.Seven, - }, - any() - ); - }); - - it("should call migrateStateFrom6To7 on each account", async () => { - const accountVersion6 = new Account({ - otherStuff: "other stuff", - } as any); - - storageService.get - .calledWith("global", any()) - .mockResolvedValue({ stateVersion: StateVersion.Six, noAutoPromptBiometrics: true }); - storageService.get.calledWith("authenticatedAccounts", any()).mockResolvedValue([userId]); - storageService.get.calledWith(userId, any()).mockResolvedValue(accountVersion6); - - const migrateSpy = jest.fn(); - (stateMigrationService as any).migrateAccountFrom6To7 = migrateSpy; - - await stateMigrationService.migrate(); - - expect(migrateSpy).toHaveBeenCalledWith(true, accountVersion6); - }); - - it("should update account.settings.disableAutoBiometricsPrompt value if global is no prompt", async () => { - const result = await (stateMigrationService as any).migrateAccountFrom6To7(true, { - otherStuff: "other stuff", - }); - - expect(result).toEqual({ - otherStuff: "other stuff", - settings: { - disableAutoBiometricsPrompt: true, - }, - }); - }); - - it("should not update account.settings.disableAutoBiometricsPrompt value if global auto prompt is enabled", async () => { - const result = await (stateMigrationService as any).migrateAccountFrom6To7(false, { - otherStuff: "other stuff", - }); - - expect(result).toEqual({ - otherStuff: "other stuff", - }); - }); - }); -}); diff --git a/libs/common/src/services/stateMigration.service.ts b/libs/common/src/services/stateMigration.service.ts deleted file mode 100644 index 77c869a16ee..00000000000 --- a/libs/common/src/services/stateMigration.service.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { AbstractStorageService } from "../abstractions/storage.service"; -import { CollectionData } from "../admin-console/models/data/collection.data"; -import { OrganizationData } from "../admin-console/models/data/organization.data"; -import { PolicyData } from "../admin-console/models/data/policy.data"; -import { ProviderData } from "../admin-console/models/data/provider.data"; -import { EnvironmentUrls } from "../auth/models/domain/environment-urls"; -import { TokenService } from "../auth/services/token.service"; -import { HtmlStorageLocation, KdfType, StateVersion, ThemeType } from "../enums"; -import { StateFactory } from "../factories/stateFactory"; -import { EventData } from "../models/data/event.data"; -import { - Account, - AccountSettings, - AccountSettingsSettings, - EncryptionPair, -} from "../models/domain/account"; -import { EncString } from "../models/domain/enc-string"; -import { GlobalState } from "../models/domain/global-state"; -import { StorageOptions } from "../models/domain/storage-options"; -import { GeneratedPasswordHistory } from "../tools/generator/password"; -import { SendData } from "../tools/send/models/data/send.data"; -import { CipherData } from "../vault/models/data/cipher.data"; -import { FolderData } from "../vault/models/data/folder.data"; - -// Originally (before January 2022) storage was handled as a flat key/value pair store. -// With the move to a typed object for state storage these keys should no longer be in use anywhere outside of this migration. -const v1Keys: { [key: string]: string } = { - accessToken: "accessToken", - alwaysShowDock: "alwaysShowDock", - autoConfirmFingerprints: "autoConfirmFingerprints", - autoFillOnPageLoadDefault: "autoFillOnPageLoadDefault", - biometricAwaitingAcceptance: "biometricAwaitingAcceptance", - biometricFingerprintValidated: "biometricFingerprintValidated", - biometricText: "biometricText", - biometricUnlock: "biometric", - clearClipboard: "clearClipboardKey", - clientId: "apikey_clientId", - clientSecret: "apikey_clientSecret", - collapsedGroupings: "collapsedGroupings", - convertAccountToKeyConnector: "convertAccountToKeyConnector", - defaultUriMatch: "defaultUriMatch", - disableAddLoginNotification: "disableAddLoginNotification", - disableAutoBiometricsPrompt: "noAutoPromptBiometrics", - disableAutoTotpCopy: "disableAutoTotpCopy", - disableBadgeCounter: "disableBadgeCounter", - disableChangedPasswordNotification: "disableChangedPasswordNotification", - disableContextMenuItem: "disableContextMenuItem", - disableFavicon: "disableFavicon", - disableGa: "disableGa", - dontShowCardsCurrentTab: "dontShowCardsCurrentTab", - dontShowIdentitiesCurrentTab: "dontShowIdentitiesCurrentTab", - emailVerified: "emailVerified", - enableAlwaysOnTop: "enableAlwaysOnTopKey", - enableAutoFillOnPageLoad: "enableAutoFillOnPageLoad", - enableBiometric: "enabledBiometric", - enableBrowserIntegration: "enableBrowserIntegration", - enableBrowserIntegrationFingerprint: "enableBrowserIntegrationFingerprint", - enableCloseToTray: "enableCloseToTray", - enableFullWidth: "enableFullWidth", - enableMinimizeToTray: "enableMinimizeToTray", - enableStartToTray: "enableStartToTrayKey", - enableTray: "enableTray", - encKey: "encKey", // Generated Symmetric Key - encOrgKeys: "encOrgKeys", - encPrivate: "encPrivateKey", - encProviderKeys: "encProviderKeys", - entityId: "entityId", - entityType: "entityType", - environmentUrls: "environmentUrls", - equivalentDomains: "equivalentDomains", - eventCollection: "eventCollection", - forcePasswordReset: "forcePasswordReset", - history: "generatedPasswordHistory", - installedVersion: "installedVersion", - kdf: "kdf", - kdfIterations: "kdfIterations", - key: "key", // Master Key - keyHash: "keyHash", - lastActive: "lastActive", - localData: "sitesLocalData", - locale: "locale", - mainWindowSize: "mainWindowSize", - minimizeOnCopyToClipboard: "minimizeOnCopyToClipboardKey", - neverDomains: "neverDomains", - noAutoPromptBiometricsText: "noAutoPromptBiometricsText", - openAtLogin: "openAtLogin", - passwordGenerationOptions: "passwordGenerationOptions", - pinProtected: "pinProtectedKey", - protectedPin: "protectedPin", - refreshToken: "refreshToken", - ssoCodeVerifier: "ssoCodeVerifier", - ssoIdentifier: "ssoOrgIdentifier", - ssoState: "ssoState", - stamp: "securityStamp", - theme: "theme", - userEmail: "userEmail", - userId: "userId", - usesConnector: "usesKeyConnector", - vaultTimeoutAction: "vaultTimeoutAction", - vaultTimeout: "lockOption", - rememberedEmail: "rememberedEmail", -}; - -const v1KeyPrefixes: { [key: string]: string } = { - ciphers: "ciphers_", - collections: "collections_", - folders: "folders_", - lastSync: "lastSync_", - policies: "policies_", - twoFactorToken: "twoFactorToken_", - organizations: "organizations_", - providers: "providers_", - sends: "sends_", - settings: "settings_", -}; - -const keys = { - global: "global", - authenticatedAccounts: "authenticatedAccounts", - activeUserId: "activeUserId", - tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication - accountActivity: "accountActivity", -}; - -const partialKeys = { - autoKey: "_masterkey_auto", - biometricKey: "_masterkey_biometric", - masterKey: "_masterkey", -}; - -export class StateMigrationService< - TGlobalState extends GlobalState = GlobalState, - TAccount extends Account = Account -> { - constructor( - protected storageService: AbstractStorageService, - protected secureStorageService: AbstractStorageService, - protected stateFactory: StateFactory - ) {} - - async needsMigration(): Promise { - const currentStateVersion = await this.getCurrentStateVersion(); - return currentStateVersion == null || currentStateVersion < StateVersion.Latest; - } - - async migrate(): Promise { - let currentStateVersion = await this.getCurrentStateVersion(); - while (currentStateVersion < StateVersion.Latest) { - switch (currentStateVersion) { - case StateVersion.One: - await this.migrateStateFrom1To2(); - break; - case StateVersion.Two: - await this.migrateStateFrom2To3(); - break; - case StateVersion.Three: - await this.migrateStateFrom3To4(); - break; - case StateVersion.Four: { - const authenticatedAccounts = await this.getAuthenticatedAccounts(); - for (const account of authenticatedAccounts) { - const migratedAccount = await this.migrateAccountFrom4To5(account); - await this.set(account.profile.userId, migratedAccount); - } - await this.setCurrentStateVersion(StateVersion.Five); - break; - } - case StateVersion.Five: { - const authenticatedAccounts = await this.getAuthenticatedAccounts(); - for (const account of authenticatedAccounts) { - const migratedAccount = await this.migrateAccountFrom5To6(account); - await this.set(account.profile.userId, migratedAccount); - } - await this.setCurrentStateVersion(StateVersion.Six); - break; - } - case StateVersion.Six: { - const authenticatedAccounts = await this.getAuthenticatedAccounts(); - const globals = (await this.getGlobals()) as any; - for (const account of authenticatedAccounts) { - const migratedAccount = await this.migrateAccountFrom6To7( - globals?.noAutoPromptBiometrics, - account - ); - await this.set(account.profile.userId, migratedAccount); - } - if (globals) { - delete globals.noAutoPromptBiometrics; - } - await this.set(keys.global, globals); - await this.setCurrentStateVersion(StateVersion.Seven); - } - } - - currentStateVersion += 1; - } - } - - protected async migrateStateFrom1To2(): Promise { - const clearV1Keys = async (clearingUserId?: string) => { - for (const key in v1Keys) { - if (key == null) { - continue; - } - await this.set(v1Keys[key], null); - } - if (clearingUserId != null) { - for (const keyPrefix in v1KeyPrefixes) { - if (keyPrefix == null) { - continue; - } - await this.set(v1KeyPrefixes[keyPrefix] + userId, null); - } - } - }; - - // Some processes, like biometrics, may have already defined a value before migrations are run. - // We don't want to null out those values if they don't exist in the old storage scheme (like for new installs) - // So, the OOO for migration is that we: - // 1. Check for an existing storage value from the old storage structure OR - // 2. Check for a value already set by processes that run before migration OR - // 3. Assign the default value - const globals: any = - (await this.get(keys.global)) ?? this.stateFactory.createGlobal(null); - globals.stateVersion = StateVersion.Two; - globals.environmentUrls = - (await this.get(v1Keys.environmentUrls)) ?? globals.environmentUrls; - globals.locale = (await this.get(v1Keys.locale)) ?? globals.locale; - globals.noAutoPromptBiometrics = - (await this.get(v1Keys.disableAutoBiometricsPrompt)) ?? - globals.noAutoPromptBiometrics; - globals.noAutoPromptBiometricsText = - (await this.get(v1Keys.noAutoPromptBiometricsText)) ?? - globals.noAutoPromptBiometricsText; - globals.ssoCodeVerifier = - (await this.get(v1Keys.ssoCodeVerifier)) ?? globals.ssoCodeVerifier; - globals.ssoOrganizationIdentifier = - (await this.get(v1Keys.ssoIdentifier)) ?? globals.ssoOrganizationIdentifier; - globals.ssoState = (await this.get(v1Keys.ssoState)) ?? globals.ssoState; - globals.rememberedEmail = - (await this.get(v1Keys.rememberedEmail)) ?? globals.rememberedEmail; - globals.theme = (await this.get(v1Keys.theme)) ?? globals.theme; - globals.vaultTimeout = (await this.get(v1Keys.vaultTimeout)) ?? globals.vaultTimeout; - globals.vaultTimeoutAction = - (await this.get(v1Keys.vaultTimeoutAction)) ?? globals.vaultTimeoutAction; - globals.window = (await this.get(v1Keys.mainWindowSize)) ?? globals.window; - globals.enableTray = (await this.get(v1Keys.enableTray)) ?? globals.enableTray; - globals.enableMinimizeToTray = - (await this.get(v1Keys.enableMinimizeToTray)) ?? globals.enableMinimizeToTray; - globals.enableCloseToTray = - (await this.get(v1Keys.enableCloseToTray)) ?? globals.enableCloseToTray; - globals.enableStartToTray = - (await this.get(v1Keys.enableStartToTray)) ?? globals.enableStartToTray; - globals.openAtLogin = (await this.get(v1Keys.openAtLogin)) ?? globals.openAtLogin; - globals.alwaysShowDock = - (await this.get(v1Keys.alwaysShowDock)) ?? globals.alwaysShowDock; - globals.enableBrowserIntegration = - (await this.get(v1Keys.enableBrowserIntegration)) ?? - globals.enableBrowserIntegration; - globals.enableBrowserIntegrationFingerprint = - (await this.get(v1Keys.enableBrowserIntegrationFingerprint)) ?? - globals.enableBrowserIntegrationFingerprint; - - const userId = - (await this.get(v1Keys.userId)) ?? (await this.get(v1Keys.entityId)); - - const defaultAccount = this.stateFactory.createAccount(null); - const accountSettings: AccountSettings = { - autoConfirmFingerPrints: - (await this.get(v1Keys.autoConfirmFingerprints)) ?? - defaultAccount.settings.autoConfirmFingerPrints, - autoFillOnPageLoadDefault: - (await this.get(v1Keys.autoFillOnPageLoadDefault)) ?? - defaultAccount.settings.autoFillOnPageLoadDefault, - biometricUnlock: - (await this.get(v1Keys.biometricUnlock)) ?? - defaultAccount.settings.biometricUnlock, - clearClipboard: - (await this.get(v1Keys.clearClipboard)) ?? defaultAccount.settings.clearClipboard, - defaultUriMatch: - (await this.get(v1Keys.defaultUriMatch)) ?? defaultAccount.settings.defaultUriMatch, - disableAddLoginNotification: - (await this.get(v1Keys.disableAddLoginNotification)) ?? - defaultAccount.settings.disableAddLoginNotification, - disableAutoBiometricsPrompt: - (await this.get(v1Keys.disableAutoBiometricsPrompt)) ?? - defaultAccount.settings.disableAutoBiometricsPrompt, - disableAutoTotpCopy: - (await this.get(v1Keys.disableAutoTotpCopy)) ?? - defaultAccount.settings.disableAutoTotpCopy, - disableBadgeCounter: - (await this.get(v1Keys.disableBadgeCounter)) ?? - defaultAccount.settings.disableBadgeCounter, - disableChangedPasswordNotification: - (await this.get(v1Keys.disableChangedPasswordNotification)) ?? - defaultAccount.settings.disableChangedPasswordNotification, - disableContextMenuItem: - (await this.get(v1Keys.disableContextMenuItem)) ?? - defaultAccount.settings.disableContextMenuItem, - disableGa: (await this.get(v1Keys.disableGa)) ?? defaultAccount.settings.disableGa, - dontShowCardsCurrentTab: - (await this.get(v1Keys.dontShowCardsCurrentTab)) ?? - defaultAccount.settings.dontShowCardsCurrentTab, - dontShowIdentitiesCurrentTab: - (await this.get(v1Keys.dontShowIdentitiesCurrentTab)) ?? - defaultAccount.settings.dontShowIdentitiesCurrentTab, - enableAlwaysOnTop: - (await this.get(v1Keys.enableAlwaysOnTop)) ?? - defaultAccount.settings.enableAlwaysOnTop, - enableAutoFillOnPageLoad: - (await this.get(v1Keys.enableAutoFillOnPageLoad)) ?? - defaultAccount.settings.enableAutoFillOnPageLoad, - enableBiometric: - (await this.get(v1Keys.enableBiometric)) ?? - defaultAccount.settings.enableBiometric, - enableFullWidth: - (await this.get(v1Keys.enableFullWidth)) ?? - defaultAccount.settings.enableFullWidth, - environmentUrls: globals.environmentUrls ?? defaultAccount.settings.environmentUrls, - equivalentDomains: - (await this.get(v1Keys.equivalentDomains)) ?? - defaultAccount.settings.equivalentDomains, - minimizeOnCopyToClipboard: - (await this.get(v1Keys.minimizeOnCopyToClipboard)) ?? - defaultAccount.settings.minimizeOnCopyToClipboard, - neverDomains: - (await this.get(v1Keys.neverDomains)) ?? defaultAccount.settings.neverDomains, - passwordGenerationOptions: - (await this.get(v1Keys.passwordGenerationOptions)) ?? - defaultAccount.settings.passwordGenerationOptions, - pinProtected: Object.assign(new EncryptionPair(), { - decrypted: null, - encrypted: await this.get(v1Keys.pinProtected), - }), - protectedPin: await this.get(v1Keys.protectedPin), - settings: - userId == null - ? null - : await this.get(v1KeyPrefixes.settings + userId), - vaultTimeout: - (await this.get(v1Keys.vaultTimeout)) ?? defaultAccount.settings.vaultTimeout, - vaultTimeoutAction: - (await this.get(v1Keys.vaultTimeoutAction)) ?? - defaultAccount.settings.vaultTimeoutAction, - }; - - // (userId == null) = no logged in user (so no known userId) and we need to temporarily store account specific settings in state to migrate on first auth - // (userId != null) = we have a currently authed user (so known userId) with encrypted data and other key settings we can move, no need to temporarily store account settings - if (userId == null) { - await this.set(keys.tempAccountSettings, accountSettings); - await this.set(keys.global, globals); - await this.set(keys.authenticatedAccounts, []); - await this.set(keys.activeUserId, null); - await clearV1Keys(); - return; - } - - globals.twoFactorToken = await this.get(v1KeyPrefixes.twoFactorToken + userId); - await this.set(keys.global, globals); - await this.set(userId, { - data: { - addEditCipherInfo: null, - ciphers: { - decrypted: null, - encrypted: await this.get<{ [id: string]: CipherData }>(v1KeyPrefixes.ciphers + userId), - }, - collapsedGroupings: null, - collections: { - decrypted: null, - encrypted: await this.get<{ [id: string]: CollectionData }>( - v1KeyPrefixes.collections + userId - ), - }, - eventCollection: await this.get(v1Keys.eventCollection), - folders: { - decrypted: null, - encrypted: await this.get<{ [id: string]: FolderData }>(v1KeyPrefixes.folders + userId), - }, - localData: null, - organizations: await this.get<{ [id: string]: OrganizationData }>( - v1KeyPrefixes.organizations + userId - ), - passwordGenerationHistory: { - decrypted: null, - encrypted: await this.get(v1Keys.history), - }, - policies: { - decrypted: null, - encrypted: await this.get<{ [id: string]: PolicyData }>(v1KeyPrefixes.policies + userId), - }, - providers: await this.get<{ [id: string]: ProviderData }>(v1KeyPrefixes.providers + userId), - sends: { - decrypted: null, - encrypted: await this.get<{ [id: string]: SendData }>(v1KeyPrefixes.sends + userId), - }, - }, - keys: { - apiKeyClientSecret: await this.get(v1Keys.clientSecret), - cryptoMasterKey: null, - cryptoMasterKeyAuto: null, - cryptoMasterKeyB64: null, - cryptoMasterKeyBiometric: null, - cryptoSymmetricKey: { - encrypted: await this.get(v1Keys.encKey), - decrypted: null, - }, - legacyEtmKey: null, - organizationKeys: { - decrypted: null, - encrypted: await this.get(v1Keys.encOrgKeys), - }, - privateKey: { - decrypted: null, - encrypted: await this.get(v1Keys.encPrivate), - }, - providerKeys: { - decrypted: null, - encrypted: await this.get(v1Keys.encProviderKeys), - }, - publicKey: null, - }, - profile: { - apiKeyClientId: await this.get(v1Keys.clientId), - authenticationStatus: null, - convertAccountToKeyConnector: await this.get(v1Keys.convertAccountToKeyConnector), - email: await this.get(v1Keys.userEmail), - emailVerified: await this.get(v1Keys.emailVerified), - entityId: null, - entityType: null, - everBeenUnlocked: null, - forcePasswordReset: null, - hasPremiumPersonally: null, - kdfIterations: await this.get(v1Keys.kdfIterations), - kdfType: await this.get(v1Keys.kdf), - keyHash: await this.get(v1Keys.keyHash), - lastSync: null, - userId: userId, - usesKeyConnector: null, - }, - settings: accountSettings, - tokens: { - accessToken: await this.get(v1Keys.accessToken), - decodedToken: null, - refreshToken: await this.get(v1Keys.refreshToken), - securityStamp: null, - }, - }); - - await this.set(keys.authenticatedAccounts, [userId]); - await this.set(keys.activeUserId, userId); - - const accountActivity: { [userId: string]: number } = { - [userId]: await this.get(v1Keys.lastActive), - }; - accountActivity[userId] = await this.get(v1Keys.lastActive); - await this.set(keys.accountActivity, accountActivity); - - await clearV1Keys(userId); - - if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "biometric" })) { - await this.secureStorageService.save( - `${userId}${partialKeys.biometricKey}`, - await this.secureStorageService.get(v1Keys.key, { keySuffix: "biometric" }), - { keySuffix: "biometric" } - ); - await this.secureStorageService.remove(v1Keys.key, { keySuffix: "biometric" }); - } - - if (await this.secureStorageService.has(v1Keys.key, { keySuffix: "auto" })) { - await this.secureStorageService.save( - `${userId}${partialKeys.autoKey}`, - await this.secureStorageService.get(v1Keys.key, { keySuffix: "auto" }), - { keySuffix: "auto" } - ); - await this.secureStorageService.remove(v1Keys.key, { keySuffix: "auto" }); - } - - if (await this.secureStorageService.has(v1Keys.key)) { - await this.secureStorageService.save( - `${userId}${partialKeys.masterKey}`, - await this.secureStorageService.get(v1Keys.key) - ); - await this.secureStorageService.remove(v1Keys.key); - } - } - - protected async migrateStateFrom2To3(): Promise { - const authenticatedUserIds = await this.get(keys.authenticatedAccounts); - await Promise.all( - authenticatedUserIds.map(async (userId) => { - const account = await this.get(userId); - if ( - account?.profile?.hasPremiumPersonally === null && - account.tokens?.accessToken != null - ) { - const decodedToken = await TokenService.decodeToken(account.tokens.accessToken); - account.profile.hasPremiumPersonally = decodedToken.premium; - await this.set(userId, account); - } - }) - ); - - const globals = await this.getGlobals(); - globals.stateVersion = StateVersion.Three; - await this.set(keys.global, globals); - } - - protected async migrateStateFrom3To4(): Promise { - const authenticatedUserIds = await this.get(keys.authenticatedAccounts); - await Promise.all( - authenticatedUserIds.map(async (userId) => { - const account = await this.get(userId); - if (account?.profile?.everBeenUnlocked != null) { - delete account.profile.everBeenUnlocked; - return this.set(userId, account); - } - }) - ); - - const globals = await this.getGlobals(); - globals.stateVersion = StateVersion.Four; - await this.set(keys.global, globals); - } - - protected async migrateAccountFrom4To5(account: TAccount): Promise { - const encryptedOrgKeys = account.keys?.organizationKeys?.encrypted; - if (encryptedOrgKeys != null) { - for (const [orgId, encKey] of Object.entries(encryptedOrgKeys)) { - encryptedOrgKeys[orgId] = { - type: "organization", - key: encKey as unknown as string, // Account v4 does not reflect the current account model so we have to cast - }; - } - } - - return account; - } - - protected async migrateAccountFrom5To6(account: TAccount): Promise { - delete (account as any).keys?.legacyEtmKey; - return account; - } - - protected async migrateAccountFrom6To7( - globalSetting: boolean, - account: TAccount - ): Promise { - if (globalSetting) { - account.settings = Object.assign({}, account.settings, { disableAutoBiometricsPrompt: true }); - } - return account; - } - - protected get options(): StorageOptions { - return { htmlStorageLocation: HtmlStorageLocation.Local }; - } - - protected get(key: string): Promise { - return this.storageService.get(key, this.options); - } - - protected set(key: string, value: any): Promise { - if (value == null) { - return this.storageService.remove(key, this.options); - } - return this.storageService.save(key, value, this.options); - } - - protected async getGlobals(): Promise { - return await this.get(keys.global); - } - - protected async getCurrentStateVersion(): Promise { - return (await this.getGlobals())?.stateVersion ?? StateVersion.One; - } - - protected async setCurrentStateVersion(newVersion: StateVersion): Promise { - const globals = await this.getGlobals(); - globals.stateVersion = newVersion; - await this.set(keys.global, globals); - } - - protected async getAuthenticatedAccounts(): Promise { - const authenticatedUserIds = await this.get(keys.authenticatedAccounts); - return Promise.all(authenticatedUserIds.map((id) => this.get(id))); - } -} diff --git a/libs/common/src/services/totp.service.ts b/libs/common/src/services/totp.service.ts index 3264c598df1..7896a8c4516 100644 --- a/libs/common/src/services/totp.service.ts +++ b/libs/common/src/services/totp.service.ts @@ -1,7 +1,7 @@ -import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; -import { LogService } from "../abstractions/log.service"; import { TotpService as TotpServiceAbstraction } from "../abstractions/totp.service"; -import { Utils } from "../misc/utils"; +import { CryptoFunctionService } from "../platform/abstractions/crypto-function.service"; +import { LogService } from "../platform/abstractions/log.service"; +import { Utils } from "../platform/misc/utils"; const B32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const SteamChars = "23456789BCDFGHJKMNPQRTVWXY"; @@ -162,7 +162,7 @@ export class TotpService implements TotpServiceAbstraction { timeBytes: Uint8Array, alg: "sha1" | "sha256" | "sha512" ) { - const signature = await this.cryptoFunctionService.hmac(timeBytes.buffer, keyBytes.buffer, alg); + const signature = await this.cryptoFunctionService.hmac(timeBytes, keyBytes, alg); return new Uint8Array(signature); } } diff --git a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.spec.ts b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.spec.ts new file mode 100644 index 00000000000..0e2c9151868 --- /dev/null +++ b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.spec.ts @@ -0,0 +1,146 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; +import { Policy } from "../../admin-console/models/domain/policy"; +import { TokenService } from "../../auth/abstractions/token.service"; +import { UserVerificationService } from "../../auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { EncString } from "../../platform/models/domain/enc-string"; + +import { VaultTimeoutSettingsService } from "./vault-timeout-settings.service"; + +describe("VaultTimeoutSettingsService", () => { + let cryptoService: MockProxy; + let tokenService: MockProxy; + let policyService: MockProxy; + let stateService: MockProxy; + let userVerificationService: MockProxy; + let service: VaultTimeoutSettingsService; + + beforeEach(() => { + cryptoService = mock(); + tokenService = mock(); + policyService = mock(); + stateService = mock(); + userVerificationService = mock(); + service = new VaultTimeoutSettingsService( + cryptoService, + tokenService, + policyService, + stateService, + userVerificationService + ); + }); + + describe("availableVaultTimeoutActions$", () => { + it("always returns LogOut", async () => { + const result = await firstValueFrom(service.availableVaultTimeoutActions$()); + + expect(result).toContain(VaultTimeoutAction.LogOut); + }); + + it("contains Lock when the user has a master password", async () => { + userVerificationService.hasMasterPassword.mockResolvedValue(true); + + const result = await firstValueFrom(service.availableVaultTimeoutActions$()); + + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("contains Lock when the user has a persistent PIN configured", async () => { + stateService.getPinKeyEncryptedUserKey.mockResolvedValue(createEncString()); + + const result = await firstValueFrom(service.availableVaultTimeoutActions$()); + + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("contains Lock when the user has a transient/ephemeral PIN configured", async () => { + stateService.getProtectedPin.mockResolvedValue("some-key"); + + const result = await firstValueFrom(service.availableVaultTimeoutActions$()); + + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("contains Lock when the user has biometrics configured", async () => { + stateService.getBiometricUnlock.mockResolvedValue(true); + + const result = await firstValueFrom(service.availableVaultTimeoutActions$()); + + expect(result).toContain(VaultTimeoutAction.Lock); + }); + + it("not contains Lock when the user does not have a master password, PIN, or biometrics", async () => { + userVerificationService.hasMasterPassword.mockResolvedValue(false); + stateService.getPinKeyEncryptedUserKey.mockResolvedValue(null); + stateService.getProtectedPin.mockResolvedValue(null); + stateService.getBiometricUnlock.mockResolvedValue(false); + + const result = await firstValueFrom(service.availableVaultTimeoutActions$()); + + expect(result).not.toContain(VaultTimeoutAction.Lock); + }); + }); + + describe("vaultTimeoutAction$", () => { + describe("given the user has a master password", () => { + it.each` + policy | userPreference | expected + ${null} | ${null} | ${VaultTimeoutAction.Lock} + ${null} | ${VaultTimeoutAction.LogOut} | ${VaultTimeoutAction.LogOut} + ${VaultTimeoutAction.LogOut} | ${null} | ${VaultTimeoutAction.LogOut} + ${VaultTimeoutAction.LogOut} | ${VaultTimeoutAction.Lock} | ${VaultTimeoutAction.LogOut} + `( + "returns $expected when policy is $policy, and user preference is $userPreference", + async ({ policy, userPreference, expected }) => { + userVerificationService.hasMasterPassword.mockResolvedValue(true); + policyService.policyAppliesToUser.mockResolvedValue(policy === null ? false : true); + policyService.getAll.mockResolvedValue( + policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[]) + ); + stateService.getVaultTimeoutAction.mockResolvedValue(userPreference); + + const result = await firstValueFrom(service.vaultTimeoutAction$()); + + expect(result).toBe(expected); + } + ); + }); + + describe("given the user does not have a master password", () => { + it.each` + unlockMethod | policy | userPreference | expected + ${false} | ${null} | ${null} | ${VaultTimeoutAction.LogOut} + ${false} | ${null} | ${VaultTimeoutAction.Lock} | ${VaultTimeoutAction.LogOut} + ${false} | ${VaultTimeoutAction.Lock} | ${null} | ${VaultTimeoutAction.LogOut} + ${true} | ${null} | ${null} | ${VaultTimeoutAction.LogOut} + ${true} | ${null} | ${VaultTimeoutAction.Lock} | ${VaultTimeoutAction.Lock} + ${true} | ${VaultTimeoutAction.Lock} | ${null} | ${VaultTimeoutAction.Lock} + ${true} | ${VaultTimeoutAction.Lock} | ${VaultTimeoutAction.LogOut} | ${VaultTimeoutAction.Lock} + `( + "returns $expected when policy is $policy, has unlock method is $unlockMethod, and user preference is $userPreference", + async ({ unlockMethod, policy, userPreference, expected }) => { + stateService.getBiometricUnlock.mockResolvedValue(unlockMethod); + userVerificationService.hasMasterPassword.mockResolvedValue(false); + policyService.policyAppliesToUser.mockResolvedValue(policy === null ? false : true); + policyService.getAll.mockResolvedValue( + policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[]) + ); + stateService.getVaultTimeoutAction.mockResolvedValue(userPreference); + + const result = await firstValueFrom(service.vaultTimeoutAction$()); + + expect(result).toBe(expected); + } + ); + }); + }); +}); + +function createEncString() { + return Symbol() as unknown as EncString; +} diff --git a/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts new file mode 100644 index 00000000000..f34983b910c --- /dev/null +++ b/libs/common/src/services/vault-timeout/vault-timeout-settings.service.ts @@ -0,0 +1,169 @@ +import { defer } from "rxjs"; + +import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; +import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "../../admin-console/enums"; +import { TokenService } from "../../auth/abstractions/token.service"; +import { UserVerificationService } from "../../auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { StateService } from "../../platform/abstractions/state.service"; + +/** + * - DISABLED: No Pin set + * - PERSISTENT: Pin is set and survives client reset + * - TRANSIENT: Pin is set and requires password unlock after client reset + */ +export type PinLockType = "DISABLED" | "PERSISTANT" | "TRANSIENT"; + +export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceAbstraction { + constructor( + private cryptoService: CryptoService, + private tokenService: TokenService, + private policyService: PolicyService, + private stateService: StateService, + private userVerificationService: UserVerificationService + ) {} + + async setVaultTimeoutOptions(timeout: number, action: VaultTimeoutAction): Promise { + await this.stateService.setVaultTimeout(timeout); + + // We swap these tokens from being on disk for lock actions, and in memory for logout actions + // Get them here to set them to their new location after changing the timeout action and clearing if needed + const token = await this.tokenService.getToken(); + const refreshToken = await this.tokenService.getRefreshToken(); + const clientId = await this.tokenService.getClientId(); + const clientSecret = await this.tokenService.getClientSecret(); + + const currentAction = await this.stateService.getVaultTimeoutAction(); + if ( + (timeout != null || timeout === 0) && + action === VaultTimeoutAction.LogOut && + action !== currentAction + ) { + // if we have a vault timeout and the action is log out, reset tokens + await this.tokenService.clearToken(); + } + + await this.stateService.setVaultTimeoutAction(action); + + await this.tokenService.setToken(token); + await this.tokenService.setRefreshToken(refreshToken); + await this.tokenService.setClientId(clientId); + await this.tokenService.setClientSecret(clientSecret); + + await this.cryptoService.refreshAdditionalKeys(); + } + + availableVaultTimeoutActions$(userId?: string) { + return defer(() => this.getAvailableVaultTimeoutActions(userId)); + } + + async isPinLockSet(userId?: string): Promise { + // we can't check the protected pin for both because old accounts only + // used it for MP on Restart + const pinIsEnabled = !!(await this.stateService.getProtectedPin({ userId })); + const aUserKeyPinIsSet = !!(await this.stateService.getPinKeyEncryptedUserKey({ userId })); + const anOldUserKeyPinIsSet = !!(await this.stateService.getEncryptedPinProtected({ userId })); + + if (aUserKeyPinIsSet || anOldUserKeyPinIsSet) { + return "PERSISTANT"; + } else if (pinIsEnabled && !aUserKeyPinIsSet && !anOldUserKeyPinIsSet) { + return "TRANSIENT"; + } else { + return "DISABLED"; + } + } + + async isBiometricLockSet(userId?: string): Promise { + return await this.stateService.getBiometricUnlock({ userId }); + } + + async getVaultTimeout(userId?: string): Promise { + const vaultTimeout = await this.stateService.getVaultTimeout({ userId }); + + if ( + await this.policyService.policyAppliesToUser(PolicyType.MaximumVaultTimeout, null, userId) + ) { + const policy = await this.policyService.getAll(PolicyType.MaximumVaultTimeout, userId); + // Remove negative values, and ensure it's smaller than maximum allowed value according to policy + let timeout = Math.min(vaultTimeout, policy[0].data.minutes); + + if (vaultTimeout == null || timeout < 0) { + timeout = policy[0].data.minutes; + } + + // TODO @jlf0dev: Can we move this somwhere else? Maybe add it to the initialization process? + // ( Apparently I'm the one that reviewed the original PR that added this :) ) + // We really shouldn't need to set the value here, but multiple services relies on this value being correct. + if (vaultTimeout !== timeout) { + await this.stateService.setVaultTimeout(timeout, { userId }); + } + + return timeout; + } + + return vaultTimeout; + } + + vaultTimeoutAction$(userId?: string) { + return defer(() => this.getVaultTimeoutAction(userId)); + } + + async getVaultTimeoutAction(userId?: string): Promise { + const availableActions = await this.getAvailableVaultTimeoutActions(); + if (availableActions.length === 1) { + return availableActions[0]; + } + + const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId: userId }); + + if ( + await this.policyService.policyAppliesToUser(PolicyType.MaximumVaultTimeout, null, userId) + ) { + const policy = await this.policyService.getAll(PolicyType.MaximumVaultTimeout, userId); + const action = policy[0].data.action; + // We really shouldn't need to set the value here, but multiple services relies on this value being correct. + if (action && vaultTimeoutAction !== action) { + await this.stateService.setVaultTimeoutAction(action, { userId: userId }); + } + if (action && availableActions.includes(action)) { + return action; + } + } + + if (vaultTimeoutAction == null) { + // Depends on whether or not the user has a master password + const defaultValue = (await this.userVerificationService.hasMasterPassword()) + ? VaultTimeoutAction.Lock + : VaultTimeoutAction.LogOut; + // We really shouldn't need to set the value here, but multiple services relies on this value being correct. + await this.stateService.setVaultTimeoutAction(defaultValue, { userId: userId }); + return defaultValue; + } + + return vaultTimeoutAction === VaultTimeoutAction.LogOut + ? VaultTimeoutAction.LogOut + : VaultTimeoutAction.Lock; + } + + private async getAvailableVaultTimeoutActions(userId?: string): Promise { + const availableActions = [VaultTimeoutAction.LogOut]; + + const canLock = + (await this.userVerificationService.hasMasterPassword(userId)) || + (await this.isPinLockSet(userId)) !== "DISABLED" || + (await this.isBiometricLockSet(userId)); + + if (canLock) { + availableActions.push(VaultTimeoutAction.Lock); + } + + return availableActions; + } + + async clear(userId?: string): Promise { + await this.stateService.setEverBeenUnlocked(false, { userId: userId }); + await this.cryptoService.clearPinKeys(userId); + } +} diff --git a/libs/common/src/services/vaultTimeout/vaultTimeout.service.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.ts similarity index 67% rename from libs/common/src/services/vaultTimeout/vaultTimeout.service.ts rename to libs/common/src/services/vault-timeout/vault-timeout.service.ts index 313cd38566d..9e5a78834f7 100644 --- a/libs/common/src/services/vaultTimeout/vaultTimeout.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.ts @@ -1,18 +1,18 @@ import { firstValueFrom } from "rxjs"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { MessagingService } from "../../abstractions/messaging.service"; -import { PlatformUtilsService } from "../../abstractions/platformUtils.service"; import { SearchService } from "../../abstractions/search.service"; -import { StateService } from "../../abstractions/state.service"; -import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../../abstractions/vaultTimeout/vaultTimeout.service"; -import { VaultTimeoutSettingsService } from "../../abstractions/vaultTimeout/vaultTimeoutSettings.service"; -import { CollectionService } from "../../admin-console/abstractions/collection.service"; +import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; +import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout.service"; import { AuthService } from "../../auth/abstractions/auth.service"; -import { KeyConnectorService } from "../../auth/abstractions/key-connector.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { ClientType } from "../../enums"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; import { CipherService } from "../../vault/abstractions/cipher.service"; +import { CollectionService } from "../../vault/abstractions/collection.service"; import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { @@ -26,7 +26,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { protected platformUtilsService: PlatformUtilsService, private messagingService: MessagingService, private searchService: SearchService, - private keyConnectorService: KeyConnectorService, private stateService: StateService, private authService: AuthService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, @@ -34,10 +33,12 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { private loggedOutCallback: (expired: boolean, userId?: string) => Promise = null ) {} - init(checkOnInterval: boolean) { + async init(checkOnInterval: boolean) { if (this.inited) { return; } + // TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3483) + await this.migrateKeyForNeverLockIfNeeded(); this.inited = true; if (checkOnInterval) { @@ -69,14 +70,12 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { return; } - if (await this.keyConnectorService.getUsesKeyConnector()) { - const pinSet = await this.vaultTimeoutSettingsService.isPinLockSet(); - const pinLock = - (pinSet[0] && (await this.stateService.getDecryptedPinProtected()) != null) || pinSet[1]; - - if (!pinLock && !(await this.vaultTimeoutSettingsService.isBiometricLockSet())) { - await this.logOut(userId); - } + const availableActions = await firstValueFrom( + this.vaultTimeoutSettingsService.availableVaultTimeoutActions$() + ); + const supportsLock = availableActions.includes(VaultTimeoutAction.Lock); + if (!supportsLock) { + await this.logOut(userId); } if (userId == null || userId === (await this.stateService.getUserId())) { @@ -85,12 +84,13 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { } await this.stateService.setEverBeenUnlocked(true, { userId: userId }); + await this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId }); - await this.cryptoService.clearKey(false, userId); + await this.cryptoService.clearUserKey(false, userId); + await this.cryptoService.clearMasterKey(userId); await this.cryptoService.clearOrgKeys(true, userId); await this.cryptoService.clearKeyPair(true, userId); - await this.cryptoService.clearEncKey(true, userId); await this.cipherService.clearCache(userId); await this.collectionService.clearCache(userId); @@ -133,9 +133,28 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { } private async executeTimeoutAction(userId: string): Promise { - const timeoutAction = await this.vaultTimeoutSettingsService.getVaultTimeoutAction(userId); + const timeoutAction = await firstValueFrom( + this.vaultTimeoutSettingsService.vaultTimeoutAction$(userId) + ); timeoutAction === VaultTimeoutAction.LogOut ? await this.logOut(userId) : await this.lock(userId); } + + private async migrateKeyForNeverLockIfNeeded(): Promise { + // Web can't set vault timeout to never + if (this.platformUtilsService.getClientType() == ClientType.Web) { + return; + } + const accounts = await firstValueFrom(this.stateService.accounts$); + for (const userId in accounts) { + if (userId != null) { + await this.cryptoService.migrateAutoKeyIfNeeded(userId); + // Legacy users should be logged out since we're not on the web vault and can't migrate. + if (await this.cryptoService.isLegacyUser(null, userId)) { + await this.logOut(userId); + } + } + } + } } diff --git a/libs/common/src/services/vaultTimeout/vaultTimeoutSettings.service.ts b/libs/common/src/services/vaultTimeout/vaultTimeoutSettings.service.ts deleted file mode 100644 index 09bd311f934..00000000000 --- a/libs/common/src/services/vaultTimeout/vaultTimeoutSettings.service.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { CryptoService } from "../../abstractions/crypto.service"; -import { StateService } from "../../abstractions/state.service"; -import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../../abstractions/vaultTimeout/vaultTimeoutSettings.service"; -import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction"; -import { PolicyType } from "../../admin-console/enums"; -import { TokenService } from "../../auth/abstractions/token.service"; -import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; - -export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceAbstraction { - constructor( - private cryptoService: CryptoService, - private tokenService: TokenService, - private policyService: PolicyService, - private stateService: StateService - ) {} - - async setVaultTimeoutOptions(timeout: number, action: VaultTimeoutAction): Promise { - await this.stateService.setVaultTimeout(timeout); - - // We swap these tokens from being on disk for lock actions, and in memory for logout actions - // Get them here to set them to their new location after changing the timeout action and clearing if needed - const token = await this.tokenService.getToken(); - const refreshToken = await this.tokenService.getRefreshToken(); - const clientId = await this.tokenService.getClientId(); - const clientSecret = await this.tokenService.getClientSecret(); - - const currentAction = await this.stateService.getVaultTimeoutAction(); - if ( - (timeout != null || timeout === 0) && - action === VaultTimeoutAction.LogOut && - action !== currentAction - ) { - // if we have a vault timeout and the action is log out, reset tokens - await this.tokenService.clearToken(); - } - - await this.stateService.setVaultTimeoutAction(action); - - await this.tokenService.setToken(token); - await this.tokenService.setRefreshToken(refreshToken); - await this.tokenService.setClientId(clientId); - await this.tokenService.setClientSecret(clientSecret); - - await this.cryptoService.toggleKey(); - } - - async isPinLockSet(): Promise<[boolean, boolean]> { - const protectedPin = await this.stateService.getProtectedPin(); - const pinProtectedKey = await this.stateService.getEncryptedPinProtected(); - return [protectedPin != null, pinProtectedKey != null]; - } - - async isBiometricLockSet(): Promise { - return await this.stateService.getBiometricUnlock(); - } - - async getVaultTimeout(userId?: string): Promise { - const vaultTimeout = await this.stateService.getVaultTimeout({ userId: userId }); - - if ( - await this.policyService.policyAppliesToUser(PolicyType.MaximumVaultTimeout, null, userId) - ) { - const policy = await this.policyService.getAll(PolicyType.MaximumVaultTimeout, userId); - // Remove negative values, and ensure it's smaller than maximum allowed value according to policy - let timeout = Math.min(vaultTimeout, policy[0].data.minutes); - - if (vaultTimeout == null || timeout < 0) { - timeout = policy[0].data.minutes; - } - - // We really shouldn't need to set the value here, but multiple services relies on this value being correct. - if (vaultTimeout !== timeout) { - await this.stateService.setVaultTimeout(timeout, { userId: userId }); - } - - return timeout; - } - - return vaultTimeout; - } - - async getVaultTimeoutAction(userId?: string): Promise { - let vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId: userId }); - - if ( - await this.policyService.policyAppliesToUser(PolicyType.MaximumVaultTimeout, null, userId) - ) { - const policy = await this.policyService.getAll(PolicyType.MaximumVaultTimeout, userId); - const action = policy[0].data.action; - - if (action) { - // We really shouldn't need to set the value here, but multiple services relies on this value being correct. - if (action && vaultTimeoutAction !== action) { - await this.stateService.setVaultTimeoutAction(action, { userId: userId }); - } - vaultTimeoutAction = action; - } - } - - return vaultTimeoutAction === VaultTimeoutAction.LogOut - ? VaultTimeoutAction.LogOut - : VaultTimeoutAction.Lock; - } - - async clear(userId?: string): Promise { - await this.stateService.setEverBeenUnlocked(false, { userId: userId }); - await this.stateService.setDecryptedPinProtected(null, { userId: userId }); - await this.stateService.setProtectedPin(null, { userId: userId }); - } -} diff --git a/libs/common/src/state-migrations/.eslintrc.json b/libs/common/src/state-migrations/.eslintrc.json new file mode 100644 index 00000000000..4b66f0a32fa --- /dev/null +++ b/libs/common/src/state-migrations/.eslintrc.json @@ -0,0 +1,24 @@ +{ + "overrides": [ + { + "files": ["*"], + "rules": { + "import/no-restricted-paths": [ + "error", + { + "basePath": "libs/common/src/state-migrations", + "zones": [ + { + "target": "./", + "from": "../", + // Relative to from, not basePath + "except": ["state-migrations"], + "message": "State migrations should rarely import from the greater codebase. If you need to import from another location, take into account the likelihood of change in that code and consider copying to the migration instead." + } + ] + } + ] + } + } + ] +} diff --git a/libs/common/src/state-migrations/index.ts b/libs/common/src/state-migrations/index.ts new file mode 100644 index 00000000000..c883b1ca811 --- /dev/null +++ b/libs/common/src/state-migrations/index.ts @@ -0,0 +1 @@ +export { migrate, CURRENT_VERSION } from "./migrate"; diff --git a/libs/common/src/state-migrations/migrate.spec.ts b/libs/common/src/state-migrations/migrate.spec.ts new file mode 100644 index 00000000000..ade3d261f69 --- /dev/null +++ b/libs/common/src/state-migrations/migrate.spec.ts @@ -0,0 +1,67 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages +import { LogService } from "../platform/abstractions/log.service"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations +import { AbstractStorageService } from "../platform/abstractions/storage.service"; + +import { CURRENT_VERSION, currentVersion, migrate } from "./migrate"; +import { MigrationBuilder } from "./migration-builder"; + +jest.mock("./migration-builder", () => { + return { + MigrationBuilder: { + create: jest.fn().mockReturnThis(), + }, + }; +}); + +describe("migrate", () => { + it("should not run migrations if state is empty", async () => { + const storage = mock(); + const logService = mock(); + storage.get.mockReturnValueOnce(null); + await migrate(storage, logService); + expect(MigrationBuilder.create).not.toHaveBeenCalled(); + }); + + it("should set to current version if state is empty", async () => { + const storage = mock(); + const logService = mock(); + storage.get.mockReturnValueOnce(null); + await migrate(storage, logService); + expect(storage.save).toHaveBeenCalledWith("stateVersion", CURRENT_VERSION); + }); +}); + +describe("currentVersion", () => { + let storage: MockProxy; + let logService: MockProxy; + + beforeEach(() => { + storage = mock(); + logService = mock(); + }); + + it("should return -1 if no version", async () => { + storage.get.mockReturnValueOnce(null); + expect(await currentVersion(storage, logService)).toEqual(-1); + }); + + it("should return version", async () => { + storage.get.calledWith("stateVersion").mockReturnValueOnce(1 as any); + expect(await currentVersion(storage, logService)).toEqual(1); + }); + + it("should return version from global", async () => { + storage.get.calledWith("stateVersion").mockReturnValueOnce(null); + storage.get.calledWith("global").mockReturnValueOnce({ stateVersion: 1 } as any); + expect(await currentVersion(storage, logService)).toEqual(1); + }); + + it("should prefer root version to global", async () => { + storage.get.calledWith("stateVersion").mockReturnValue(1 as any); + storage.get.calledWith("global").mockReturnValue({ stateVersion: 2 } as any); + expect(await currentVersion(storage, logService)).toEqual(1); + }); +}); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts new file mode 100644 index 00000000000..483c4f2e8eb --- /dev/null +++ b/libs/common/src/state-migrations/migrate.ts @@ -0,0 +1,60 @@ +// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages +import { LogService } from "../platform/abstractions/log.service"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations +import { AbstractStorageService } from "../platform/abstractions/storage.service"; + +import { MigrationBuilder } from "./migration-builder"; +import { MigrationHelper } from "./migration-helper"; +import { FixPremiumMigrator } from "./migrations/3-fix-premium"; +import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; +import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; +import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; +import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; +import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; +import { MinVersionMigrator } from "./migrations/min-version"; + +export const MIN_VERSION = 2; +export const CURRENT_VERSION = 8; +export type MinVersion = typeof MIN_VERSION; + +export async function migrate( + storageService: AbstractStorageService, + logService: LogService +): Promise { + const migrationHelper = new MigrationHelper( + await currentVersion(storageService, logService), + storageService, + logService + ); + if (migrationHelper.currentVersion < 0) { + // Cannot determine state, assuming empty so we don't repeatedly apply a migration. + await storageService.save("stateVersion", CURRENT_VERSION); + return; + } + MigrationBuilder.create() + .with(MinVersionMigrator) + .with(FixPremiumMigrator, 2, 3) + .with(RemoveEverBeenUnlockedMigrator, 3, 4) + .with(AddKeyTypeToOrgKeysMigrator, 4, 5) + .with(RemoveLegacyEtmKeyMigrator, 5, 6) + .with(MoveBiometricAutoPromptToAccount, 6, 7) + .with(MoveStateVersionMigrator, 7, CURRENT_VERSION) + .migrate(migrationHelper); +} + +export async function currentVersion( + storageService: AbstractStorageService, + logService: LogService +) { + let state = await storageService.get("stateVersion"); + if (state == null) { + // Pre v8 + state = (await storageService.get<{ stateVersion: number }>("global"))?.stateVersion; + } + if (state == null) { + logService.info("No state version found, assuming empty state."); + return -1; + } + logService.info(`State version: ${state}`); + return state; +} diff --git a/libs/common/src/state-migrations/migration-builder.spec.ts b/libs/common/src/state-migrations/migration-builder.spec.ts new file mode 100644 index 00000000000..fa53544f133 --- /dev/null +++ b/libs/common/src/state-migrations/migration-builder.spec.ts @@ -0,0 +1,117 @@ +import { mock } from "jest-mock-extended"; + +import { MigrationBuilder } from "./migration-builder"; +import { MigrationHelper } from "./migration-helper"; +import { Migrator } from "./migrator"; + +describe("MigrationBuilder", () => { + class TestMigrator extends Migrator<0, 1> { + async migrate(helper: MigrationHelper): Promise { + await helper.set("test", "test"); + return; + } + + async rollback(helper: MigrationHelper): Promise { + await helper.set("test", "rollback"); + return; + } + } + + let sut: MigrationBuilder; + + beforeEach(() => { + sut = MigrationBuilder.create(); + }); + + class TestBadMigrator extends Migrator<1, 0> { + async migrate(helper: MigrationHelper): Promise { + await helper.set("test", "test"); + } + + async rollback(helper: MigrationHelper): Promise { + await helper.set("test", "rollback"); + } + } + + it("should throw if instantiated incorrectly", () => { + expect(() => MigrationBuilder.create().with(TestMigrator, null, null)).toThrow(); + expect(() => + MigrationBuilder.create().with(TestMigrator, 0, 1).with(TestBadMigrator, 1, 0) + ).toThrow(); + }); + + it("should be able to create a new MigrationBuilder", () => { + expect(sut).toBeInstanceOf(MigrationBuilder); + }); + + it("should be able to add a migrator", () => { + const newBuilder = sut.with(TestMigrator, 0, 1); + const migrations = newBuilder["migrations"]; + expect(migrations.length).toBe(1); + expect(migrations[0]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "up" }); + }); + + it("should be able to add a rollback", () => { + const newBuilder = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0); + const migrations = newBuilder["migrations"]; + expect(migrations.length).toBe(2); + expect(migrations[1]).toMatchObject({ migrator: expect.any(TestMigrator), direction: "down" }); + }); + + describe("migrate", () => { + let migrator: TestMigrator; + let rollback_migrator: TestMigrator; + + beforeEach(() => { + sut = sut.with(TestMigrator, 0, 1).rollback(TestMigrator, 1, 0); + migrator = (sut as any).migrations[0].migrator; + rollback_migrator = (sut as any).migrations[1].migrator; + }); + + it("should migrate", async () => { + const helper = new MigrationHelper(0, mock(), mock()); + const spy = jest.spyOn(migrator, "migrate"); + await sut.migrate(helper); + expect(spy).toBeCalledWith(helper); + }); + + it("should rollback", async () => { + const helper = new MigrationHelper(1, mock(), mock()); + const spy = jest.spyOn(rollback_migrator, "rollback"); + await sut.migrate(helper); + expect(spy).toBeCalledWith(helper); + }); + + it("should update version on migrate", async () => { + const helper = new MigrationHelper(0, mock(), mock()); + const spy = jest.spyOn(migrator, "updateVersion"); + await sut.migrate(helper); + expect(spy).toBeCalledWith(helper, "up"); + }); + + it("should update version on rollback", async () => { + const helper = new MigrationHelper(1, mock(), mock()); + const spy = jest.spyOn(rollback_migrator, "updateVersion"); + await sut.migrate(helper); + expect(spy).toBeCalledWith(helper, "down"); + }); + + it("should not run the migrator if the current version does not match the from version", async () => { + const helper = new MigrationHelper(3, mock(), mock()); + const migrate = jest.spyOn(migrator, "migrate"); + const rollback = jest.spyOn(rollback_migrator, "rollback"); + await sut.migrate(helper); + expect(migrate).not.toBeCalled(); + expect(rollback).not.toBeCalled(); + }); + + it("should not update version if the current version does not match the from version", async () => { + const helper = new MigrationHelper(3, mock(), mock()); + const migrate = jest.spyOn(migrator, "updateVersion"); + const rollback = jest.spyOn(rollback_migrator, "updateVersion"); + await sut.migrate(helper); + expect(migrate).not.toBeCalled(); + expect(rollback).not.toBeCalled(); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migration-builder.ts b/libs/common/src/state-migrations/migration-builder.ts new file mode 100644 index 00000000000..776295a6b8f --- /dev/null +++ b/libs/common/src/state-migrations/migration-builder.ts @@ -0,0 +1,106 @@ +import { MigrationHelper } from "./migration-helper"; +import { Direction, Migrator, VersionFrom, VersionTo } from "./migrator"; + +export class MigrationBuilder { + /** Create a new MigrationBuilder with an empty buffer of migrations to perform. + * + * Add migrations to the buffer with {@link with} and {@link rollback}. + * @returns A new MigrationBuilder. + */ + static create(): MigrationBuilder<0> { + return new MigrationBuilder([]); + } + + private constructor( + private migrations: readonly { migrator: Migrator; direction: Direction }[] + ) {} + + /** Add a migrator to the MigrationBuilder. Types are updated such that the chained MigrationBuilder must currently be + * at state version equal to the from version of the migrator. Return as MigrationBuilder where TTo is the to + * version of the migrator, so that the next migrator can be chained. + * + * @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is + * required to instantiate version numbers unless a default constructor is defined. + * @returns A new MigrationBuilder with the to version of the migrator as the current version. + */ + with< + TMigrator extends Migrator, + TFrom extends VersionFrom & TCurrent, + TTo extends VersionTo + >( + ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo] + ): MigrationBuilder { + return this.addMigrator(migrate, "up"); + } + + /** Add a migrator to rollback on the MigrationBuilder's list of migrations. As with {@link with}, types of + * MigrationBuilder and Migrator must align. However, this time the migration is reversed so TCurrent of the + * MigrationBuilder must be equal to the to version of the migrator. Return as MigrationBuilder where TFrom + * is the from version of the migrator, so that the next migrator can be chained. + * + * @param migrate A migrator class or a tuple of a migrator class, the from version, and the to version. A tuple is + * required to instantiate version numbers unless a default constructor is defined. + * @returns A new MigrationBuilder with the from version of the migrator as the current version. + */ + rollback< + TMigrator extends Migrator, + TFrom extends VersionFrom, + TTo extends VersionTo & TCurrent + >( + ...migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TTo, TFrom] + ): MigrationBuilder { + if (migrate.length === 3) { + migrate = [migrate[0], migrate[2], migrate[1]]; + } + return this.addMigrator(migrate, "down"); + } + + /** Execute the migrations as defined in the MigrationBuilder's migrator buffer */ + migrate(helper: MigrationHelper): Promise { + return this.migrations.reduce( + (promise, migrator) => + promise.then(async () => { + await this.runMigrator(migrator.migrator, helper, migrator.direction); + }), + Promise.resolve() + ); + } + + private addMigrator< + TMigrator extends Migrator, + TFrom extends VersionFrom & TCurrent, + TTo extends VersionTo + >( + migrate: [new () => TMigrator] | [new (from: TFrom, to: TTo) => TMigrator, TFrom, TTo], + direction: Direction = "up" + ) { + const newMigration = + migrate.length === 1 + ? { migrator: new migrate[0](), direction } + : { migrator: new migrate[0](migrate[1], migrate[2]), direction }; + + return new MigrationBuilder([...this.migrations, newMigration]); + } + + private async runMigrator( + migrator: Migrator, + helper: MigrationHelper, + direction: Direction + ): Promise { + const shouldMigrate = await migrator.shouldMigrate(helper, direction); + helper.info( + `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) should migrate: ${shouldMigrate} - ${direction}` + ); + if (shouldMigrate) { + const method = direction === "up" ? migrator.migrate : migrator.rollback; + await method(helper); + helper.info( + `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) migrated - ${direction}` + ); + await migrator.updateVersion(helper, direction); + helper.info( + `Migrator ${migrator.constructor.name} (to version ${migrator.toVersion}) updated version - ${direction}` + ); + } + } +} diff --git a/libs/common/src/state-migrations/migration-helper.spec.ts b/libs/common/src/state-migrations/migration-helper.spec.ts new file mode 100644 index 00000000000..5b8a0f2eb4f --- /dev/null +++ b/libs/common/src/state-migrations/migration-helper.spec.ts @@ -0,0 +1,84 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages +import { LogService } from "../platform/abstractions/log.service"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations +import { AbstractStorageService } from "../platform/abstractions/storage.service"; + +import { MigrationHelper } from "./migration-helper"; + +const exampleJSON = { + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + otherStuff: "otherStuff1", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + otherStuff: "otherStuff2", + }, +}; + +describe("RemoveLegacyEtmKeyMigrator", () => { + let storage: MockProxy; + let logService: MockProxy; + let sut: MigrationHelper; + + beforeEach(() => { + logService = mock(); + storage = mock(); + storage.get.mockImplementation((key) => (exampleJSON as any)[key]); + + sut = new MigrationHelper(0, storage, logService); + }); + + describe("get", () => { + it("should delegate to storage.get", async () => { + await sut.get("key"); + expect(storage.get).toHaveBeenCalledWith("key"); + }); + }); + + describe("set", () => { + it("should delegate to storage.save", async () => { + await sut.set("key", "value"); + expect(storage.save).toHaveBeenCalledWith("key", "value"); + }); + }); + + describe("getAccounts", () => { + it("should return all accounts", async () => { + const accounts = await sut.getAccounts(); + expect(accounts).toEqual([ + { userId: "c493ed01-4e08-4e88-abc7-332f380ca760", account: { otherStuff: "otherStuff1" } }, + { userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9", account: { otherStuff: "otherStuff2" } }, + ]); + }); + + it("should handle missing authenticatedAccounts", async () => { + storage.get.mockImplementation((key) => + key === "authenticatedAccounts" ? undefined : (exampleJSON as any)[key] + ); + const accounts = await sut.getAccounts(); + expect(accounts).toEqual([]); + }); + }); +}); + +/** Helper to create well-mocked migration helpers in migration tests */ +export function mockMigrationHelper(storageJson: any): MockProxy { + const logService: MockProxy = mock(); + const storage: MockProxy = mock(); + storage.get.mockImplementation((key) => (storageJson as any)[key]); + storage.save.mockImplementation(async (key, value) => { + (storageJson as any)[key] = value; + }); + const helper = new MigrationHelper(0, storage, logService); + + const mockHelper = mock(); + mockHelper.get.mockImplementation((key) => helper.get(key)); + mockHelper.set.mockImplementation((key, value) => helper.set(key, value)); + mockHelper.getAccounts.mockImplementation(() => helper.getAccounts()); + return mockHelper; +} diff --git a/libs/common/src/state-migrations/migration-helper.ts b/libs/common/src/state-migrations/migration-helper.ts new file mode 100644 index 00000000000..a185aa69a99 --- /dev/null +++ b/libs/common/src/state-migrations/migration-helper.ts @@ -0,0 +1,37 @@ +// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages +import { LogService } from "../platform/abstractions/log.service"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations +import { AbstractStorageService } from "../platform/abstractions/storage.service"; + +export class MigrationHelper { + constructor( + public currentVersion: number, + private storageService: AbstractStorageService, + public logService: LogService + ) {} + + get(key: string): Promise { + return this.storageService.get(key); + } + + set(key: string, value: T): Promise { + this.logService.info(`Setting ${key}`); + return this.storageService.save(key, value); + } + + info(message: string): void { + this.logService.info(message); + } + + async getAccounts(): Promise< + { userId: string; account: ExpectedAccountType }[] + > { + const userIds = (await this.get("authenticatedAccounts")) ?? []; + return Promise.all( + userIds.map(async (userId) => ({ + userId, + account: await this.get(userId), + })) + ); + } +} diff --git a/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts b/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts new file mode 100644 index 00000000000..1ef910d4569 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/3-fix-premium.spec.ts @@ -0,0 +1,111 @@ +import { MockProxy } from "jest-mock-extended"; + +// eslint-disable-next-line import/no-restricted-paths -- Used for testing migration, which requires import +import { TokenService } from "../../auth/services/token.service"; +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { FixPremiumMigrator } from "./3-fix-premium"; + +function migrateExampleJSON() { + return { + global: { + stateVersion: 2, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + profile: { + otherStuff: "otherStuff2", + hasPremiumPersonally: null as boolean, + }, + tokens: { + otherStuff: "otherStuff3", + accessToken: "accessToken", + }, + otherStuff: "otherStuff4", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + profile: { + otherStuff: "otherStuff5", + hasPremiumPersonally: true, + }, + tokens: { + otherStuff: "otherStuff6", + accessToken: "accessToken", + }, + otherStuff: "otherStuff7", + }, + otherStuff: "otherStuff8", + }; +} + +jest.mock("../../auth/services/token.service", () => ({ + TokenService: { + decodeToken: jest.fn(), + }, +})); + +describe("FixPremiumMigrator", () => { + let helper: MockProxy; + let sut: FixPremiumMigrator; + const decodeTokenSpy = TokenService.decodeToken as jest.Mock; + + beforeEach(() => { + helper = mockMigrationHelper(migrateExampleJSON()); + sut = new FixPremiumMigrator(2, 3); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("migrate", () => { + it("should migrate hasPremiumPersonally", async () => { + decodeTokenSpy.mockResolvedValueOnce({ premium: true }); + await sut.migrate(helper); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + profile: { + otherStuff: "otherStuff2", + hasPremiumPersonally: true, + }, + tokens: { + otherStuff: "otherStuff3", + accessToken: "accessToken", + }, + otherStuff: "otherStuff4", + }); + }); + + it("should not migrate if decode throws", async () => { + decodeTokenSpy.mockRejectedValueOnce(new Error("test")); + await sut.migrate(helper); + + expect(helper.set).not.toHaveBeenCalled(); + }); + + it("should not migrate if decode returns null", async () => { + decodeTokenSpy.mockResolvedValueOnce(null); + await sut.migrate(helper); + + expect(helper.set).not.toHaveBeenCalled(); + }); + }); + + describe("updateVersion", () => { + it("should update version", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 3, + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/3-fix-premium.ts b/libs/common/src/state-migrations/migrations/3-fix-premium.ts new file mode 100644 index 00000000000..b6c69a99168 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/3-fix-premium.ts @@ -0,0 +1,48 @@ +// eslint-disable-next-line import/no-restricted-paths -- Used for token decoding, which are valid for days. We want the latest +import { TokenService } from "../../auth/services/token.service"; +import { MigrationHelper } from "../migration-helper"; +import { Migrator, IRREVERSIBLE, Direction } from "../migrator"; + +type ExpectedAccountType = { + profile?: { hasPremiumPersonally?: boolean }; + tokens?: { accessToken?: string }; +}; + +export class FixPremiumMigrator extends Migrator<2, 3> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function fixPremium(userId: string, account: ExpectedAccountType) { + if (account?.profile?.hasPremiumPersonally === null && account.tokens?.accessToken != null) { + let decodedToken: { premium: boolean }; + try { + decodedToken = await TokenService.decodeToken(account.tokens.accessToken); + } catch { + return; + } + + if (decodedToken?.premium == null) { + return; + } + + account.profile.hasPremiumPersonally = decodedToken?.premium; + return helper.set(userId, account); + } + } + + await Promise.all(accounts.map(({ userId, account }) => fixPremium(userId, account))); + } + + rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version + // it is nested inside a global object. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + const global: Record = (await helper.get("global")) || {}; + await helper.set("global", { ...global, stateVersion: endVersion }); + } +} diff --git a/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts new file mode 100644 index 00000000000..1701762118d --- /dev/null +++ b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.spec.ts @@ -0,0 +1,75 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { RemoveEverBeenUnlockedMigrator } from "./4-remove-ever-been-unlocked"; + +function migrateExampleJSON() { + return { + global: { + stateVersion: 3, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + profile: { + otherStuff: "otherStuff2", + everBeenUnlocked: true, + }, + otherStuff: "otherStuff3", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + profile: { + otherStuff: "otherStuff4", + everBeenUnlocked: false, + }, + otherStuff: "otherStuff5", + }, + otherStuff: "otherStuff6", + }; +} + +describe("RemoveEverBeenUnlockedMigrator", () => { + let helper: MockProxy; + let sut: RemoveEverBeenUnlockedMigrator; + + beforeEach(() => { + helper = mockMigrationHelper(migrateExampleJSON()); + sut = new RemoveEverBeenUnlockedMigrator(3, 4); + }); + + describe("migrate", () => { + it("should remove everBeenUnlocked from profile", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledTimes(2); + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + profile: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", { + profile: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + }); + }); + + describe("updateVersion", () => { + it("should update version up", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 4, + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts new file mode 100644 index 00000000000..cfa45958d06 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/4-remove-ever-been-unlocked.ts @@ -0,0 +1,32 @@ +import { MigrationHelper } from "../migration-helper"; +import { Direction, IRREVERSIBLE, Migrator } from "../migrator"; + +type ExpectedAccountType = { profile?: { everBeenUnlocked?: boolean } }; + +export class RemoveEverBeenUnlockedMigrator extends Migrator<3, 4> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function removeEverBeenUnlocked(userId: string, account: ExpectedAccountType) { + if (account?.profile?.everBeenUnlocked != null) { + delete account.profile.everBeenUnlocked; + return helper.set(userId, account); + } + } + + Promise.all(accounts.map(({ userId, account }) => removeEverBeenUnlocked(userId, account))); + } + + rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version + // it is nested inside a global object. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + const global: { stateVersion: number } = (await helper.get("global")) || ({} as any); + await helper.set("global", { ...global, stateVersion: endVersion }); + } +} diff --git a/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts new file mode 100644 index 00000000000..028a0b879b1 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.spec.ts @@ -0,0 +1,141 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { AddKeyTypeToOrgKeysMigrator } from "./5-add-key-type-to-org-keys"; + +function migrateExampleJSON() { + return { + global: { + stateVersion: 4, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + keys: { + organizationKeys: { + encrypted: { + orgOneId: "orgOneEncKey", + orgTwoId: "orgTwoEncKey", + }, + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + }; +} + +function rollbackExampleJSON() { + return { + global: { + stateVersion: 5, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + keys: { + organizationKeys: { + encrypted: { + orgOneId: { + type: "organization", + key: "orgOneEncKey", + }, + orgTwoId: { + type: "organization", + key: "orgTwoEncKey", + }, + }, + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + }; +} + +describe("AddKeyTypeToOrgKeysMigrator", () => { + let helper: MockProxy; + let sut: AddKeyTypeToOrgKeysMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(migrateExampleJSON()); + sut = new AddKeyTypeToOrgKeysMigrator(4, 5); + }); + + it("should add organization type to organization keys", async () => { + await sut.migrate(helper); + + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + keys: { + organizationKeys: { + encrypted: { + orgOneId: { + type: "organization", + key: "orgOneEncKey", + }, + orgTwoId: { + type: "organization", + key: "orgTwoEncKey", + }, + }, + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should update version", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 5, + otherStuff: "otherStuff1", + }); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackExampleJSON()); + sut = new AddKeyTypeToOrgKeysMigrator(4, 5); + }); + + it("should remove type from orgainzation keys", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + keys: { + organizationKeys: { + encrypted: { + orgOneId: "orgOneEncKey", + orgTwoId: "orgTwoEncKey", + }, + }, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + }); + + it("should update version down", async () => { + await sut.updateVersion(helper, "down"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 4, + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts new file mode 100644 index 00000000000..ab1550c52e3 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/5-add-key-type-to-org-keys.ts @@ -0,0 +1,67 @@ +import { MigrationHelper } from "../migration-helper"; +import { Direction, Migrator } from "../migrator"; + +type ExpectedAccountType = { keys?: { organizationKeys?: { encrypted: Record } } }; +type NewAccountType = { + keys?: { + organizationKeys?: { encrypted: Record }; + }; +}; + +export class AddKeyTypeToOrgKeysMigrator extends Migrator<4, 5> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function updateOrgKey(userId: string, account: ExpectedAccountType) { + const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted; + if (encryptedOrgKeys == null) { + return; + } + + const newOrgKeys: Record = {}; + + Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => { + newOrgKeys[orgId] = { + type: "organization", + key: encKey, + }; + }); + (account as any).keys.organizationKeys.encrypted = newOrgKeys; + + await helper.set(userId, account); + } + + Promise.all(accounts.map(({ userId, account }) => updateOrgKey(userId, account))); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function updateOrgKey(userId: string, account: NewAccountType) { + const encryptedOrgKeys = account?.keys?.organizationKeys?.encrypted; + if (encryptedOrgKeys == null) { + return; + } + + const newOrgKeys: Record = {}; + + Object.entries(encryptedOrgKeys).forEach(([orgId, encKey]) => { + newOrgKeys[orgId] = encKey.key; + }); + (account as any).keys.organizationKeys.encrypted = newOrgKeys; + + await helper.set(userId, account); + } + + Promise.all(accounts.map(async ({ userId, account }) => updateOrgKey(userId, account))); + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version + // it is nested inside a global object. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + const global: { stateVersion: number } = (await helper.get("global")) || ({} as any); + await helper.set("global", { ...global, stateVersion: endVersion }); + } +} diff --git a/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts new file mode 100644 index 00000000000..bc7b862f6cf --- /dev/null +++ b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.spec.ts @@ -0,0 +1,80 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { RemoveLegacyEtmKeyMigrator } from "./6-remove-legacy-etm-key"; + +function exampleJSON() { + return { + global: { + stateVersion: 5, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + "fd005ea6-a16a-45ef-ba4a-a194269bfd73", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + keys: { + legacyEtmKey: "legacyEtmKey", + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + keys: { + legacyEtmKey: "legacyEtmKey", + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("RemoveLegacyEtmKeyMigrator", () => { + let helper: MockProxy; + let sut: RemoveLegacyEtmKeyMigrator; + + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON()); + sut = new RemoveLegacyEtmKeyMigrator(5, 6); + }); + + describe("migrate", () => { + it("should remove legacyEtmKey from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + keys: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", { + keys: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + }); + }); + + describe("rollback", () => { + it("should throw", async () => { + await expect(sut.rollback(helper)).rejects.toThrow(); + }); + }); + + describe("updateVersion", () => { + it("should update version up", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 6, + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts new file mode 100644 index 00000000000..2a06916ea33 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/6-remove-legacy-etm-key.ts @@ -0,0 +1,32 @@ +import { MigrationHelper } from "../migration-helper"; +import { Direction, IRREVERSIBLE, Migrator } from "../migrator"; + +type ExpectedAccountType = { keys?: { legacyEtmKey?: string } }; + +export class RemoveLegacyEtmKeyMigrator extends Migrator<5, 6> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function updateAccount(userId: string, account: ExpectedAccountType) { + if (account?.keys?.legacyEtmKey) { + delete account.keys.legacyEtmKey; + await helper.set(userId, account); + } + } + + await Promise.all(accounts.map(({ userId, account }) => updateAccount(userId, account))); + } + + async rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version + // it is nested inside a global object. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + const global: { stateVersion: number } = (await helper.get("global")) || ({} as any); + await helper.set("global", { ...global, stateVersion: endVersion }); + } +} diff --git a/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts new file mode 100644 index 00000000000..fe73f8a9bc4 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.spec.ts @@ -0,0 +1,102 @@ +import { MockProxy, any, matches } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { MoveBiometricAutoPromptToAccount } from "./7-move-biometric-auto-prompt-to-account"; + +function exampleJSON() { + return { + global: { + stateVersion: 6, + noAutoPromptBiometrics: true, + otherStuff: "otherStuff1", + }, + authenticatedAccounts: [ + "c493ed01-4e08-4e88-abc7-332f380ca760", + "23e61a5f-2ece-4f5e-b499-f0bc489482a9", + "fd005ea6-a16a-45ef-ba4a-a194269bfd73", + ], + "c493ed01-4e08-4e88-abc7-332f380ca760": { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + "23e61a5f-2ece-4f5e-b499-f0bc489482a9": { + settings: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +describe("RemoveLegacyEtmKeyMigrator", () => { + let helper: MockProxy; + let sut: MoveBiometricAutoPromptToAccount; + + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON()); + sut = new MoveBiometricAutoPromptToAccount(6, 7); + }); + + describe("migrate", () => { + it("should remove noAutoPromptBiometrics from global", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("global", { + otherStuff: "otherStuff1", + stateVersion: 6, + }); + }); + + it("should set disableAutoBiometricsPrompt to true on all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("c493ed01-4e08-4e88-abc7-332f380ca760", { + settings: { + disableAutoBiometricsPrompt: true, + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("23e61a5f-2ece-4f5e-b499-f0bc489482a9", { + settings: { + disableAutoBiometricsPrompt: true, + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }); + }); + + it("should not set disableAutoBiometricsPrompt to true on accounts if noAutoPromptBiometrics is false", async () => { + const json = exampleJSON(); + json.global.noAutoPromptBiometrics = false; + helper = mockMigrationHelper(json); + await sut.migrate(helper); + expect(helper.set).not.toHaveBeenCalledWith( + matches((s) => s != "global"), + any() + ); + }); + }); + + describe("rollback", () => { + it("should throw", async () => { + await expect(sut.rollback(helper)).rejects.toThrow(); + }); + }); + + describe("updateVersion", () => { + it("should update version up", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith( + "global", + Object.assign({}, exampleJSON().global, { + stateVersion: 7, + }) + ); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts new file mode 100644 index 00000000000..0ac065d60c1 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/7-move-biometric-auto-prompt-to-account.ts @@ -0,0 +1,45 @@ +import { MigrationHelper } from "../migration-helper"; +import { Direction, IRREVERSIBLE, Migrator } from "../migrator"; + +type ExpectedAccountType = { settings?: { disableAutoBiometricsPrompt?: boolean } }; + +export class MoveBiometricAutoPromptToAccount extends Migrator<6, 7> { + async migrate(helper: MigrationHelper): Promise { + const global = await helper.get<{ noAutoPromptBiometrics?: boolean }>("global"); + const noAutoPromptBiometrics = global?.noAutoPromptBiometrics ?? false; + + const accounts = await helper.getAccounts(); + async function updateAccount(userId: string, account: ExpectedAccountType) { + if (account == null) { + return; + } + + if (noAutoPromptBiometrics) { + account.settings = Object.assign(account?.settings ?? {}, { + disableAutoBiometricsPrompt: true, + }); + await helper.set(userId, account); + } + } + + delete global.noAutoPromptBiometrics; + + await Promise.all([ + ...accounts.map(({ userId, account }) => updateAccount(userId, account)), + helper.set("global", global), + ]); + } + + async rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but for this version + // it is nested inside a global object. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + const global: { stateVersion: number } = (await helper.get("global")) || ({} as any); + await helper.set("global", { ...global, stateVersion: endVersion }); + } +} diff --git a/libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts b/libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts new file mode 100644 index 00000000000..8c84fd642ea --- /dev/null +++ b/libs/common/src/state-migrations/migrations/8-move-state-version.spec.ts @@ -0,0 +1,90 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { MoveStateVersionMigrator } from "./8-move-state-version"; + +function migrateExampleJSON() { + return { + global: { + stateVersion: 6, + otherStuff: "otherStuff1", + }, + otherStuff: "otherStuff2", + }; +} + +function rollbackExampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + stateVersion: 7, + otherStuff: "otherStuff2", + }; +} + +describe("moveStateVersion", () => { + let helper: MockProxy; + let sut: MoveStateVersionMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(migrateExampleJSON()); + sut = new MoveStateVersionMigrator(7, 8); + }); + + it("should move state version to root", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("stateVersion", 6); + }); + + it("should remove state version from global", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("global", { + otherStuff: "otherStuff1", + }); + }); + + it("should throw if state version not found", async () => { + helper.get.mockReturnValue({ otherStuff: "otherStuff1" } as any); + await expect(sut.migrate(helper)).rejects.toThrow( + "Migration failed, state version not found" + ); + }); + + it("should update version up", async () => { + await sut.updateVersion(helper, "up"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("stateVersion", 8); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackExampleJSON()); + sut = new MoveStateVersionMigrator(7, 8); + }); + + it("should move state version to global", async () => { + await sut.rollback(helper); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 7, + otherStuff: "otherStuff1", + }); + expect(helper.set).toHaveBeenCalledWith("stateVersion", undefined); + }); + + it("should update version down", async () => { + await sut.updateVersion(helper, "down"); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + stateVersion: 7, + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/8-move-state-version.ts b/libs/common/src/state-migrations/migrations/8-move-state-version.ts new file mode 100644 index 00000000000..cbcdf423843 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/8-move-state-version.ts @@ -0,0 +1,37 @@ +import { JsonObject } from "type-fest"; + +import { MigrationHelper } from "../migration-helper"; +import { Direction, Migrator } from "../migrator"; + +export class MoveStateVersionMigrator extends Migrator<7, 8> { + async migrate(helper: MigrationHelper): Promise { + const global = await helper.get<{ stateVersion: number }>("global"); + if (global.stateVersion) { + await helper.set("stateVersion", global.stateVersion); + delete global.stateVersion; + await helper.set("global", global); + } else { + throw new Error("Migration failed, state version not found"); + } + } + + async rollback(helper: MigrationHelper): Promise { + const version = await helper.get("stateVersion"); + const global = await helper.get("global"); + await helper.set("global", { ...global, stateVersion: version }); + await helper.set("stateVersion", undefined); + } + + // Override is necessary because default implementation assumes `stateVersion` at the root, but this migration moves + // it from a `global` object to root.This makes for unique rollback versioning. + override async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + if (direction === "up") { + await helper.set("stateVersion", endVersion); + } else { + const global: { stateVersion: number } = (await helper.get("global")) || ({} as any); + await helper.set("global", { ...global, stateVersion: endVersion }); + } + } +} diff --git a/libs/common/src/state-migrations/migrations/min-version.spec.ts b/libs/common/src/state-migrations/migrations/min-version.spec.ts new file mode 100644 index 00000000000..26e106c19a9 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/min-version.spec.ts @@ -0,0 +1,29 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MIN_VERSION } from "../migrate"; +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { MinVersionMigrator } from "./min-version"; + +describe("MinVersionMigrator", () => { + let helper: MockProxy; + let sut: MinVersionMigrator; + + beforeEach(() => { + helper = mockMigrationHelper(null); + sut = new MinVersionMigrator(); + }); + + describe("shouldMigrate", () => { + it("should return true if current version is less than min version", async () => { + helper.currentVersion = MIN_VERSION - 1; + expect(await sut.shouldMigrate(helper)).toBe(true); + }); + + it("should return false if current version is greater than min version", async () => { + helper.currentVersion = MIN_VERSION + 1; + expect(await sut.shouldMigrate(helper)).toBe(false); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/min-version.ts b/libs/common/src/state-migrations/migrations/min-version.ts new file mode 100644 index 00000000000..a417cc51a3c --- /dev/null +++ b/libs/common/src/state-migrations/migrations/min-version.ts @@ -0,0 +1,26 @@ +import { MinVersion, MIN_VERSION } from "../migrate"; +import { MigrationHelper } from "../migration-helper"; +import { IRREVERSIBLE, Migrator } from "../migrator"; + +export function minVersionError(current: number) { + return `Your local data is too old to be migrated. Your current state version is ${current}, but minimum version is ${MIN_VERSION}.`; +} + +export class MinVersionMigrator extends Migrator<0, MinVersion> { + constructor() { + super(0, MIN_VERSION); + } + + // Overrides the default implementation to catch any version that may be passed in. + override shouldMigrate(helper: MigrationHelper): Promise { + return Promise.resolve(helper.currentVersion < MIN_VERSION); + } + async migrate(helper: MigrationHelper): Promise { + if (helper.currentVersion < MIN_VERSION) { + throw new Error(minVersionError(helper.currentVersion)); + } + } + async rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } +} diff --git a/libs/common/src/state-migrations/migrator.spec.ts b/libs/common/src/state-migrations/migrator.spec.ts new file mode 100644 index 00000000000..3abaa277273 --- /dev/null +++ b/libs/common/src/state-migrations/migrator.spec.ts @@ -0,0 +1,75 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +// eslint-disable-next-line import/no-restricted-paths -- Needed to print log messages +import { LogService } from "../platform/abstractions/log.service"; +// eslint-disable-next-line import/no-restricted-paths -- Needed to interface with storage locations +import { AbstractStorageService } from "../platform/abstractions/storage.service"; + +import { MigrationHelper } from "./migration-helper"; +import { Migrator } from "./migrator"; + +describe("migrator default methods", () => { + class TestMigrator extends Migrator<0, 1> { + async migrate(helper: MigrationHelper): Promise { + await helper.set("test", "test"); + } + async rollback(helper: MigrationHelper): Promise { + await helper.set("test", "rollback"); + } + } + + let storage: MockProxy; + let logService: MockProxy; + let helper: MigrationHelper; + let sut: TestMigrator; + + beforeEach(() => { + storage = mock(); + logService = mock(); + helper = new MigrationHelper(0, storage, logService); + sut = new TestMigrator(0, 1); + }); + + describe("shouldMigrate", () => { + describe("up", () => { + it("should return true if the current version equals the from version", async () => { + expect(await sut.shouldMigrate(helper, "up")).toBe(true); + }); + + it("should return false if the current version does not equal the from version", async () => { + helper.currentVersion = 1; + expect(await sut.shouldMigrate(helper, "up")).toBe(false); + }); + }); + + describe("down", () => { + it("should return true if the current version equals the to version", async () => { + helper.currentVersion = 1; + expect(await sut.shouldMigrate(helper, "down")).toBe(true); + }); + + it("should return false if the current version does not equal the to version", async () => { + expect(await sut.shouldMigrate(helper, "down")).toBe(false); + }); + }); + }); + + describe("updateVersion", () => { + describe("up", () => { + it("should update the version", async () => { + await sut.updateVersion(helper, "up"); + expect(storage.save).toBeCalledWith("stateVersion", 1); + expect(helper.currentVersion).toBe(1); + }); + }); + + describe("down", () => { + it("should update the version", async () => { + helper.currentVersion = 1; + await sut.updateVersion(helper, "down"); + expect(storage.save).toBeCalledWith("stateVersion", 0); + expect(helper.currentVersion).toBe(0); + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrator.ts b/libs/common/src/state-migrations/migrator.ts new file mode 100644 index 00000000000..aba81372d49 --- /dev/null +++ b/libs/common/src/state-migrations/migrator.ts @@ -0,0 +1,40 @@ +import { NonNegativeInteger } from "type-fest"; + +import { MigrationHelper } from "./migration-helper"; + +export const IRREVERSIBLE = new Error("Irreversible migration"); + +export type VersionFrom = T extends Migrator + ? TFrom extends NonNegativeInteger + ? TFrom + : never + : never; +export type VersionTo = T extends Migrator + ? TTo extends NonNegativeInteger + ? TTo + : never + : never; +export type Direction = "up" | "down"; + +export abstract class Migrator { + constructor(public fromVersion: TFrom, public toVersion: TTo) { + if (fromVersion == null || toVersion == null) { + throw new Error("Invalid migration"); + } + if (fromVersion > toVersion) { + throw new Error("Invalid migration"); + } + } + + shouldMigrate(helper: MigrationHelper, direction: Direction): Promise { + const startVersion = direction === "up" ? this.fromVersion : this.toVersion; + return Promise.resolve(helper.currentVersion === startVersion); + } + abstract migrate(helper: MigrationHelper): Promise; + abstract rollback(helper: MigrationHelper): Promise; + async updateVersion(helper: MigrationHelper, direction: Direction): Promise { + const endVersion = direction === "up" ? this.toVersion : this.fromVersion; + helper.currentVersion = endVersion; + await helper.set("stateVersion", endVersion); + } +} diff --git a/libs/common/src/tools/generator/generator-options.ts b/libs/common/src/tools/generator/generator-options.ts new file mode 100644 index 00000000000..4f8eb293ab5 --- /dev/null +++ b/libs/common/src/tools/generator/generator-options.ts @@ -0,0 +1,3 @@ +export type GeneratorOptions = { + type?: "password" | "username"; +}; diff --git a/libs/common/src/tools/generator/password/index.ts b/libs/common/src/tools/generator/password/index.ts index 4dafe20d3aa..bacc2c0c70d 100644 --- a/libs/common/src/tools/generator/password/index.ts +++ b/libs/common/src/tools/generator/password/index.ts @@ -1,3 +1,4 @@ +export { PasswordGeneratorOptions } from "./password-generator-options"; export { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; export { PasswordGenerationService } from "./password-generation.service"; export { GeneratedPasswordHistory } from "./generated-password-history"; diff --git a/libs/common/src/tools/generator/password/password-generation.service.abstraction.ts b/libs/common/src/tools/generator/password/password-generation.service.abstraction.ts index 7de8c28ed72..3f2c8424ad0 100644 --- a/libs/common/src/tools/generator/password/password-generation.service.abstraction.ts +++ b/libs/common/src/tools/generator/password/password-generation.service.abstraction.ts @@ -1,5 +1,3 @@ -import * as zxcvbn from "zxcvbn"; - import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; import { GeneratedPasswordHistory } from "./generated-password-history"; @@ -17,11 +15,6 @@ export abstract class PasswordGenerationServiceAbstraction { getHistory: () => Promise; addHistory: (password: string) => Promise; clear: (userId?: string) => Promise; - passwordStrength: ( - password: string, - email?: string, - userInputs?: string[] - ) => zxcvbn.ZXCVBNResult; normalizeOptions: ( options: PasswordGeneratorOptions, enforcedPolicyOptions: PasswordGeneratorPolicyOptions diff --git a/libs/common/src/tools/generator/password/password-generation.service.ts b/libs/common/src/tools/generator/password/password-generation.service.ts index 43c4a0f1491..55bab173cec 100644 --- a/libs/common/src/tools/generator/password/password-generation.service.ts +++ b/libs/common/src/tools/generator/password/password-generation.service.ts @@ -1,12 +1,10 @@ -import * as zxcvbn from "zxcvbn"; - -import { CryptoService } from "../../../abstractions/crypto.service"; -import { StateService } from "../../../abstractions/state.service"; import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "../../../admin-console/enums"; import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options"; -import { EFFLongWordList } from "../../../misc/wordlist"; -import { EncString } from "../../../models/domain/enc-string"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { StateService } from "../../../platform/abstractions/state.service"; +import { EFFLongWordList } from "../../../platform/misc/wordlist"; +import { EncString } from "../../../platform/models/domain/enc-string"; import { GeneratedPasswordHistory } from "./generated-password-history"; import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; @@ -338,7 +336,7 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr } async getHistory(): Promise { - const hasKey = await this.cryptoService.hasKey(); + const hasKey = await this.cryptoService.hasUserKey(); if (!hasKey) { return new Array(); } @@ -358,7 +356,7 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr async addHistory(password: string): Promise { // Cannot add new history if no key is available - const hasKey = await this.cryptoService.hasKey(); + const hasKey = await this.cryptoService.hasUserKey(); if (!hasKey) { return; } @@ -387,33 +385,6 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr await this.stateService.setDecryptedPasswordGenerationHistory(null, { userId: userId }); } - /** - * Calculates a password strength score using zxcvbn. - * @param password The password to calculate the strength of. - * @param emailInput An unparsed email address to use as user input. - * @param userInputs An array of additional user inputs to use when calculating the strength. - */ - passwordStrength( - password: string, - emailInput: string = null, - userInputs: string[] = null - ): zxcvbn.ZXCVBNResult { - if (password == null || password.length === 0) { - return null; - } - const globalUserInputs = [ - "bitwarden", - "bit", - "warden", - ...(userInputs ?? []), - ...this.emailToUserInputs(emailInput), - ]; - // Use a hash set to get rid of any duplicate user inputs - const finalUserInputs = Array.from(new Set(globalUserInputs)); - const result = zxcvbn(password, finalUserInputs); - return result; - } - normalizeOptions( options: PasswordGeneratorOptions, enforcedPolicyOptions: PasswordGeneratorPolicyOptions @@ -476,27 +447,6 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr this.sanitizePasswordLength(options, false); } - /** - * Convert an email address into a list of user inputs for zxcvbn by - * taking the local part of the email address and splitting it into words. - * @param email - * @private - */ - private emailToUserInputs(email: string): string[] { - if (email == null || email.length === 0) { - return []; - } - const atPosition = email.indexOf("@"); - if (atPosition < 0) { - return []; - } - return email - .substring(0, atPosition) - .trim() - .toLowerCase() - .split(/[^A-Za-z0-9]/); - } - private capitalize(str: string) { return str.charAt(0).toUpperCase() + str.slice(1); } diff --git a/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts index 6b4bc423148..c50326e1fa4 100644 --- a/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts +++ b/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts @@ -6,11 +6,15 @@ import { ForwarderOptions } from "./forwarder-options"; export class AnonAddyForwarder implements Forwarder { async generate(apiService: ApiService, options: ForwarderOptions): Promise { if (options.apiKey == null || options.apiKey === "") { - throw "Invalid AnonAddy API token."; + throw "Invalid addy.io API token."; } if (options.anonaddy?.domain == null || options.anonaddy.domain === "") { - throw "Invalid AnonAddy domain."; + throw "Invalid addy.io domain."; } + if (options.anonaddy?.baseUrl == null || options.anonaddy.baseUrl === "") { + throw "Invalid addy.io url."; + } + const requestInit: RequestInit = { redirect: "manual", cache: "no-store", @@ -18,9 +22,10 @@ export class AnonAddyForwarder implements Forwarder { headers: new Headers({ Authorization: "Bearer " + options.apiKey, "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", }), }; - const url = "https://app.anonaddy.com/api/v1/aliases"; + const url = options.anonaddy.baseUrl + "/api/v1/aliases"; requestInit.body = JSON.stringify({ domain: options.anonaddy.domain, description: @@ -34,8 +39,11 @@ export class AnonAddyForwarder implements Forwarder { return json?.data?.email; } if (response.status === 401) { - throw "Invalid AnonAddy API token."; + throw "Invalid addy.io API token."; + } + if (response?.statusText != null) { + throw "addy.io error:\n" + response.statusText; } - throw "Unknown AnonAddy error occurred."; + throw "Unknown addy.io error occurred."; } } diff --git a/libs/common/src/tools/generator/username/email-forwarders/forward-email-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/forward-email-forwarder.ts new file mode 100644 index 00000000000..98801c9e3da --- /dev/null +++ b/libs/common/src/tools/generator/username/email-forwarders/forward-email-forwarder.ts @@ -0,0 +1,49 @@ +import { ApiService } from "../../../../abstractions/api.service"; +import { Utils } from "../../../../platform/misc/utils"; + +import { Forwarder } from "./forwarder"; +import { ForwarderOptions } from "./forwarder-options"; + +export class ForwardEmailForwarder implements Forwarder { + async generate(apiService: ApiService, options: ForwarderOptions): Promise { + if (options.apiKey == null || options.apiKey === "") { + throw "Invalid Forward Email API key."; + } + if (options.forwardemail?.domain == null || options.forwardemail.domain === "") { + throw "Invalid Forward Email domain."; + } + const requestInit: RequestInit = { + redirect: "manual", + cache: "no-store", + method: "POST", + headers: new Headers({ + Authorization: "Basic " + Utils.fromUtf8ToB64(options.apiKey + ":"), + "Content-Type": "application/json", + }), + }; + const url = `https://api.forwardemail.net/v1/domains/${options.forwardemail.domain}/aliases`; + requestInit.body = JSON.stringify({ + labels: options.website, + description: + (options.website != null ? "Website: " + options.website + ". " : "") + + "Generated by Bitwarden.", + }); + const request = new Request(url, requestInit); + const response = await apiService.nativeFetch(request); + if (response.status === 200 || response.status === 201) { + const json = await response.json(); + return json?.name + "@" + (json?.domain?.name || options.forwardemail.domain); + } + if (response.status === 401) { + throw "Invalid Forward Email API key."; + } + const json = await response.json(); + if (json?.message != null) { + throw "Forward Email error:\n" + json.message; + } + if (json?.error != null) { + throw "Forward Email error:\n" + json.error; + } + throw "Unknown Forward Email error occurred."; + } +} diff --git a/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts b/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts index ef5311f0d7d..cca6dd34dd1 100644 --- a/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts +++ b/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts @@ -3,6 +3,7 @@ export class ForwarderOptions { website: string; fastmail = new FastmailForwarderOptions(); anonaddy = new AnonAddyForwarderOptions(); + forwardemail = new ForwardEmailForwarderOptions(); } export class FastmailForwarderOptions { @@ -11,4 +12,9 @@ export class FastmailForwarderOptions { export class AnonAddyForwarderOptions { domain: string; + baseUrl: string; +} + +export class ForwardEmailForwarderOptions { + domain: string; } diff --git a/libs/common/src/tools/generator/username/email-forwarders/index.ts b/libs/common/src/tools/generator/username/email-forwarders/index.ts index a9a437225ef..d102cc236ee 100644 --- a/libs/common/src/tools/generator/username/email-forwarders/index.ts +++ b/libs/common/src/tools/generator/username/email-forwarders/index.ts @@ -5,3 +5,4 @@ export { FirefoxRelayForwarder } from "./firefox-relay-forwarder"; export { Forwarder } from "./forwarder"; export { ForwarderOptions } from "./forwarder-options"; export { SimpleLoginForwarder } from "./simple-login-forwarder"; +export { ForwardEmailForwarder } from "./forward-email-forwarder"; diff --git a/libs/common/src/tools/generator/username/email-forwarders/simple-login-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/simple-login-forwarder.ts index bf3eea42326..7ecd72dc59c 100644 --- a/libs/common/src/tools/generator/username/email-forwarders/simple-login-forwarder.ts +++ b/libs/common/src/tools/generator/username/email-forwarders/simple-login-forwarder.ts @@ -35,13 +35,9 @@ export class SimpleLoginForwarder implements Forwarder { if (response.status === 401) { throw "Invalid SimpleLogin API key."; } - try { - const json = await response.json(); - if (json?.error != null) { - throw "SimpleLogin error:" + json.error; - } - } catch { - // Do nothing... + const json = await response.json(); + if (json?.error != null) { + throw "SimpleLogin error:" + json.error; } throw "Unknown SimpleLogin error occurred."; } diff --git a/libs/common/src/tools/generator/username/index.ts b/libs/common/src/tools/generator/username/index.ts index b4f73a29edb..c4197b4344f 100644 --- a/libs/common/src/tools/generator/username/index.ts +++ b/libs/common/src/tools/generator/username/index.ts @@ -1,2 +1,3 @@ +export { UsernameGeneratorOptions } from "./username-generation-options"; export { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction"; export { UsernameGenerationService } from "./username-generation.service"; diff --git a/libs/common/src/tools/generator/username/username-generation-options.ts b/libs/common/src/tools/generator/username/username-generation-options.ts new file mode 100644 index 00000000000..970f7e945e3 --- /dev/null +++ b/libs/common/src/tools/generator/username/username-generation-options.ts @@ -0,0 +1,20 @@ +export type UsernameGeneratorOptions = { + type?: "word" | "subaddress" | "catchall" | "forwarded"; + wordCapitalize?: boolean; + wordIncludeNumber?: boolean; + subaddressType?: "random" | "website-name"; + subaddressEmail?: string; + catchallType?: "random" | "website-name"; + catchallDomain?: string; + website?: string; + forwardedService?: string; + forwardedAnonAddyApiToken?: string; + forwardedAnonAddyDomain?: string; + forwardedAnonAddyBaseUrl?: string; + forwardedDuckDuckGoToken?: string; + forwardedFirefoxApiToken?: string; + forwardedFastmailApiToken?: string; + forwardedForwardEmailApiToken?: string; + forwardedForwardEmailDomain?: string; + forwardedSimpleLoginApiKey?: string; +}; diff --git a/libs/common/src/tools/generator/username/username-generation.service.abstraction.ts b/libs/common/src/tools/generator/username/username-generation.service.abstraction.ts index 52accf7d8ca..05affef0e2f 100644 --- a/libs/common/src/tools/generator/username/username-generation.service.abstraction.ts +++ b/libs/common/src/tools/generator/username/username-generation.service.abstraction.ts @@ -1,9 +1,11 @@ +import { UsernameGeneratorOptions } from "./username-generation-options"; + export abstract class UsernameGenerationServiceAbstraction { - generateUsername: (options: any) => Promise; - generateWord: (options: any) => Promise; - generateSubaddress: (options: any) => Promise; - generateCatchall: (options: any) => Promise; - generateForwarded: (options: any) => Promise; - getOptions: () => Promise; - saveOptions: (options: any) => Promise; + generateUsername: (options: UsernameGeneratorOptions) => Promise; + generateWord: (options: UsernameGeneratorOptions) => Promise; + generateSubaddress: (options: UsernameGeneratorOptions) => Promise; + generateCatchall: (options: UsernameGeneratorOptions) => Promise; + generateForwarded: (options: UsernameGeneratorOptions) => Promise; + getOptions: () => Promise; + saveOptions: (options: UsernameGeneratorOptions) => Promise; } diff --git a/libs/common/src/tools/generator/username/username-generation.service.ts b/libs/common/src/tools/generator/username/username-generation.service.ts index 4ad305e8b2a..35a3a73da90 100644 --- a/libs/common/src/tools/generator/username/username-generation.service.ts +++ b/libs/common/src/tools/generator/username/username-generation.service.ts @@ -1,20 +1,22 @@ import { ApiService } from "../../../abstractions/api.service"; -import { CryptoService } from "../../../abstractions/crypto.service"; -import { StateService } from "../../../abstractions/state.service"; -import { EFFLongWordList } from "../../../misc/wordlist"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { StateService } from "../../../platform/abstractions/state.service"; +import { EFFLongWordList } from "../../../platform/misc/wordlist"; import { AnonAddyForwarder, DuckDuckGoForwarder, FastmailForwarder, FirefoxRelayForwarder, + ForwardEmailForwarder, Forwarder, ForwarderOptions, SimpleLoginForwarder, } from "./email-forwarders"; +import { UsernameGeneratorOptions } from "./username-generation-options"; import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction"; -const DefaultOptions = { +const DefaultOptions: UsernameGeneratorOptions = { type: "word", wordCapitalize: true, wordIncludeNumber: true, @@ -22,6 +24,8 @@ const DefaultOptions = { catchallType: "random", forwardedService: "", forwardedAnonAddyDomain: "anonaddy.me", + forwardedAnonAddyBaseUrl: "https://app.addy.io", + forwardedForwardEmailDomain: "hideaddress.net", }; export class UsernameGenerationService implements UsernameGenerationServiceAbstraction { @@ -31,7 +35,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr private apiService: ApiService ) {} - generateUsername(options: any): Promise { + generateUsername(options: UsernameGeneratorOptions): Promise { if (options.type === "catchall") { return this.generateCatchall(options); } else if (options.type === "subaddress") { @@ -43,7 +47,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr } } - async generateWord(options: any): Promise { + async generateWord(options: UsernameGeneratorOptions): Promise { const o = Object.assign({}, DefaultOptions, options); if (o.wordCapitalize == null) { @@ -65,7 +69,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr return word; } - async generateSubaddress(options: any): Promise { + async generateSubaddress(options: UsernameGeneratorOptions): Promise { const o = Object.assign({}, DefaultOptions, options); const subaddressEmail = o.subaddressEmail; @@ -92,7 +96,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr return emailBeginning + "+" + subaddressString + "@" + emailEnding; } - async generateCatchall(options: any): Promise { + async generateCatchall(options: UsernameGeneratorOptions): Promise { const o = Object.assign({}, DefaultOptions, options); if (o.catchallDomain == null || o.catchallDomain === "") { @@ -111,7 +115,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr return startString + "@" + o.catchallDomain; } - async generateForwarded(options: any): Promise { + async generateForwarded(options: UsernameGeneratorOptions): Promise { const o = Object.assign({}, DefaultOptions, options); if (o.forwardedService == null) { @@ -128,6 +132,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr forwarder = new AnonAddyForwarder(); forwarderOptions.apiKey = o.forwardedAnonAddyApiToken; forwarderOptions.anonaddy.domain = o.forwardedAnonAddyDomain; + forwarderOptions.anonaddy.baseUrl = o.forwardedAnonAddyBaseUrl; } else if (o.forwardedService === "firefoxrelay") { forwarder = new FirefoxRelayForwarder(); forwarderOptions.apiKey = o.forwardedFirefoxApiToken; @@ -137,6 +142,10 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr } else if (o.forwardedService === "duckduckgo") { forwarder = new DuckDuckGoForwarder(); forwarderOptions.apiKey = o.forwardedDuckDuckGoToken; + } else if (o.forwardedService === "forwardemail") { + forwarder = new ForwardEmailForwarder(); + forwarderOptions.apiKey = o.forwardedForwardEmailApiToken; + forwarderOptions.forwardemail.domain = o.forwardedForwardEmailDomain; } if (forwarder == null) { @@ -146,7 +155,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr return forwarder.generate(this.apiService, forwarderOptions); } - async getOptions(): Promise { + async getOptions(): Promise { let options = await this.stateService.getUsernameGenerationOptions(); if (options == null) { options = Object.assign({}, DefaultOptions); @@ -157,7 +166,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr return options; } - async saveOptions(options: any) { + async saveOptions(options: UsernameGeneratorOptions) { await this.stateService.setUsernameGenerationOptions(options); } diff --git a/libs/common/src/tools/password-strength/index.ts b/libs/common/src/tools/password-strength/index.ts new file mode 100644 index 00000000000..3e160fa8204 --- /dev/null +++ b/libs/common/src/tools/password-strength/index.ts @@ -0,0 +1,2 @@ +export { PasswordStrengthServiceAbstraction } from "./password-strength.service.abstraction"; +export { PasswordStrengthService } from "./password-strength.service"; diff --git a/libs/common/src/tools/password-strength/password-strength.service.abstraction.ts b/libs/common/src/tools/password-strength/password-strength.service.abstraction.ts new file mode 100644 index 00000000000..5708a4c7bd1 --- /dev/null +++ b/libs/common/src/tools/password-strength/password-strength.service.abstraction.ts @@ -0,0 +1,5 @@ +import { ZXCVBNResult } from "zxcvbn"; + +export abstract class PasswordStrengthServiceAbstraction { + getPasswordStrength: (password: string, email?: string, userInputs?: string[]) => ZXCVBNResult; +} diff --git a/libs/common/src/tools/password-strength/password-strength.service.ts b/libs/common/src/tools/password-strength/password-strength.service.ts new file mode 100644 index 00000000000..78a8f963991 --- /dev/null +++ b/libs/common/src/tools/password-strength/password-strength.service.ts @@ -0,0 +1,53 @@ +import * as zxcvbn from "zxcvbn"; + +import { PasswordStrengthServiceAbstraction } from "./password-strength.service.abstraction"; + +export class PasswordStrengthService implements PasswordStrengthServiceAbstraction { + /** + * Calculates a password strength score using zxcvbn. + * @param password The password to calculate the strength of. + * @param emailInput An unparsed email address to use as user input. + * @param userInputs An array of additional user inputs to use when calculating the strength. + */ + getPasswordStrength( + password: string, + emailInput: string = null, + userInputs: string[] = null + ): zxcvbn.ZXCVBNResult { + if (password == null || password.length === 0) { + return null; + } + const globalUserInputs = [ + "bitwarden", + "bit", + "warden", + ...(userInputs ?? []), + ...this.emailToUserInputs(emailInput), + ]; + // Use a hash set to get rid of any duplicate user inputs + const finalUserInputs = Array.from(new Set(globalUserInputs)); + const result = zxcvbn(password, finalUserInputs); + return result; + } + + /** + * Convert an email address into a list of user inputs for zxcvbn by + * taking the local part of the email address and splitting it into words. + * @param email + * @private + */ + private emailToUserInputs(email: string): string[] { + if (email == null || email.length === 0) { + return []; + } + const atPosition = email.indexOf("@"); + if (atPosition < 0) { + return []; + } + return email + .substring(0, atPosition) + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/); + } +} diff --git a/libs/common/src/tools/send/models/domain/send-access.ts b/libs/common/src/tools/send/models/domain/send-access.ts index ae83784da42..98bd8864e72 100644 --- a/libs/common/src/tools/send/models/domain/send-access.ts +++ b/libs/common/src/tools/send/models/domain/send-access.ts @@ -1,6 +1,6 @@ -import Domain from "../../../../models/domain/domain-base"; -import { EncString } from "../../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../../models/domain/symmetric-crypto-key"; +import Domain from "../../../../platform/models/domain/domain-base"; +import { EncString } from "../../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { SendType } from "../../enums/send-type"; import { SendAccessResponse } from "../response/send-access.response"; import { SendAccessView } from "../view/send-access.view"; diff --git a/libs/common/src/tools/send/models/domain/send-file.ts b/libs/common/src/tools/send/models/domain/send-file.ts index a99ba222dff..7c26fbb72ae 100644 --- a/libs/common/src/tools/send/models/domain/send-file.ts +++ b/libs/common/src/tools/send/models/domain/send-file.ts @@ -1,8 +1,8 @@ import { Jsonify } from "type-fest"; -import Domain from "../../../../models/domain/domain-base"; -import { EncString } from "../../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../../models/domain/symmetric-crypto-key"; +import Domain from "../../../../platform/models/domain/domain-base"; +import { EncString } from "../../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { SendFileData } from "../data/send-file.data"; import { SendFileView } from "../view/send-file.view"; diff --git a/libs/common/src/tools/send/models/domain/send-text.ts b/libs/common/src/tools/send/models/domain/send-text.ts index a404dd7502b..e98e4bf2902 100644 --- a/libs/common/src/tools/send/models/domain/send-text.ts +++ b/libs/common/src/tools/send/models/domain/send-text.ts @@ -1,8 +1,8 @@ import { Jsonify } from "type-fest"; -import Domain from "../../../../models/domain/domain-base"; -import { EncString } from "../../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../../models/domain/symmetric-crypto-key"; +import Domain from "../../../../platform/models/domain/domain-base"; +import { EncString } from "../../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { SendTextData } from "../data/send-text.data"; import { SendTextView } from "../view/send-text.view"; diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index 8bd832a501f..25a89b26893 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -2,10 +2,10 @@ import { Substitute, Arg, SubstituteOf } from "@fluffy-spoon/substitute"; import { makeStaticByteArray, mockEnc } from "../../../../../spec"; -import { CryptoService } from "../../../../abstractions/crypto.service"; -import { EncryptService } from "../../../../abstractions/encrypt.service"; -import { EncString } from "../../../../models/domain/enc-string"; -import { ContainerService } from "../../../../services/container.service"; +import { CryptoService } from "../../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; +import { EncString } from "../../../../platform/models/domain/enc-string"; +import { ContainerService } from "../../../../platform/services/container.service"; import { SendType } from "../../enums/send-type"; import { SendData } from "../data/send.data"; diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index a12952181aa..357af1c58f5 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -1,8 +1,8 @@ import { Jsonify } from "type-fest"; -import { Utils } from "../../../../misc/utils"; -import Domain from "../../../../models/domain/domain-base"; -import { EncString } from "../../../../models/domain/enc-string"; +import { Utils } from "../../../../platform/misc/utils"; +import Domain from "../../../../platform/models/domain/domain-base"; +import { EncString } from "../../../../platform/models/domain/enc-string"; import { SendType } from "../../enums/send-type"; import { SendData } from "../data/send.data"; import { SendView } from "../view/send.view"; diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index df802e562a8..4e340916d9b 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -1,6 +1,6 @@ -import { Utils } from "../../../../misc/utils"; -import { SymmetricCryptoKey } from "../../../../models/domain/symmetric-crypto-key"; import { View } from "../../../../models/view/view"; +import { Utils } from "../../../../platform/misc/utils"; +import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; import { DeepJsonify } from "../../../../types/deep-jsonify"; import { SendType } from "../../enums/send-type"; import { Send } from "../domain/send"; @@ -13,7 +13,7 @@ export class SendView implements View { accessId: string = null; name: string = null; notes: string = null; - key: ArrayBuffer; + key: Uint8Array; cryptoKey: SymmetricCryptoKey; type: SendType = null; text = new SendTextView(); @@ -82,7 +82,7 @@ export class SendView implements View { } return Object.assign(new SendView(), json, { - key: Utils.fromB64ToArray(json.key)?.buffer, + key: Utils.fromB64ToArray(json.key), cryptoKey: SymmetricCryptoKey.fromJSON(json.cryptoKey), text: SendTextView.fromJSON(json.text), file: SendFileView.fromJSON(json.file), diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts index 0fac99d6a0f..8d484c66595 100644 --- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts @@ -1,5 +1,5 @@ -import { EncArrayBuffer } from "../../../models/domain/enc-array-buffer"; import { ListResponse } from "../../../models/response/list.response"; +import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { Send } from "../models/domain/send"; import { SendAccessRequest } from "../models/request/send-access.request"; import { SendRequest } from "../models/request/send.request"; diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index 924bea7472b..5572d992f5b 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -1,12 +1,12 @@ import { ApiService } from "../../../abstractions/api.service"; +import { ErrorResponse } from "../../../models/response/error.response"; +import { ListResponse } from "../../../models/response/list.response"; import { FileUploadApiMethods, FileUploadService, -} from "../../../abstractions/file-upload/file-upload.service"; -import { Utils } from "../../../misc/utils"; -import { EncArrayBuffer } from "../../../models/domain/enc-array-buffer"; -import { ErrorResponse } from "../../../models/response/error.response"; -import { ListResponse } from "../../../models/response/list.response"; +} from "../../../platform/abstractions/file-upload/file-upload.service"; +import { Utils } from "../../../platform/misc/utils"; +import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; diff --git a/libs/common/src/tools/send/services/send.service.abstraction.ts b/libs/common/src/tools/send/services/send.service.abstraction.ts index 9624ce02715..d99a8973588 100644 --- a/libs/common/src/tools/send/services/send.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send.service.abstraction.ts @@ -1,7 +1,7 @@ import { Observable } from "rxjs"; -import { EncArrayBuffer } from "../../../models/domain/enc-array-buffer"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendView } from "../models/view/send.view"; diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index bd185e030ba..69971dc5487 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -1,13 +1,13 @@ import { any, mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom } from "rxjs"; -import { CryptoService } from "../../../abstractions/crypto.service"; -import { CryptoFunctionService } from "../../../abstractions/cryptoFunction.service"; -import { EncryptService } from "../../../abstractions/encrypt.service"; -import { I18nService } from "../../../abstractions/i18n.service"; -import { StateService } from "../../../abstractions/state.service"; -import { EncString } from "../../../models/domain/enc-string"; -import { ContainerService } from "../../../services/container.service"; +import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { I18nService } from "../../../platform/abstractions/i18n.service"; +import { StateService } from "../../../platform/abstractions/state.service"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { ContainerService } from "../../../platform/services/container.service"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; import { SendView } from "../models/view/send.view"; diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 1f1e3da9182..fc1985067fb 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -1,14 +1,14 @@ import { BehaviorSubject, concatMap } from "rxjs"; -import { CryptoService } from "../../../abstractions/crypto.service"; -import { CryptoFunctionService } from "../../../abstractions/cryptoFunction.service"; -import { I18nService } from "../../../abstractions/i18n.service"; -import { StateService } from "../../../abstractions/state.service"; import { SEND_KDF_ITERATIONS } from "../../../enums"; -import { Utils } from "../../../misc/utils"; -import { EncArrayBuffer } from "../../../models/domain/enc-array-buffer"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { I18nService } from "../../../platform/abstractions/i18n.service"; +import { StateService } from "../../../platform/abstractions/state.service"; +import { Utils } from "../../../platform/misc/utils"; +import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { SendType } from "../enums/send-type"; import { SendData } from "../models/data/send.data"; import { Send } from "../models/domain/send"; @@ -70,7 +70,7 @@ export class SendService implements InternalSendServiceAbstraction { send.hideEmail = model.hideEmail; send.maxAccessCount = model.maxAccessCount; if (model.key == null) { - model.key = await this.cryptoFunctionService.randomBytes(16); + model.key = await this.cryptoFunctionService.aesGenerateKey(128); model.cryptoKey = await this.cryptoService.makeSendKey(model.key); } if (password != null) { @@ -143,9 +143,9 @@ export class SendService implements InternalSendServiceAbstraction { } decSends = []; - const hasKey = await this.cryptoService.hasKey(); + const hasKey = await this.cryptoService.hasUserKey(); if (!hasKey) { - throw new Error("No key."); + throw new Error("No user key found."); } const promises: Promise[] = []; @@ -241,7 +241,7 @@ export class SendService implements InternalSendServiceAbstraction { key: SymmetricCryptoKey ): Promise<[EncString, EncArrayBuffer]> { const encFileName = await this.cryptoService.encrypt(fileName, key); - const encFileData = await this.cryptoService.encryptToBytes(data, key); + const encFileData = await this.cryptoService.encryptToBytes(new Uint8Array(data), key); return [encFileName, encFileData]; } @@ -249,7 +249,7 @@ export class SendService implements InternalSendServiceAbstraction { const sends = Object.values(sendsMap || {}).map((f) => new Send(f)); this._sends.next(sends); - if (await this.cryptoService.hasKey()) { + if (await this.cryptoService.hasUserKey()) { this._sendViews.next(await this.decryptSends(sends)); } } diff --git a/libs/common/src/types/csprng.d.ts b/libs/common/src/types/csprng.d.ts index ec0a31a9f78..6d1c8a7cdb9 100644 --- a/libs/common/src/types/csprng.d.ts +++ b/libs/common/src/types/csprng.d.ts @@ -4,6 +4,6 @@ import { Opaque } from "type-fest"; // represents an array or string value generated from a // cryptographic secure pseudorandom number generator (CSPRNG) -type CsprngArray = Opaque; +type CsprngArray = Opaque; type CsprngString = Opaque; diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index ffb2c43adce..404a58abb1a 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -1,5 +1,5 @@ import { UriMatchType } from "../../enums"; -import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { CipherType } from "../enums/cipher-type"; import { CipherData } from "../models/data/cipher.data"; import { Cipher } from "../models/domain/cipher"; @@ -11,7 +11,8 @@ export abstract class CipherService { clearCache: (userId?: string) => Promise; encrypt: ( model: CipherView, - key?: SymmetricCryptoKey, + keyForEncryption?: SymmetricCryptoKey, + keyForCipherKeyDecryption?: SymmetricCryptoKey, originalCipher?: Cipher ) => Promise; encryptFields: (fieldsModel: FieldView[], key: SymmetricCryptoKey) => Promise; @@ -33,8 +34,8 @@ export abstract class CipherService { updateLastUsedDate: (id: string) => Promise; updateLastLaunchedDate: (id: string) => Promise; saveNeverDomain: (domain: string) => Promise; - createWithServer: (cipher: Cipher) => Promise; - updateWithServer: (cipher: Cipher) => Promise; + createWithServer: (cipher: Cipher, orgAdmin?: boolean) => Promise; + updateWithServer: (cipher: Cipher, orgAdmin?: boolean, isNotClone?: boolean) => Promise; shareWithServer: ( cipher: CipherView, organizationId: string, @@ -76,5 +77,10 @@ export abstract class CipherService { cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[] ) => Promise; restoreWithServer: (id: string, asAdmin?: boolean) => Promise; - restoreManyWithServer: (ids: string[]) => Promise; + restoreManyWithServer: ( + ids: string[], + organizationId?: string, + asAdmin?: boolean + ) => Promise; + getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise; } diff --git a/libs/common/src/admin-console/abstractions/collection.service.ts b/libs/common/src/vault/abstractions/collection.service.ts similarity index 100% rename from libs/common/src/admin-console/abstractions/collection.service.ts rename to libs/common/src/vault/abstractions/collection.service.ts diff --git a/libs/common/src/vault/abstractions/file-upload/cipher-file-upload.service.ts b/libs/common/src/vault/abstractions/file-upload/cipher-file-upload.service.ts index a8bd35bb254..46026298475 100644 --- a/libs/common/src/vault/abstractions/file-upload/cipher-file-upload.service.ts +++ b/libs/common/src/vault/abstractions/file-upload/cipher-file-upload.service.ts @@ -1,6 +1,6 @@ -import { EncArrayBuffer } from "../../../models/domain/enc-array-buffer"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { Cipher } from "../../models/domain/cipher"; import { CipherResponse } from "../../models/response/cipher.response"; diff --git a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts index 96b9820dc71..6f809b0a074 100644 --- a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { FolderData } from "../../models/data/folder.data"; import { Folder } from "../../models/domain/folder"; import { FolderView } from "../../models/view/folder.view"; diff --git a/libs/common/src/vault/abstractions/password-reprompt.service.ts b/libs/common/src/vault/abstractions/password-reprompt.service.ts deleted file mode 100644 index 6253425b34d..00000000000 --- a/libs/common/src/vault/abstractions/password-reprompt.service.ts +++ /dev/null @@ -1,5 +0,0 @@ -export abstract class PasswordRepromptService { - protectedFields: () => string[]; - showPasswordPrompt: () => Promise; - enabled: () => Promise; -} diff --git a/libs/common/src/vault/models/data/cipher.data.ts b/libs/common/src/vault/models/data/cipher.data.ts index 2f83ee194b4..1452ffe7ee0 100644 --- a/libs/common/src/vault/models/data/cipher.data.ts +++ b/libs/common/src/vault/models/data/cipher.data.ts @@ -33,6 +33,7 @@ export class CipherData { creationDate: string; deletedDate: string; reprompt: CipherRepromptType; + key: string; constructor(response?: CipherResponse, collectionIds?: string[]) { if (response == null) { @@ -54,6 +55,7 @@ export class CipherData { this.creationDate = response.creationDate; this.deletedDate = response.deletedDate; this.reprompt = response.reprompt; + this.key = response.key; switch (this.type) { case CipherType.Login: diff --git a/libs/common/src/admin-console/models/data/collection.data.ts b/libs/common/src/vault/models/data/collection.data.ts similarity index 85% rename from libs/common/src/admin-console/models/data/collection.data.ts rename to libs/common/src/vault/models/data/collection.data.ts index 09513ca4621..ee27846eed9 100644 --- a/libs/common/src/admin-console/models/data/collection.data.ts +++ b/libs/common/src/vault/models/data/collection.data.ts @@ -6,6 +6,7 @@ export class CollectionData { name: string; externalId: string; readOnly: boolean; + hidePasswords: boolean; constructor(response: CollectionDetailsResponse) { this.id = response.id; @@ -13,5 +14,6 @@ export class CollectionData { this.name = response.name; this.externalId = response.externalId; this.readOnly = response.readOnly; + this.hidePasswords = response.hidePasswords; } } diff --git a/libs/common/src/vault/models/domain/attachment.spec.ts b/libs/common/src/vault/models/domain/attachment.spec.ts index c1397ad3ca4..0af7df73e10 100644 --- a/libs/common/src/vault/models/domain/attachment.spec.ts +++ b/libs/common/src/vault/models/domain/attachment.spec.ts @@ -1,11 +1,15 @@ import { mock, MockProxy } from "jest-mock-extended"; import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec"; -import { CryptoService } from "../../../abstractions/crypto.service"; -import { EncryptService } from "../../../abstractions/encrypt.service"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; -import { ContainerService } from "../../../services/container.service"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; +import { + OrgKey, + SymmetricCryptoKey, + UserKey, +} from "../../../platform/models/domain/symmetric-crypto-key"; +import { ContainerService } from "../../../platform/services/container.service"; import { AttachmentData } from "../../models/data/attachment.data"; import { Attachment } from "../../models/domain/attachment"; @@ -105,12 +109,12 @@ describe("Attachment", () => { await attachment.decrypt(null, providedKey); - expect(cryptoService.getKeyForUserEncryption).not.toHaveBeenCalled(); + expect(cryptoService.getUserKeyWithLegacySupport).not.toHaveBeenCalled(); expect(encryptService.decryptToBytes).toHaveBeenCalledWith(attachment.key, providedKey); }); it("gets an organization key if required", async () => { - const orgKey = mock(); + const orgKey = mock(); cryptoService.getOrgKey.calledWith("orgId").mockResolvedValue(orgKey); await attachment.decrypt("orgId", null); @@ -120,12 +124,12 @@ describe("Attachment", () => { }); it("gets the user's decryption key if required", async () => { - const userKey = mock(); - cryptoService.getKeyForUserEncryption.mockResolvedValue(userKey); + const userKey = mock(); + cryptoService.getUserKeyWithLegacySupport.mockResolvedValue(userKey); await attachment.decrypt(null, null); - expect(cryptoService.getKeyForUserEncryption).toHaveBeenCalled(); + expect(cryptoService.getUserKeyWithLegacySupport).toHaveBeenCalled(); expect(encryptService.decryptToBytes).toHaveBeenCalledWith(attachment.key, userKey); }); }); @@ -136,8 +140,8 @@ describe("Attachment", () => { jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson); const actual = Attachment.fromJSON({ - key: "myKey", - fileName: "myFileName", + key: "myKey" as EncryptedString, + fileName: "myFileName" as EncryptedString, }); expect(actual).toEqual({ diff --git a/libs/common/src/vault/models/domain/attachment.ts b/libs/common/src/vault/models/domain/attachment.ts index 99d3d631fed..1caa290fd6a 100644 --- a/libs/common/src/vault/models/domain/attachment.ts +++ b/libs/common/src/vault/models/domain/attachment.ts @@ -1,9 +1,9 @@ import { Jsonify } from "type-fest"; -import { Utils } from "../../../misc/utils"; -import Domain from "../../../models/domain/domain-base"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import { Utils } from "../../../platform/misc/utils"; +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { AttachmentData } from "../data/attachment.data"; import { AttachmentView } from "../view/attachment.view"; @@ -71,7 +71,7 @@ export class Attachment extends Domain { const cryptoService = Utils.getContainerService().getCryptoService(); return orgId != null ? await cryptoService.getOrgKey(orgId) - : await cryptoService.getKeyForUserEncryption(); + : await cryptoService.getUserKeyWithLegacySupport(); } toAttachmentData(): AttachmentData { diff --git a/libs/common/src/vault/models/domain/card.spec.ts b/libs/common/src/vault/models/domain/card.spec.ts index a80d57a587f..a7011966d94 100644 --- a/libs/common/src/vault/models/domain/card.spec.ts +++ b/libs/common/src/vault/models/domain/card.spec.ts @@ -1,5 +1,5 @@ import { mockEnc, mockFromJson } from "../../../../spec"; -import { EncString } from "../../../models/domain/enc-string"; +import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; import { CardData } from "../../../vault/models/data/card.data"; import { Card } from "../../models/domain/card"; @@ -76,12 +76,12 @@ describe("Card", () => { jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson); const actual = Card.fromJSON({ - cardholderName: "mockCardHolder", - brand: "mockBrand", - number: "mockNumber", - expMonth: "mockExpMonth", - expYear: "mockExpYear", - code: "mockCode", + cardholderName: "mockCardHolder" as EncryptedString, + brand: "mockBrand" as EncryptedString, + number: "mockNumber" as EncryptedString, + expMonth: "mockExpMonth" as EncryptedString, + expYear: "mockExpYear" as EncryptedString, + code: "mockCode" as EncryptedString, }); expect(actual).toEqual({ diff --git a/libs/common/src/vault/models/domain/card.ts b/libs/common/src/vault/models/domain/card.ts index ba8a53dbaf2..055b0c497cf 100644 --- a/libs/common/src/vault/models/domain/card.ts +++ b/libs/common/src/vault/models/domain/card.ts @@ -1,8 +1,8 @@ import { Jsonify } from "type-fest"; -import Domain from "../../../models/domain/domain-base"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { CardData } from "../data/card.data"; import { CardView } from "../view/card.view"; diff --git a/libs/common/src/vault/models/domain/cipher.spec.ts b/libs/common/src/vault/models/domain/cipher.spec.ts index b23b15e3ef7..6234fe7029b 100644 --- a/libs/common/src/vault/models/domain/cipher.spec.ts +++ b/libs/common/src/vault/models/domain/cipher.spec.ts @@ -2,10 +2,14 @@ import { Substitute, Arg } from "@fluffy-spoon/substitute"; import { Jsonify } from "type-fest"; -import { mockEnc, mockFromJson } from "../../../../spec"; +import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils"; import { FieldType, SecureNoteType, UriMatchType } from "../../../enums"; -import { EncString } from "../../../models/domain/enc-string"; -import { InitializerKey } from "../../../services/cryptography/initializer-key"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { ContainerService } from "../../../platform/services/container.service"; +import { InitializerKey } from "../../../platform/services/cryptography/initializer-key"; +import { CipherService } from "../../abstractions/cipher.service"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { CipherData } from "../../models/data/cipher.data"; @@ -47,6 +51,7 @@ describe("Cipher DTO", () => { attachments: null, fields: null, passwordHistory: null, + key: null, }); }); @@ -69,6 +74,7 @@ describe("Cipher DTO", () => { creationDate: "2022-01-01T12:00:00.000Z", deletedDate: null, reprompt: CipherRepromptType.None, + key: "EncryptedString", login: { uris: [{ uri: "EncryptedString", match: UriMatchType.Domain }], username: "EncryptedString", @@ -136,6 +142,7 @@ describe("Cipher DTO", () => { creationDate: new Date("2022-01-01T12:00:00.000Z"), deletedDate: null, reprompt: 0, + key: { encryptedString: "EncryptedString", encryptionType: 0 }, login: { passwordRevisionDate: new Date("2022-01-31T12:00:00.000Z"), autofillOnPageLoad: false, @@ -206,6 +213,7 @@ describe("Cipher DTO", () => { cipher.creationDate = new Date("2022-01-01T12:00:00.000Z"); cipher.deletedDate = null; cipher.reprompt = CipherRepromptType.None; + cipher.key = mockEnc("EncKey"); const loginView = new LoginView(); loginView.username = "username"; @@ -215,7 +223,20 @@ describe("Cipher DTO", () => { login.decrypt(Arg.any(), Arg.any()).resolves(loginView); cipher.login = login; - const cipherView = await cipher.decrypt(); + const cryptoService = Substitute.for(); + const encryptService = Substitute.for(); + const cipherService = Substitute.for(); + + encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64)); + + (window as any).bitwardenContainerService = new ContainerService( + cryptoService, + encryptService + ); + + const cipherView = await cipher.decrypt( + await cipherService.getKeyForCipherKeyDecryption(cipher) + ); expect(cipherView).toMatchObject({ id: "id", @@ -261,6 +282,7 @@ describe("Cipher DTO", () => { creationDate: "2022-01-01T12:00:00.000Z", deletedDate: null, reprompt: CipherRepromptType.None, + key: "EncKey", secureNote: { type: SecureNoteType.Generic, }, @@ -292,6 +314,7 @@ describe("Cipher DTO", () => { attachments: null, fields: null, passwordHistory: null, + key: { encryptedString: "EncKey", encryptionType: 0 }, }); }); @@ -318,8 +341,22 @@ describe("Cipher DTO", () => { cipher.reprompt = CipherRepromptType.None; cipher.secureNote = new SecureNote(); cipher.secureNote.type = SecureNoteType.Generic; + cipher.key = mockEnc("EncKey"); + + const cryptoService = Substitute.for(); + const encryptService = Substitute.for(); + const cipherService = Substitute.for(); + + encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64)); + + (window as any).bitwardenContainerService = new ContainerService( + cryptoService, + encryptService + ); - const cipherView = await cipher.decrypt(); + const cipherView = await cipher.decrypt( + await cipherService.getKeyForCipherKeyDecryption(cipher) + ); expect(cipherView).toMatchObject({ id: "id", @@ -373,6 +410,7 @@ describe("Cipher DTO", () => { expYear: "EncryptedString", code: "EncryptedString", }, + key: "EncKey", }; }); @@ -408,6 +446,7 @@ describe("Cipher DTO", () => { attachments: null, fields: null, passwordHistory: null, + key: { encryptedString: "EncKey", encryptionType: 0 }, }); }); @@ -432,6 +471,7 @@ describe("Cipher DTO", () => { cipher.creationDate = new Date("2022-01-01T12:00:00.000Z"); cipher.deletedDate = null; cipher.reprompt = CipherRepromptType.None; + cipher.key = mockEnc("EncKey"); const cardView = new CardView(); cardView.cardholderName = "cardholderName"; @@ -441,7 +481,20 @@ describe("Cipher DTO", () => { card.decrypt(Arg.any(), Arg.any()).resolves(cardView); cipher.card = card; - const cipherView = await cipher.decrypt(); + const cryptoService = Substitute.for(); + const encryptService = Substitute.for(); + const cipherService = Substitute.for(); + + encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64)); + + (window as any).bitwardenContainerService = new ContainerService( + cryptoService, + encryptService + ); + + const cipherView = await cipher.decrypt( + await cipherService.getKeyForCipherKeyDecryption(cipher) + ); expect(cipherView).toMatchObject({ id: "id", @@ -487,6 +540,7 @@ describe("Cipher DTO", () => { creationDate: "2022-01-01T12:00:00.000Z", deletedDate: null, reprompt: CipherRepromptType.None, + key: "EncKey", identity: { title: "EncryptedString", firstName: "EncryptedString", @@ -554,6 +608,7 @@ describe("Cipher DTO", () => { attachments: null, fields: null, passwordHistory: null, + key: { encryptedString: "EncKey", encryptionType: 0 }, }); }); @@ -578,6 +633,7 @@ describe("Cipher DTO", () => { cipher.creationDate = new Date("2022-01-01T12:00:00.000Z"); cipher.deletedDate = null; cipher.reprompt = CipherRepromptType.None; + cipher.key = mockEnc("EncKey"); const identityView = new IdentityView(); identityView.firstName = "firstName"; @@ -587,7 +643,20 @@ describe("Cipher DTO", () => { identity.decrypt(Arg.any(), Arg.any()).resolves(identityView); cipher.identity = identity; - const cipherView = await cipher.decrypt(); + const cryptoService = Substitute.for(); + const encryptService = Substitute.for(); + const cipherService = Substitute.for(); + + encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64)); + + (window as any).bitwardenContainerService = new ContainerService( + cryptoService, + encryptService + ); + + const cipherView = await cipher.decrypt( + await cipherService.getKeyForCipherKeyDecryption(cipher) + ); expect(cipherView).toMatchObject({ id: "id", diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 55ef16914c4..23349695a72 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -1,10 +1,11 @@ import { Jsonify } from "type-fest"; -import { Decryptable } from "../../../interfaces/decryptable.interface"; -import Domain from "../../../models/domain/domain-base"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; -import { InitializerKey } from "../../../services/cryptography/initializer-key"; +import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; +import { Utils } from "../../../platform/misc/utils"; +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { InitializerKey } from "../../../platform/services/cryptography/initializer-key"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { CipherData } from "../data/cipher.data"; @@ -45,6 +46,7 @@ export class Cipher extends Domain implements Decryptable { creationDate: Date; deletedDate: Date; reprompt: CipherRepromptType; + key: EncString; constructor(obj?: CipherData, localData: LocalData = null) { super(); @@ -61,6 +63,7 @@ export class Cipher extends Domain implements Decryptable { folderId: null, name: null, notes: null, + key: null, }, ["id", "organizationId", "folderId"] ); @@ -117,9 +120,17 @@ export class Cipher extends Domain implements Decryptable { } } - async decrypt(encKey?: SymmetricCryptoKey): Promise { + // We are passing the organizationId into the EncString.decrypt() method here, but because the encKey will always be + // present and so the organizationId will not be used. + // We will refactor the EncString.decrypt() in https://bitwarden.atlassian.net/browse/PM-3762 to remove the dependency on the organizationId. + async decrypt(encKey: SymmetricCryptoKey): Promise { const model = new CipherView(this); + if (this.key != null) { + const encryptService = Utils.getContainerService().getEncryptService(); + encKey = new SymmetricCryptoKey(await encryptService.decryptToBytes(this.key, encKey)); + } + await this.decryptObj( model, { @@ -147,14 +158,12 @@ export class Cipher extends Domain implements Decryptable { break; } - const orgId = this.organizationId; - if (this.attachments != null && this.attachments.length > 0) { const attachments: any[] = []; await this.attachments.reduce((promise, attachment) => { return promise .then(() => { - return attachment.decrypt(orgId, encKey); + return attachment.decrypt(this.organizationId, encKey); }) .then((decAttachment) => { attachments.push(decAttachment); @@ -168,7 +177,7 @@ export class Cipher extends Domain implements Decryptable { await this.fields.reduce((promise, field) => { return promise .then(() => { - return field.decrypt(orgId, encKey); + return field.decrypt(this.organizationId, encKey); }) .then((decField) => { fields.push(decField); @@ -182,7 +191,7 @@ export class Cipher extends Domain implements Decryptable { await this.passwordHistory.reduce((promise, ph) => { return promise .then(() => { - return ph.decrypt(orgId, encKey); + return ph.decrypt(this.organizationId, encKey); }) .then((decPh) => { passwordHistory.push(decPh); @@ -209,6 +218,7 @@ export class Cipher extends Domain implements Decryptable { c.creationDate = this.creationDate != null ? this.creationDate.toISOString() : null; c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : null; c.reprompt = this.reprompt; + c.key = this.key?.encryptedString; this.buildDataModel(this, c, { name: null, @@ -257,6 +267,7 @@ export class Cipher extends Domain implements Decryptable { const attachments = obj.attachments?.map((a: any) => Attachment.fromJSON(a)); const fields = obj.fields?.map((f: any) => Field.fromJSON(f)); const passwordHistory = obj.passwordHistory?.map((ph: any) => Password.fromJSON(ph)); + const key = EncString.fromJSON(obj.key); Object.assign(domain, obj, { name, @@ -266,6 +277,7 @@ export class Cipher extends Domain implements Decryptable { attachments, fields, passwordHistory, + key, }); switch (obj.type) { diff --git a/libs/common/src/admin-console/models/domain/collection.spec.ts b/libs/common/src/vault/models/domain/collection.spec.ts similarity index 96% rename from libs/common/src/admin-console/models/domain/collection.spec.ts rename to libs/common/src/vault/models/domain/collection.spec.ts index 977a5e53cfa..c685f0272eb 100644 --- a/libs/common/src/admin-console/models/domain/collection.spec.ts +++ b/libs/common/src/vault/models/domain/collection.spec.ts @@ -13,6 +13,7 @@ describe("Collection", () => { name: "encName", externalId: "extId", readOnly: true, + hidePasswords: true, }; }); @@ -39,7 +40,7 @@ describe("Collection", () => { name: { encryptedString: "encName", encryptionType: 0 }, externalId: "extId", readOnly: true, - hidePasswords: null, + hidePasswords: true, }); }); diff --git a/libs/common/src/admin-console/models/domain/collection.ts b/libs/common/src/vault/models/domain/collection.ts similarity index 86% rename from libs/common/src/admin-console/models/domain/collection.ts rename to libs/common/src/vault/models/domain/collection.ts index 22c26bc11c6..8bcec318dfb 100644 --- a/libs/common/src/admin-console/models/domain/collection.ts +++ b/libs/common/src/vault/models/domain/collection.ts @@ -1,5 +1,5 @@ -import Domain from "../../../models/domain/domain-base"; -import { EncString } from "../../../models/domain/enc-string"; +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; import { CollectionData } from "../data/collection.data"; import { CollectionView } from "../view/collection.view"; diff --git a/libs/common/src/vault/models/domain/field.spec.ts b/libs/common/src/vault/models/domain/field.spec.ts index 0754c0b3b68..8aa77b78a51 100644 --- a/libs/common/src/vault/models/domain/field.spec.ts +++ b/libs/common/src/vault/models/domain/field.spec.ts @@ -1,6 +1,6 @@ import { mockEnc, mockFromJson } from "../../../../spec"; import { FieldType } from "../../../enums"; -import { EncString } from "../../../models/domain/enc-string"; +import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; import { FieldData } from "../../models/data/field.data"; import { Field } from "../../models/domain/field"; @@ -67,8 +67,8 @@ describe("Field", () => { jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson); const actual = Field.fromJSON({ - name: "myName", - value: "myValue", + name: "myName" as EncryptedString, + value: "myValue" as EncryptedString, }); expect(actual).toEqual({ diff --git a/libs/common/src/vault/models/domain/field.ts b/libs/common/src/vault/models/domain/field.ts index b66b78a6460..388f9f806c4 100644 --- a/libs/common/src/vault/models/domain/field.ts +++ b/libs/common/src/vault/models/domain/field.ts @@ -1,9 +1,9 @@ import { Jsonify } from "type-fest"; import { FieldType, LinkedIdType } from "../../../enums"; -import Domain from "../../../models/domain/domain-base"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { FieldData } from "../data/field.data"; import { FieldView } from "../view/field.view"; diff --git a/libs/common/src/vault/models/domain/folder.spec.ts b/libs/common/src/vault/models/domain/folder.spec.ts index dc8b490e72a..69134d19cfb 100644 --- a/libs/common/src/vault/models/domain/folder.spec.ts +++ b/libs/common/src/vault/models/domain/folder.spec.ts @@ -1,5 +1,5 @@ import { mockEnc, mockFromJson } from "../../../../spec"; -import { EncString } from "../../../models/domain/enc-string"; +import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; import { FolderData } from "../../models/data/folder.data"; import { Folder } from "../../models/domain/folder"; @@ -40,14 +40,14 @@ describe("Folder", () => { }); describe("fromJSON", () => { - jest.mock("../../../models/domain/enc-string"); + jest.mock("../../../platform/models/domain/enc-string"); jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson); it("initializes nested objects", () => { const revisionDate = new Date("2022-08-04T01:06:40.441Z"); const actual = Folder.fromJSON({ revisionDate: revisionDate.toISOString(), - name: "name", + name: "name" as EncryptedString, id: "id", }); diff --git a/libs/common/src/vault/models/domain/folder.ts b/libs/common/src/vault/models/domain/folder.ts index 160d119e863..db999ee7d60 100644 --- a/libs/common/src/vault/models/domain/folder.ts +++ b/libs/common/src/vault/models/domain/folder.ts @@ -1,7 +1,7 @@ import { Jsonify } from "type-fest"; -import Domain from "../../../models/domain/domain-base"; -import { EncString } from "../../../models/domain/enc-string"; +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; import { FolderData } from "../data/folder.data"; import { FolderView } from "../view/folder.view"; diff --git a/libs/common/src/vault/models/domain/identity.spec.ts b/libs/common/src/vault/models/domain/identity.spec.ts index a3fdbd580bb..3a95138998b 100644 --- a/libs/common/src/vault/models/domain/identity.spec.ts +++ b/libs/common/src/vault/models/domain/identity.spec.ts @@ -1,5 +1,5 @@ import { mockEnc, mockFromJson } from "../../../../spec"; -import { EncString } from "../../../models/domain/enc-string"; +import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; import { IdentityData } from "../../models/data/identity.data"; import { Identity } from "../../models/domain/identity"; @@ -137,24 +137,24 @@ describe("Identity", () => { jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson); const actual = Identity.fromJSON({ - firstName: "mockFirstName", - lastName: "mockLastName", - address1: "mockAddress1", - address2: "mockAddress2", - address3: "mockAddress3", - city: "mockCity", - company: "mockCompany", - country: "mockCountry", - email: "mockEmail", - licenseNumber: "mockLicenseNumber", - middleName: "mockMiddleName", - passportNumber: "mockPassportNumber", - phone: "mockPhone", - postalCode: "mockPostalCode", - ssn: "mockSsn", - state: "mockState", - title: "mockTitle", - username: "mockUsername", + firstName: "mockFirstName" as EncryptedString, + lastName: "mockLastName" as EncryptedString, + address1: "mockAddress1" as EncryptedString, + address2: "mockAddress2" as EncryptedString, + address3: "mockAddress3" as EncryptedString, + city: "mockCity" as EncryptedString, + company: "mockCompany" as EncryptedString, + country: "mockCountry" as EncryptedString, + email: "mockEmail" as EncryptedString, + licenseNumber: "mockLicenseNumber" as EncryptedString, + middleName: "mockMiddleName" as EncryptedString, + passportNumber: "mockPassportNumber" as EncryptedString, + phone: "mockPhone" as EncryptedString, + postalCode: "mockPostalCode" as EncryptedString, + ssn: "mockSsn" as EncryptedString, + state: "mockState" as EncryptedString, + title: "mockTitle" as EncryptedString, + username: "mockUsername" as EncryptedString, }); expect(actual).toEqual({ diff --git a/libs/common/src/vault/models/domain/identity.ts b/libs/common/src/vault/models/domain/identity.ts index 6a5fab7e341..cebaa405f6c 100644 --- a/libs/common/src/vault/models/domain/identity.ts +++ b/libs/common/src/vault/models/domain/identity.ts @@ -1,8 +1,8 @@ import { Jsonify } from "type-fest"; -import Domain from "../../../models/domain/domain-base"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { IdentityData } from "../data/identity.data"; import { IdentityView } from "../view/identity.view"; diff --git a/libs/common/src/vault/models/domain/login-uri.spec.ts b/libs/common/src/vault/models/domain/login-uri.spec.ts index 28378e73c4a..7649739267d 100644 --- a/libs/common/src/vault/models/domain/login-uri.spec.ts +++ b/libs/common/src/vault/models/domain/login-uri.spec.ts @@ -2,7 +2,7 @@ import { Jsonify } from "type-fest"; import { mockEnc, mockFromJson } from "../../../../spec"; import { UriMatchType } from "../../../enums"; -import { EncString } from "../../../models/domain/enc-string"; +import { EncString } from "../../../platform/models/domain/enc-string"; import { LoginUriData } from "../data/login-uri.data"; import { LoginUri } from "./login-uri"; diff --git a/libs/common/src/vault/models/domain/login-uri.ts b/libs/common/src/vault/models/domain/login-uri.ts index 4d8d6c98562..5b599467588 100644 --- a/libs/common/src/vault/models/domain/login-uri.ts +++ b/libs/common/src/vault/models/domain/login-uri.ts @@ -1,9 +1,9 @@ import { Jsonify } from "type-fest"; import { UriMatchType } from "../../../enums"; -import Domain from "../../../models/domain/domain-base"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { LoginUriData } from "../data/login-uri.data"; import { LoginUriView } from "../view/login-uri.view"; diff --git a/libs/common/src/vault/models/domain/login.spec.ts b/libs/common/src/vault/models/domain/login.spec.ts index 4cc774bdc3e..4d35d0971d7 100644 --- a/libs/common/src/vault/models/domain/login.spec.ts +++ b/libs/common/src/vault/models/domain/login.spec.ts @@ -3,7 +3,7 @@ import { Substitute, Arg } from "@fluffy-spoon/substitute"; import { mockEnc, mockFromJson } from "../../../../spec"; import { UriMatchType } from "../../../enums"; -import { EncString } from "../../../models/domain/enc-string"; +import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; import { LoginData } from "../../models/data/login.data"; import { Login } from "../../models/domain/login"; import { LoginUri } from "../../models/domain/login-uri"; @@ -108,10 +108,10 @@ describe("Login DTO", () => { const actual = Login.fromJSON({ uris: ["loginUri1", "loginUri2"] as any, - username: "myUsername", - password: "myPassword", + username: "myUsername" as EncryptedString, + password: "myPassword" as EncryptedString, passwordRevisionDate: passwordRevisionDate.toISOString(), - totp: "myTotp", + totp: "myTotp" as EncryptedString, }); expect(actual).toEqual({ diff --git a/libs/common/src/vault/models/domain/login.ts b/libs/common/src/vault/models/domain/login.ts index 763fba212f0..bc046e784db 100644 --- a/libs/common/src/vault/models/domain/login.ts +++ b/libs/common/src/vault/models/domain/login.ts @@ -1,8 +1,8 @@ import { Jsonify } from "type-fest"; -import Domain from "../../../models/domain/domain-base"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { LoginData } from "../data/login.data"; import { LoginView } from "../view/login.view"; diff --git a/libs/common/src/vault/models/domain/password.spec.ts b/libs/common/src/vault/models/domain/password.spec.ts index 4e42904ad96..614b9639e52 100644 --- a/libs/common/src/vault/models/domain/password.spec.ts +++ b/libs/common/src/vault/models/domain/password.spec.ts @@ -1,5 +1,5 @@ import { mockEnc, mockFromJson } from "../../../../spec"; -import { EncString } from "../../../models/domain/enc-string"; +import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; import { PasswordHistoryData } from "../../models/data/password-history.data"; import { Password } from "../../models/domain/password"; @@ -55,7 +55,7 @@ describe("Password", () => { const lastUsedDate = new Date("2022-01-31T12:00:00.000Z"); const actual = Password.fromJSON({ - password: "myPassword", + password: "myPassword" as EncryptedString, lastUsedDate: lastUsedDate.toISOString(), }); diff --git a/libs/common/src/vault/models/domain/password.ts b/libs/common/src/vault/models/domain/password.ts index 95b76e284f7..c2bb3df783f 100644 --- a/libs/common/src/vault/models/domain/password.ts +++ b/libs/common/src/vault/models/domain/password.ts @@ -1,8 +1,8 @@ import { Jsonify } from "type-fest"; -import Domain from "../../../models/domain/domain-base"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { PasswordHistoryData } from "../data/password-history.data"; import { PasswordHistoryView } from "../view/password-history.view"; diff --git a/libs/common/src/vault/models/domain/secure-note.ts b/libs/common/src/vault/models/domain/secure-note.ts index 5f3ee673376..cc70c42abbb 100644 --- a/libs/common/src/vault/models/domain/secure-note.ts +++ b/libs/common/src/vault/models/domain/secure-note.ts @@ -1,8 +1,8 @@ import { Jsonify } from "type-fest"; import { SecureNoteType } from "../../../enums"; -import Domain from "../../../models/domain/domain-base"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import Domain from "../../../platform/models/domain/domain-base"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { SecureNoteData } from "../data/secure-note.data"; import { SecureNoteView } from "../view/secure-note.view"; diff --git a/libs/common/src/vault/models/request/cipher-bulk-restore.request.ts b/libs/common/src/vault/models/request/cipher-bulk-restore.request.ts index 70e5a4e82af..89dc0cb5c15 100644 --- a/libs/common/src/vault/models/request/cipher-bulk-restore.request.ts +++ b/libs/common/src/vault/models/request/cipher-bulk-restore.request.ts @@ -1,7 +1,9 @@ export class CipherBulkRestoreRequest { ids: string[]; + organizationId: string; - constructor(ids: string[]) { + constructor(ids: string[], organizationId?: string) { this.ids = ids == null ? [] : ids; + this.organizationId = organizationId; } } diff --git a/libs/common/src/vault/models/request/cipher.request.ts b/libs/common/src/vault/models/request/cipher.request.ts index cae48fb7af9..0f34200e79e 100644 --- a/libs/common/src/vault/models/request/cipher.request.ts +++ b/libs/common/src/vault/models/request/cipher.request.ts @@ -29,6 +29,7 @@ export class CipherRequest { attachments2: { [id: string]: AttachmentRequest }; lastKnownRevisionDate: Date; reprompt: CipherRepromptType; + key: string; constructor(cipher: Cipher) { this.type = cipher.type; @@ -39,6 +40,7 @@ export class CipherRequest { this.favorite = cipher.favorite; this.lastKnownRevisionDate = cipher.revisionDate; this.reprompt = cipher.reprompt; + this.key = cipher.key?.encryptedString; switch (this.type) { case CipherType.Login: diff --git a/libs/common/src/admin-console/models/request/collection-with-id.request.ts b/libs/common/src/vault/models/request/collection-with-id.request.ts similarity index 80% rename from libs/common/src/admin-console/models/request/collection-with-id.request.ts rename to libs/common/src/vault/models/request/collection-with-id.request.ts index ae48395a49d..4ff050b3919 100644 --- a/libs/common/src/admin-console/models/request/collection-with-id.request.ts +++ b/libs/common/src/vault/models/request/collection-with-id.request.ts @@ -1,5 +1,6 @@ import { Collection } from "../domain/collection"; -import { CollectionRequest } from "../request/collection.request"; + +import { CollectionRequest } from "./collection.request"; export class CollectionWithIdRequest extends CollectionRequest { id: string; diff --git a/libs/common/src/admin-console/models/request/collection.request.ts b/libs/common/src/vault/models/request/collection.request.ts similarity index 79% rename from libs/common/src/admin-console/models/request/collection.request.ts rename to libs/common/src/vault/models/request/collection.request.ts index d28a7d06fe6..173bb3c18c3 100644 --- a/libs/common/src/admin-console/models/request/collection.request.ts +++ b/libs/common/src/vault/models/request/collection.request.ts @@ -1,7 +1,6 @@ +import { SelectionReadOnlyRequest } from "../../../admin-console/models/request/selection-read-only.request"; import { Collection } from "../domain/collection"; -import { SelectionReadOnlyRequest } from "./selection-read-only.request"; - export class CollectionRequest { name: string; externalId: string; diff --git a/libs/common/src/vault/models/response/cipher.response.ts b/libs/common/src/vault/models/response/cipher.response.ts index 71e43373775..8bc8a37874e 100644 --- a/libs/common/src/vault/models/response/cipher.response.ts +++ b/libs/common/src/vault/models/response/cipher.response.ts @@ -32,6 +32,7 @@ export class CipherResponse extends BaseResponse { creationDate: string; deletedDate: string; reprompt: CipherRepromptType; + key: string; constructor(response: any) { super(response); @@ -90,5 +91,6 @@ export class CipherResponse extends BaseResponse { } this.reprompt = this.getResponseProperty("Reprompt") || CipherRepromptType.None; + this.key = this.getResponseProperty("Key") || null; } } diff --git a/libs/common/src/admin-console/models/response/collection.response.ts b/libs/common/src/vault/models/response/collection.response.ts similarity index 86% rename from libs/common/src/admin-console/models/response/collection.response.ts rename to libs/common/src/vault/models/response/collection.response.ts index 5efc3b90ae1..4cce6b072e6 100644 --- a/libs/common/src/admin-console/models/response/collection.response.ts +++ b/libs/common/src/vault/models/response/collection.response.ts @@ -1,7 +1,6 @@ +import { SelectionReadOnlyResponse } from "../../../admin-console/models/response/selection-read-only.response"; import { BaseResponse } from "../../../models/response/base.response"; -import { SelectionReadOnlyResponse } from "./selection-read-only.response"; - export class CollectionResponse extends BaseResponse { id: string; organizationId: string; @@ -19,10 +18,12 @@ export class CollectionResponse extends BaseResponse { export class CollectionDetailsResponse extends CollectionResponse { readOnly: boolean; + hidePasswords: boolean; constructor(response: any) { super(response); this.readOnly = this.getResponseProperty("ReadOnly") || false; + this.hidePasswords = this.getResponseProperty("HidePasswords") || false; } } diff --git a/libs/common/src/vault/models/response/sync.response.ts b/libs/common/src/vault/models/response/sync.response.ts index d042c4b5a76..42778a8cef9 100644 --- a/libs/common/src/vault/models/response/sync.response.ts +++ b/libs/common/src/vault/models/response/sync.response.ts @@ -1,4 +1,3 @@ -import { CollectionDetailsResponse } from "../../../admin-console/models/response/collection.response"; import { PolicyResponse } from "../../../admin-console/models/response/policy.response"; import { BaseResponse } from "../../../models/response/base.response"; import { DomainsResponse } from "../../../models/response/domains.response"; @@ -6,6 +5,7 @@ import { ProfileResponse } from "../../../models/response/profile.response"; import { SendResponse } from "../../../tools/send/models/response/send.response"; import { CipherResponse } from "./cipher.response"; +import { CollectionDetailsResponse } from "./collection.response"; import { FolderResponse } from "./folder.response"; export class SyncResponse extends BaseResponse { diff --git a/libs/common/src/vault/models/view/attachment.view.spec.ts b/libs/common/src/vault/models/view/attachment.view.spec.ts index dc81fe3f78e..7cb291f2714 100644 --- a/libs/common/src/vault/models/view/attachment.view.spec.ts +++ b/libs/common/src/vault/models/view/attachment.view.spec.ts @@ -1,9 +1,9 @@ import { mockFromJson } from "../../../../spec"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { AttachmentView } from "./attachment.view"; -jest.mock("../../../models/domain/symmetric-crypto-key"); +jest.mock("../../../platform/models/domain/symmetric-crypto-key"); describe("AttachmentView", () => { it("fromJSON initializes nested objects", () => { diff --git a/libs/common/src/vault/models/view/attachment.view.ts b/libs/common/src/vault/models/view/attachment.view.ts index e0387f9f79f..0c6bd980b07 100644 --- a/libs/common/src/vault/models/view/attachment.view.ts +++ b/libs/common/src/vault/models/view/attachment.view.ts @@ -1,7 +1,7 @@ import { Jsonify } from "type-fest"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; import { View } from "../../../models/view/view"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { Attachment } from "../domain/attachment"; export class AttachmentView implements View { diff --git a/libs/common/src/vault/models/view/card.view.ts b/libs/common/src/vault/models/view/card.view.ts index abeb381a7a6..25c70977076 100644 --- a/libs/common/src/vault/models/view/card.view.ts +++ b/libs/common/src/vault/models/view/card.view.ts @@ -81,4 +81,67 @@ export class CardView extends ItemView { static fromJSON(obj: Partial>): CardView { return Object.assign(new CardView(), obj); } + + // ref https://stackoverflow.com/a/5911300 + static getCardBrandByPatterns(cardNum: string): string { + if (cardNum == null || typeof cardNum !== "string" || cardNum.trim() === "") { + return null; + } + + // Visa + let re = new RegExp("^4"); + if (cardNum.match(re) != null) { + return "Visa"; + } + + // Mastercard + // Updated for Mastercard 2017 BINs expansion + if ( + /^(5[1-5][0-9]{14}|2(22[1-9][0-9]{12}|2[3-9][0-9]{13}|[3-6][0-9]{14}|7[0-1][0-9]{13}|720[0-9]{12}))$/.test( + cardNum + ) + ) { + return "Mastercard"; + } + + // AMEX + re = new RegExp("^3[47]"); + if (cardNum.match(re) != null) { + return "Amex"; + } + + // Discover + re = new RegExp( + "^(6011|622(12[6-9]|1[3-9][0-9]|[2-8][0-9]{2}|9[0-1][0-9]|92[0-5]|64[4-9])|65)" + ); + if (cardNum.match(re) != null) { + return "Discover"; + } + + // Diners + re = new RegExp("^36"); + if (cardNum.match(re) != null) { + return "Diners Club"; + } + + // Diners - Carte Blanche + re = new RegExp("^30[0-5]"); + if (cardNum.match(re) != null) { + return "Diners Club"; + } + + // JCB + re = new RegExp("^35(2[89]|[3-8][0-9])"); + if (cardNum.match(re) != null) { + return "JCB"; + } + + // Visa Electron + re = new RegExp("^(4026|417500|4508|4844|491(3|7))"); + if (cardNum.match(re) != null) { + return "Visa"; + } + + return null; + } } diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 8284c94edf1..0667519be7e 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -1,9 +1,9 @@ import { Jsonify } from "type-fest"; import { LinkedIdType } from "../../../enums"; -import { InitializerMetadata } from "../../../interfaces/initializer-metadata.interface"; import { View } from "../../../models/view/view"; -import { InitializerKey } from "../../../services/cryptography/initializer-key"; +import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface"; +import { InitializerKey } from "../../../platform/services/cryptography/initializer-key"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { LocalData } from "../data/local.data"; diff --git a/libs/common/src/admin-console/models/view/collection.view.ts b/libs/common/src/vault/models/view/collection.view.ts similarity index 50% rename from libs/common/src/admin-console/models/view/collection.view.ts rename to libs/common/src/vault/models/view/collection.view.ts index 52644035c0c..98159c99cc6 100644 --- a/libs/common/src/admin-console/models/view/collection.view.ts +++ b/libs/common/src/vault/models/view/collection.view.ts @@ -1,3 +1,4 @@ +import { Organization } from "../../../admin-console/models/domain/organization"; import { ITreeNodeObject } from "../../../models/domain/tree-node"; import { View } from "../../../models/view/view"; import { Collection } from "../domain/collection"; @@ -10,6 +11,7 @@ export class CollectionView implements View, ITreeNodeObject { organizationId: string = null; name: string = null; externalId: string = null; + // readOnly applies to the items within a collection readOnly: boolean = null; hidePasswords: boolean = null; @@ -26,4 +28,24 @@ export class CollectionView implements View, ITreeNodeObject { this.hidePasswords = c.hidePasswords; } } + + // For editing collection details, not the items within it. + canEdit(org: Organization): boolean { + if (org.id !== this.organizationId) { + throw new Error( + "Id of the organization provided does not match the org id of the collection." + ); + } + return org?.canEditAnyCollection || org?.canEditAssignedCollections; + } + + // For deleting a collection, not the items within it. + canDelete(org: Organization): boolean { + if (org.id !== this.organizationId) { + throw new Error( + "Id of the organization provided does not match the org id of the collection." + ); + } + return org?.canDeleteAnyCollection || org?.canDeleteAssignedCollections; + } } diff --git a/libs/common/src/vault/models/view/identity.view.ts b/libs/common/src/vault/models/view/identity.view.ts index 9ee3fe29340..9d14a554f76 100644 --- a/libs/common/src/vault/models/view/identity.view.ts +++ b/libs/common/src/vault/models/view/identity.view.ts @@ -2,7 +2,7 @@ import { Jsonify } from "type-fest"; import { IdentityLinkedId as LinkedId } from "../../../enums"; import { linkedFieldOption } from "../../../misc/linkedFieldOption.decorator"; -import { Utils } from "../../../misc/utils"; +import { Utils } from "../../../platform/misc/utils"; import { ItemView } from "./item.view"; diff --git a/libs/common/src/vault/models/view/login-uri-view.spec.ts b/libs/common/src/vault/models/view/login-uri-view.spec.ts index 974846a5708..b4a5e7288a4 100644 --- a/libs/common/src/vault/models/view/login-uri-view.spec.ts +++ b/libs/common/src/vault/models/view/login-uri-view.spec.ts @@ -1,5 +1,5 @@ import { UriMatchType } from "../../../enums"; -import { Utils } from "../../../misc/utils"; +import { Utils } from "../../../platform/misc/utils"; import { LoginUriView } from "./login-uri.view"; diff --git a/libs/common/src/vault/models/view/login-uri.view.ts b/libs/common/src/vault/models/view/login-uri.view.ts index 8b06366f803..be08f63d8b1 100644 --- a/libs/common/src/vault/models/view/login-uri.view.ts +++ b/libs/common/src/vault/models/view/login-uri.view.ts @@ -1,8 +1,8 @@ import { Jsonify } from "type-fest"; import { UriMatchType } from "../../../enums"; -import { Utils } from "../../../misc/utils"; import { View } from "../../../models/view/view"; +import { Utils } from "../../../platform/misc/utils"; import { LoginUri } from "../domain/login-uri"; const CanLaunchWhitelist = [ diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index e1685b1125c..954a14fe8e9 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -2,7 +2,7 @@ import { Jsonify } from "type-fest"; import { LoginLinkedId as LinkedId, UriMatchType } from "../../../enums"; import { linkedFieldOption } from "../../../misc/linkedFieldOption.decorator"; -import { Utils } from "../../../misc/utils"; +import { Utils } from "../../../platform/misc/utils"; import { Login } from "../domain/login"; import { ItemView } from "./item.view"; diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 9fa70653cb6..2c9adce553b 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1,48 +1,127 @@ -// eslint-disable-next-line no-restricted-imports -import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; +import { mock, mockReset } from "jest-mock-extended"; +import { of } from "rxjs"; +import { makeStaticByteArray } from "../../../spec/utils"; import { ApiService } from "../../abstractions/api.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { EncryptService } from "../../abstractions/encrypt.service"; -import { I18nService } from "../../abstractions/i18n.service"; import { SearchService } from "../../abstractions/search.service"; import { SettingsService } from "../../abstractions/settings.service"; -import { StateService } from "../../abstractions/state.service"; -import { EncArrayBuffer } from "../../models/domain/enc-array-buffer"; -import { EncString } from "../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; +import { UriMatchType, FieldType } from "../../enums"; +import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { + CipherKey, + OrgKey, + SymmetricCryptoKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { ContainerService } from "../../platform/services/container.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; +import { CipherRepromptType } from "../enums/cipher-reprompt-type"; +import { CipherType } from "../enums/cipher-type"; +import { CipherData } from "../models/data/cipher.data"; import { Cipher } from "../models/domain/cipher"; +import { CipherCreateRequest } from "../models/request/cipher-create.request"; +import { CipherPartialRequest } from "../models/request/cipher-partial.request"; +import { CipherRequest } from "../models/request/cipher.request"; +import { CipherView } from "../models/view/cipher.view"; import { CipherService } from "./cipher.service"; const ENCRYPTED_TEXT = "This data has been encrypted"; -const ENCRYPTED_BYTES = Substitute.for(); +const ENCRYPTED_BYTES = mock(); + +const cipherData: CipherData = { + id: "id", + organizationId: "orgId", + folderId: "folderId", + edit: true, + viewPassword: true, + organizationUseTotp: true, + favorite: false, + revisionDate: "2022-01-31T12:00:00.000Z", + type: CipherType.Login, + name: "EncryptedString", + notes: "EncryptedString", + creationDate: "2022-01-01T12:00:00.000Z", + deletedDate: null, + key: "EncKey", + reprompt: CipherRepromptType.None, + login: { + uris: [{ uri: "EncryptedString", match: UriMatchType.Domain }], + username: "EncryptedString", + password: "EncryptedString", + passwordRevisionDate: "2022-01-31T12:00:00.000Z", + totp: "EncryptedString", + autofillOnPageLoad: false, + }, + passwordHistory: [{ password: "EncryptedString", lastUsedDate: "2022-01-31T12:00:00.000Z" }], + attachments: [ + { + id: "a1", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + { + id: "a2", + url: "url", + size: "1100", + sizeName: "1.1 KB", + fileName: "file", + key: "EncKey", + }, + ], + fields: [ + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Text, + linkedId: null, + }, + { + name: "EncryptedString", + value: "EncryptedString", + type: FieldType.Hidden, + linkedId: null, + }, + ], +}; describe("Cipher Service", () => { - let cryptoService: SubstituteOf; - let stateService: SubstituteOf; - let settingsService: SubstituteOf; - let apiService: SubstituteOf; - let cipherFileUploadService: SubstituteOf; - let i18nService: SubstituteOf; - let searchService: SubstituteOf; - let encryptService: SubstituteOf; + const cryptoService = mock(); + const stateService = mock(); + const settingsService = mock(); + const apiService = mock(); + const cipherFileUploadService = mock(); + const i18nService = mock(); + const searchService = mock(); + const encryptService = mock(); + const configService = mock(); let cipherService: CipherService; + let cipherObj: Cipher; beforeEach(() => { - cryptoService = Substitute.for(); - stateService = Substitute.for(); - settingsService = Substitute.for(); - apiService = Substitute.for(); - cipherFileUploadService = Substitute.for(); - i18nService = Substitute.for(); - searchService = Substitute.for(); - encryptService = Substitute.for(); - - cryptoService.encryptToBytes(Arg.any(), Arg.any()).resolves(ENCRYPTED_BYTES); - cryptoService.encrypt(Arg.any(), Arg.any()).resolves(new EncString(ENCRYPTED_TEXT)); + mockReset(apiService); + mockReset(cryptoService); + mockReset(stateService); + mockReset(settingsService); + mockReset(cipherFileUploadService); + mockReset(i18nService); + mockReset(searchService); + mockReset(encryptService); + mockReset(configService); + + encryptService.encryptToBytes.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES)); + encryptService.encrypt.mockReturnValue(Promise.resolve(new EncString(ENCRYPTED_TEXT))); + + (window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService); cipherService = new CipherService( cryptoService, @@ -52,19 +131,181 @@ describe("Cipher Service", () => { searchService, stateService, encryptService, - cipherFileUploadService + cipherFileUploadService, + configService ); + + cipherObj = new Cipher(cipherData); + }); + describe("saveAttachmentRawWithServer()", () => { + it("should upload encrypted file contents with save attachments", async () => { + const fileName = "filename"; + const fileData = new Uint8Array(10); + cryptoService.getOrgKey.mockReturnValue( + Promise.resolve(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey) + ); + cryptoService.makeDataEncKey.mockReturnValue( + Promise.resolve(new SymmetricCryptoKey(new Uint8Array(32))) + ); + + configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false)); + process.env.FLAGS = JSON.stringify({ + enableCipherKeyEncryption: false, + }); + + const spy = jest.spyOn(cipherFileUploadService, "upload"); + + await cipherService.saveAttachmentRawWithServer(new Cipher(), fileName, fileData); + + expect(spy).toHaveBeenCalled(); + }); }); - it("attachments upload encrypted file contents", async () => { - const fileName = "filename"; - const fileData = new Uint8Array(10).buffer; - cryptoService.getOrgKey(Arg.any()).resolves(new SymmetricCryptoKey(new Uint8Array(32).buffer)); + describe("createWithServer()", () => { + it("should call apiService.postCipherAdmin when orgAdmin param is true and the cipher orgId != null", async () => { + const spy = jest + .spyOn(apiService, "postCipherAdmin") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.createWithServer(cipherObj, true); + const expectedObj = new CipherCreateRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(expectedObj); + }); + it("should call apiService.postCipher when orgAdmin param is true and the cipher orgId is null", async () => { + cipherObj.organizationId = null; + const spy = jest + .spyOn(apiService, "postCipher") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.createWithServer(cipherObj, true); + const expectedObj = new CipherRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(expectedObj); + }); + + it("should call apiService.postCipherCreate if collectionsIds != null", async () => { + cipherObj.collectionIds = ["123"]; + const spy = jest + .spyOn(apiService, "postCipherCreate") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.createWithServer(cipherObj); + const expectedObj = new CipherCreateRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(expectedObj); + }); + + it("should call apiService.postCipher when orgAdmin and collectionIds logic is false", async () => { + const spy = jest + .spyOn(apiService, "postCipher") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.createWithServer(cipherObj); + const expectedObj = new CipherRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(expectedObj); + }); + }); + + describe("updateWithServer()", () => { + it("should call apiService.putCipherAdmin when orgAdmin and isNotClone params are true", async () => { + const spy = jest + .spyOn(apiService, "putCipherAdmin") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.updateWithServer(cipherObj, true, true); + const expectedObj = new CipherRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj); + }); + + it("should call apiService.putCipher if cipher.edit is true", async () => { + cipherObj.edit = true; + const spy = jest + .spyOn(apiService, "putCipher") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.updateWithServer(cipherObj); + const expectedObj = new CipherRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj); + }); + + it("should call apiService.putPartialCipher when orgAdmin, isNotClone, and edit are false", async () => { + cipherObj.edit = false; + const spy = jest + .spyOn(apiService, "putPartialCipher") + .mockImplementation(() => Promise.resolve(cipherObj)); + cipherService.updateWithServer(cipherObj); + const expectedObj = new CipherPartialRequest(cipherObj); + + expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj); + }); + }); + + describe("encrypt", () => { + let cipherView: CipherView; + + beforeEach(() => { + cipherView = new CipherView(); + cipherView.type = CipherType.Login; + + encryptService.decryptToBytes.mockReturnValue(Promise.resolve(makeStaticByteArray(64))); + configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true)); + cryptoService.makeCipherKey.mockReturnValue( + Promise.resolve(new SymmetricCryptoKey(makeStaticByteArray(64)) as CipherKey) + ); + cryptoService.encrypt.mockReturnValue(Promise.resolve(new EncString(ENCRYPTED_TEXT))); + }); + + describe("cipher.key", () => { + it("is null when enableCipherKeyEncryption flag is false", async () => { + process.env.FLAGS = JSON.stringify({ + enableCipherKeyEncryption: false, + }); + + const cipher = await cipherService.encrypt(cipherView); + + expect(cipher.key).toBeNull(); + }); + + it("is defined when enableCipherKeyEncryption flag is true", async () => { + process.env.FLAGS = JSON.stringify({ + enableCipherKeyEncryption: true, + }); + + const cipher = await cipherService.encrypt(cipherView); + + expect(cipher.key).toBeDefined(); + }); + }); + + describe("encryptWithCipherKey", () => { + beforeEach(() => { + jest.spyOn(cipherService, "encryptCipherWithCipherKey"); + }); + + it("is not called when enableCipherKeyEncryption is false", async () => { + process.env.FLAGS = JSON.stringify({ + enableCipherKeyEncryption: false, + }); + + await cipherService.encrypt(cipherView); + + expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled(); + }); + + it("is called when enableCipherKeyEncryption is true", async () => { + process.env.FLAGS = JSON.stringify({ + enableCipherKeyEncryption: true, + }); - await cipherService.saveAttachmentRawWithServer(new Cipher(), fileName, fileData); + await cipherService.encrypt(cipherView); - cipherFileUploadService - .received(1) - .upload(Arg.any(), Arg.any(), ENCRYPTED_BYTES, Arg.any(), Arg.any()); + expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled(); + }); + }); }); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index c59319285a3..b9bbc3e291e 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1,19 +1,28 @@ +import { firstValueFrom } from "rxjs"; +import { SemVer } from "semver"; + import { ApiService } from "../../abstractions/api.service"; -import { CryptoService } from "../../abstractions/crypto.service"; -import { EncryptService } from "../../abstractions/encrypt.service"; -import { I18nService } from "../../abstractions/i18n.service"; import { SearchService } from "../../abstractions/search.service"; import { SettingsService } from "../../abstractions/settings.service"; -import { StateService } from "../../abstractions/state.service"; import { FieldType, UriMatchType } from "../../enums"; -import { sequentialize } from "../../misc/sequentialize"; -import { Utils } from "../../misc/utils"; -import Domain from "../../models/domain/domain-base"; -import { EncArrayBuffer } from "../../models/domain/enc-array-buffer"; -import { EncString } from "../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { ErrorResponse } from "../../models/response/error.response"; import { View } from "../../models/view/view"; +import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { flagEnabled } from "../../platform/misc/flags"; +import { sequentialize } from "../../platform/misc/sequentialize"; +import { Utils } from "../../platform/misc/utils"; +import Domain from "../../platform/models/domain/domain-base"; +import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { + OrgKey, + SymmetricCryptoKey, + UserKey, +} from "../../platform/models/domain/symmetric-crypto-key"; import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { CipherType } from "../enums/cipher-type"; @@ -43,6 +52,8 @@ import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { PasswordHistoryView } from "../models/view/password-history.view"; +const CIPHER_KEY_ENC_MIN_SERVER_VER = new SemVer("2023.9.1"); + export class CipherService implements CipherServiceAbstraction { private sortedCiphersCache: SortedCiphersCache = new SortedCiphersCache( this.sortCiphersByLastUsed @@ -56,7 +67,8 @@ export class CipherService implements CipherServiceAbstraction { private searchService: SearchService, private stateService: StateService, private encryptService: EncryptService, - private cipherFileUploadService: CipherFileUploadService + private cipherFileUploadService: CipherFileUploadService, + private configService: ConfigServiceAbstraction ) {} async getDecryptedCipherCache(): Promise { @@ -81,63 +93,18 @@ export class CipherService implements CipherServiceAbstraction { async encrypt( model: CipherView, - key?: SymmetricCryptoKey, + keyForEncryption?: SymmetricCryptoKey, + keyForCipherKeyDecryption?: SymmetricCryptoKey, originalCipher: Cipher = null ): Promise { - // Adjust password history if (model.id != null) { if (originalCipher == null) { originalCipher = await this.get(model.id); } if (originalCipher != null) { - const existingCipher = await originalCipher.decrypt(); - model.passwordHistory = existingCipher.passwordHistory || []; - if (model.type === CipherType.Login && existingCipher.type === CipherType.Login) { - if ( - existingCipher.login.password != null && - existingCipher.login.password !== "" && - existingCipher.login.password !== model.login.password - ) { - const ph = new PasswordHistoryView(); - ph.password = existingCipher.login.password; - ph.lastUsedDate = model.login.passwordRevisionDate = new Date(); - model.passwordHistory.splice(0, 0, ph); - } else { - model.login.passwordRevisionDate = existingCipher.login.passwordRevisionDate; - } - } - if (existingCipher.hasFields) { - const existingHiddenFields = existingCipher.fields.filter( - (f) => - f.type === FieldType.Hidden && - f.name != null && - f.name !== "" && - f.value != null && - f.value !== "" - ); - const hiddenFields = - model.fields == null - ? [] - : model.fields.filter( - (f) => f.type === FieldType.Hidden && f.name != null && f.name !== "" - ); - existingHiddenFields.forEach((ef) => { - const matchedField = hiddenFields.find((f) => f.name === ef.name); - if (matchedField == null || matchedField.value !== ef.value) { - const ph = new PasswordHistoryView(); - ph.password = ef.name + ": " + ef.value; - ph.lastUsedDate = new Date(); - model.passwordHistory.splice(0, 0, ph); - } - }); - } - } - if (model.passwordHistory != null && model.passwordHistory.length === 0) { - model.passwordHistory = null; - } else if (model.passwordHistory != null && model.passwordHistory.length > 5) { - // only save last 5 history - model.passwordHistory = model.passwordHistory.slice(0, 5); + await this.updateModelfromExistingCipher(model, originalCipher); } + this.adjustPasswordHistoryLength(model); } const cipher = new Cipher(); @@ -151,35 +118,32 @@ export class CipherService implements CipherServiceAbstraction { cipher.reprompt = model.reprompt; cipher.edit = model.edit; - if (key == null && cipher.organizationId != null) { - key = await this.cryptoService.getOrgKey(cipher.organizationId); - if (key == null) { - throw new Error("Cannot encrypt cipher for organization. No key."); - } - } - await Promise.all([ - this.encryptObjProperty( + if (await this.getCipherKeyEncryptionEnabled()) { + cipher.key = originalCipher?.key ?? null; + const userOrOrgKey = await this.getKeyForCipherKeyDecryption(cipher); + // The keyForEncryption is only used for encrypting the cipher key, not the cipher itself, since cipher key encryption is enabled. + // If the caller has provided a key for cipher key encryption, use it. Otherwise, use the user or org key. + keyForEncryption ||= userOrOrgKey; + // If the caller has provided a key for cipher key decryption, use it. Otherwise, use the user or org key. + keyForCipherKeyDecryption ||= userOrOrgKey; + return this.encryptCipherWithCipherKey( model, cipher, - { - name: null, - notes: null, - }, - key - ), - this.encryptCipherData(cipher, model, key), - this.encryptFields(model.fields, key).then((fields) => { - cipher.fields = fields; - }), - this.encryptPasswordHistories(model.passwordHistory, key).then((ph) => { - cipher.passwordHistory = ph; - }), - this.encryptAttachments(model.attachments, key).then((attachments) => { - cipher.attachments = attachments; - }), - ]); - - return cipher; + keyForEncryption, + keyForCipherKeyDecryption + ); + } else { + if (keyForEncryption == null && cipher.organizationId != null) { + keyForEncryption = await this.cryptoService.getOrgKey(cipher.organizationId); + if (keyForEncryption == null) { + throw new Error("Cannot encrypt cipher for organization. No key."); + } + } + // We want to ensure that the cipher key is null if cipher key encryption is disabled + // so that decryption uses the proper key. + cipher.key = null; + return this.encryptCipher(model, cipher, keyForEncryption); + } } async encryptAttachments( @@ -325,14 +289,9 @@ export class CipherService implements CipherServiceAbstraction { return await this.getDecryptedCipherCache(); } - const hasKey = await this.cryptoService.hasKey(); - if (!hasKey) { - throw new Error("No key."); - } - const ciphers = await this.getAll(); const orgKeys = await this.cryptoService.getOrgKeys(); - const userKey = await this.cryptoService.getKeyForUserEncryption(); + const userKey = await this.cryptoService.getUserKeyWithLegacySupport(); // Group ciphers by orgId or under 'null' for the user's ciphers const grouped = ciphers.reduce((agg, c) => { @@ -399,14 +358,21 @@ export class CipherService implements CipherServiceAbstraction { defaultMatch ??= await this.stateService.getDefaultUriMatch(); return ciphers.filter((cipher) => { - if (cipher.deletedDate != null) { + const cipherIsLogin = cipher.type === CipherType.Login && cipher.login !== null; + + if (cipher.deletedDate !== null) { return false; } - if (includeOtherTypes != null && includeOtherTypes.indexOf(cipher.type) > -1) { + + if ( + Array.isArray(includeOtherTypes) && + includeOtherTypes.includes(cipher.type) && + !cipherIsLogin + ) { return true; } - if (cipher.type === CipherType.Login && cipher.login !== null) { + if (cipherIsLogin) { return cipher.login.matchesUri(url, equivalentDomains, defaultMatch); } @@ -519,9 +485,12 @@ export class CipherService implements CipherServiceAbstraction { await this.stateService.setNeverDomains(domains); } - async createWithServer(cipher: Cipher): Promise { + async createWithServer(cipher: Cipher, orgAdmin?: boolean): Promise { let response: CipherResponse; - if (cipher.collectionIds != null) { + if (orgAdmin && cipher.organizationId != null) { + const request = new CipherCreateRequest(cipher); + response = await this.apiService.postCipherAdmin(request); + } else if (cipher.collectionIds != null) { const request = new CipherCreateRequest(cipher); response = await this.apiService.postCipherCreate(request); } else { @@ -534,9 +503,12 @@ export class CipherService implements CipherServiceAbstraction { await this.upsert(data); } - async updateWithServer(cipher: Cipher): Promise { + async updateWithServer(cipher: Cipher, orgAdmin?: boolean, isNotClone?: boolean): Promise { let response: CipherResponse; - if (cipher.edit) { + if (orgAdmin && isNotClone) { + const request = new CipherRequest(cipher); + response = await this.apiService.putCipherAdmin(cipher.id, request); + } else if (cipher.edit) { const request = new CipherRequest(cipher); response = await this.apiService.putCipher(cipher.id, request); } else { @@ -567,7 +539,7 @@ export class CipherService implements CipherServiceAbstraction { cipher.organizationId = organizationId; cipher.collectionIds = collectionIds; - const encCipher = await this.encrypt(cipher); + const encCipher = await this.encryptSharedCipher(cipher); const request = new CipherShareRequest(encCipher); const response = await this.apiService.putShareCipher(cipher.id, request); const data = new CipherData(response, collectionIds); @@ -585,7 +557,7 @@ export class CipherService implements CipherServiceAbstraction { cipher.organizationId = organizationId; cipher.collectionIds = collectionIds; promises.push( - this.encrypt(cipher).then((c) => { + this.encryptSharedCipher(cipher).then((c) => { encCiphers.push(c); }) ); @@ -630,14 +602,32 @@ export class CipherService implements CipherServiceAbstraction { async saveAttachmentRawWithServer( cipher: Cipher, filename: string, - data: ArrayBuffer, + data: Uint8Array, admin = false ): Promise { - const key = await this.cryptoService.getOrgKey(cipher.organizationId); - const encFileName = await this.cryptoService.encrypt(filename, key); + const encKey = await this.getKeyForCipherKeyDecryption(cipher); + const cipherKeyEncryptionEnabled = await this.getCipherKeyEncryptionEnabled(); + + const cipherEncKey = + cipherKeyEncryptionEnabled && cipher.key != null + ? (new SymmetricCryptoKey( + await this.encryptService.decryptToBytes(cipher.key, encKey) + ) as UserKey) + : encKey; + + //if cipher key encryption is disabled but the item has an individual key, + //then we rollback to using the user key as the main key of encryption of the item + //in order to keep item and it's attachments with the same encryption level + if (cipher.key != null && !cipherKeyEncryptionEnabled) { + const model = await cipher.decrypt(await this.getKeyForCipherKeyDecryption(cipher)); + cipher = await this.encrypt(model); + await this.updateWithServer(cipher); + } - const dataEncKey = await this.cryptoService.makeEncKey(key); - const encData = await this.cryptoService.encryptToBytes(data, dataEncKey[0]); + const encFileName = await this.encryptService.encrypt(filename, cipherEncKey); + + const dataEncKey = await this.cryptoService.makeDataEncKey(cipherEncKey); + const encData = await this.encryptService.encryptToBytes(new Uint8Array(data), dataEncKey[0]); const response = await this.cipherFileUploadService.upload( cipher, @@ -740,10 +730,11 @@ export class CipherService implements CipherServiceAbstraction { } async deleteManyWithServer(ids: string[], asAdmin = false): Promise { + const request = new CipherBulkDeleteRequest(ids); if (asAdmin) { - await this.apiService.deleteManyCiphersAdmin(new CipherBulkDeleteRequest(ids)); + await this.apiService.deleteManyCiphersAdmin(request); } else { - await this.apiService.deleteManyCiphers(new CipherBulkDeleteRequest(ids)); + await this.apiService.deleteManyCiphers(request); } await this.delete(ids); } @@ -879,10 +870,11 @@ export class CipherService implements CipherServiceAbstraction { } async softDeleteManyWithServer(ids: string[], asAdmin = false): Promise { + const request = new CipherBulkDeleteRequest(ids); if (asAdmin) { - await this.apiService.putDeleteManyCiphersAdmin(new CipherBulkDeleteRequest(ids)); + await this.apiService.putDeleteManyCiphersAdmin(request); } else { - await this.apiService.putDeleteManyCiphers(new CipherBulkDeleteRequest(ids)); + await this.apiService.putDeleteManyCiphers(request); } await this.softDelete(ids); @@ -915,14 +907,30 @@ export class CipherService implements CipherServiceAbstraction { } async restoreWithServer(id: string, asAdmin = false): Promise { - const response = asAdmin - ? await this.apiService.putRestoreCipherAdmin(id) - : await this.apiService.putRestoreCipher(id); + let response; + if (asAdmin) { + response = await this.apiService.putRestoreCipherAdmin(id); + } else { + response = await this.apiService.putRestoreCipher(id); + } + await this.restore({ id: id, revisionDate: response.revisionDate }); } - async restoreManyWithServer(ids: string[]): Promise { - const response = await this.apiService.putRestoreManyCiphers(new CipherBulkRestoreRequest(ids)); + async restoreManyWithServer( + ids: string[], + organizationId: string = null, + asAdmin = false + ): Promise { + let response; + if (asAdmin) { + const request = new CipherBulkRestoreRequest(ids, organizationId); + response = await this.apiService.putRestoreManyCiphersAdmin(request); + } else { + const request = new CipherBulkRestoreRequest(ids); + response = await this.apiService.putRestoreManyCiphers(request); + } + const restores: { id: string; revisionDate: string }[] = []; for (const cipher of response.data) { restores.push({ id: cipher.id, revisionDate: cipher.revisionDate }); @@ -930,8 +938,80 @@ export class CipherService implements CipherServiceAbstraction { await this.restore(restores); } + async getKeyForCipherKeyDecryption(cipher: Cipher): Promise { + return ( + (await this.cryptoService.getOrgKey(cipher.organizationId)) || + ((await this.cryptoService.getUserKeyWithLegacySupport()) as UserKey) + ); + } + // Helpers + // In the case of a cipher that is being shared with an organization, we want to decrypt the + // cipher key with the user's key and then re-encrypt it with the organization's key. + private async encryptSharedCipher(model: CipherView): Promise { + const keyForCipherKeyDecryption = await this.cryptoService.getUserKeyWithLegacySupport(); + return await this.encrypt(model, null, keyForCipherKeyDecryption); + } + + private async updateModelfromExistingCipher( + model: CipherView, + originalCipher: Cipher + ): Promise { + const existingCipher = await originalCipher.decrypt( + await this.getKeyForCipherKeyDecryption(originalCipher) + ); + model.passwordHistory = existingCipher.passwordHistory || []; + if (model.type === CipherType.Login && existingCipher.type === CipherType.Login) { + if ( + existingCipher.login.password != null && + existingCipher.login.password !== "" && + existingCipher.login.password !== model.login.password + ) { + const ph = new PasswordHistoryView(); + ph.password = existingCipher.login.password; + ph.lastUsedDate = model.login.passwordRevisionDate = new Date(); + model.passwordHistory.splice(0, 0, ph); + } else { + model.login.passwordRevisionDate = existingCipher.login.passwordRevisionDate; + } + } + if (existingCipher.hasFields) { + const existingHiddenFields = existingCipher.fields.filter( + (f) => + f.type === FieldType.Hidden && + f.name != null && + f.name !== "" && + f.value != null && + f.value !== "" + ); + const hiddenFields = + model.fields == null + ? [] + : model.fields.filter( + (f) => f.type === FieldType.Hidden && f.name != null && f.name !== "" + ); + existingHiddenFields.forEach((ef) => { + const matchedField = hiddenFields.find((f) => f.name === ef.name); + if (matchedField == null || matchedField.value !== ef.value) { + const ph = new PasswordHistoryView(); + ph.password = ef.name + ": " + ef.value; + ph.lastUsedDate = new Date(); + model.passwordHistory.splice(0, 0, ph); + } + }); + } + } + + private adjustPasswordHistoryLength(model: CipherView) { + if (model.passwordHistory != null && model.passwordHistory.length === 0) { + model.passwordHistory = null; + } else if (model.passwordHistory != null && model.passwordHistory.length > 5) { + // only save last 5 history + model.passwordHistory = model.passwordHistory.slice(0, 5); + } + } + private async shareAttachmentWithServer( attachmentView: AttachmentView, cipherId: string, @@ -946,11 +1026,15 @@ export class CipherService implements CipherServiceAbstraction { const encBuf = await EncArrayBuffer.fromResponse(attachmentResponse); const decBuf = await this.cryptoService.decryptFromBytes(encBuf, null); - const key = await this.cryptoService.getOrgKey(organizationId); - const encFileName = await this.cryptoService.encrypt(attachmentView.fileName, key); - const dataEncKey = await this.cryptoService.makeEncKey(key); - const encData = await this.cryptoService.encryptToBytes(decBuf, dataEncKey[0]); + let encKey: UserKey | OrgKey; + encKey = await this.cryptoService.getOrgKey(organizationId); + encKey ||= (await this.cryptoService.getUserKeyWithLegacySupport()) as UserKey; + + const dataEncKey = await this.cryptoService.makeDataEncKey(encKey); + + const encFileName = await this.encryptService.encrypt(attachmentView.fileName, encKey); + const encData = await this.encryptService.encryptToBytes(new Uint8Array(decBuf), dataEncKey[0]); const fd = new FormData(); try { @@ -1156,4 +1240,69 @@ export class CipherService implements CipherServiceAbstraction { private clearSortedCiphers() { this.sortedCiphersCache.clear(); } + + private async encryptCipher( + model: CipherView, + cipher: Cipher, + key: SymmetricCryptoKey + ): Promise { + await Promise.all([ + this.encryptObjProperty( + model, + cipher, + { + name: null, + notes: null, + }, + key + ), + this.encryptCipherData(cipher, model, key), + this.encryptFields(model.fields, key).then((fields) => { + cipher.fields = fields; + }), + this.encryptPasswordHistories(model.passwordHistory, key).then((ph) => { + cipher.passwordHistory = ph; + }), + this.encryptAttachments(model.attachments, key).then((attachments) => { + cipher.attachments = attachments; + }), + ]); + + return cipher; + } + + private async encryptCipherWithCipherKey( + model: CipherView, + cipher: Cipher, + keyForCipherKeyEncryption: SymmetricCryptoKey, + keyForCipherKeyDecryption: SymmetricCryptoKey + ): Promise { + // First, we get the key for cipher key encryption, in its decrypted form + let decryptedCipherKey: SymmetricCryptoKey; + if (cipher.key == null) { + decryptedCipherKey = await this.cryptoService.makeCipherKey(); + } else { + decryptedCipherKey = new SymmetricCryptoKey( + await this.encryptService.decryptToBytes(cipher.key, keyForCipherKeyDecryption) + ); + } + + // Then, we have to encrypt the cipher key with the proper key. + cipher.key = await this.encryptService.encrypt( + decryptedCipherKey.key, + keyForCipherKeyEncryption + ); + + // Finally, we can encrypt the cipher with the decrypted cipher key. + return this.encryptCipher(model, cipher, decryptedCipherKey); + } + + private async getCipherKeyEncryptionEnabled(): Promise { + return ( + flagEnabled("enableCipherKeyEncryption") && + (await firstValueFrom( + this.configService.checkServerMeetsVersionRequirement$(CIPHER_KEY_ENC_MIN_SERVER_VER) + )) + ); + } } diff --git a/libs/common/src/admin-console/services/collection.service.ts b/libs/common/src/vault/services/collection.service.ts similarity index 93% rename from libs/common/src/admin-console/services/collection.service.ts rename to libs/common/src/vault/services/collection.service.ts index ccd58455114..277d40cc88d 100644 --- a/libs/common/src/admin-console/services/collection.service.ts +++ b/libs/common/src/vault/services/collection.service.ts @@ -1,10 +1,10 @@ -import { CryptoService } from "../../abstractions/crypto.service"; -import { I18nService } from "../../abstractions/i18n.service"; -import { StateService } from "../../abstractions/state.service"; import { ServiceUtils } from "../../misc/serviceUtils"; -import { Utils } from "../../misc/utils"; import { TreeNode } from "../../models/domain/tree-node"; -import { CollectionService as CollectionServiceAbstraction } from "../abstractions/collection.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { I18nService } from "../../platform/abstractions/i18n.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; +import { CollectionService as CollectionServiceAbstraction } from "../../vault/abstractions/collection.service"; import { CollectionData } from "../models/data/collection.data"; import { Collection } from "../models/domain/collection"; import { CollectionView } from "../models/view/collection.view"; @@ -79,7 +79,7 @@ export class CollectionService implements CollectionServiceAbstraction { return decryptedCollections; } - const hasKey = await this.cryptoService.hasKey(); + const hasKey = await this.cryptoService.hasUserKey(); if (!hasKey) { throw new Error("No key."); } diff --git a/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts b/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts index cbeaf988ef3..3af85018ad4 100644 --- a/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts +++ b/libs/common/src/vault/services/file-upload/cipher-file-upload.service.ts @@ -1,13 +1,13 @@ import { ApiService } from "../../../abstractions/api.service"; +import { ErrorResponse } from "../../../models/response/error.response"; import { FileUploadApiMethods, FileUploadService, -} from "../../../abstractions/file-upload/file-upload.service"; -import { Utils } from "../../../misc/utils"; -import { EncArrayBuffer } from "../../../models/domain/enc-array-buffer"; -import { EncString } from "../../../models/domain/enc-string"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; -import { ErrorResponse } from "../../../models/response/error.response"; +} from "../../../platform/abstractions/file-upload/file-upload.service"; +import { Utils } from "../../../platform/misc/utils"; +import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "../../abstractions/file-upload/cipher-file-upload.service"; import { Cipher } from "../../models/domain/cipher"; import { AttachmentRequest } from "../../models/request/attachment.request"; diff --git a/libs/common/src/vault/services/folder/folder.service.spec.ts b/libs/common/src/vault/services/folder/folder.service.spec.ts index 594a3ab1373..8dc158ed442 100644 --- a/libs/common/src/vault/services/folder/folder.service.spec.ts +++ b/libs/common/src/vault/services/folder/folder.service.spec.ts @@ -2,12 +2,12 @@ import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { BehaviorSubject, firstValueFrom } from "rxjs"; -import { CryptoService } from "../../../abstractions/crypto.service"; -import { EncryptService } from "../../../abstractions/encrypt.service"; -import { I18nService } from "../../../abstractions/i18n.service"; -import { EncString } from "../../../models/domain/enc-string"; -import { ContainerService } from "../../../services/container.service"; -import { StateService } from "../../../services/state.service"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { I18nService } from "../../../platform/abstractions/i18n.service"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { ContainerService } from "../../../platform/services/container.service"; +import { StateService } from "../../../platform/services/state.service"; import { CipherService } from "../../abstractions/cipher.service"; import { FolderData } from "../../models/data/folder.data"; import { FolderView } from "../../models/view/folder.view"; diff --git a/libs/common/src/vault/services/folder/folder.service.ts b/libs/common/src/vault/services/folder/folder.service.ts index 89ccf67a99d..2244d7a458a 100644 --- a/libs/common/src/vault/services/folder/folder.service.ts +++ b/libs/common/src/vault/services/folder/folder.service.ts @@ -1,10 +1,10 @@ import { BehaviorSubject, concatMap } from "rxjs"; -import { CryptoService } from "../../../abstractions/crypto.service"; -import { I18nService } from "../../../abstractions/i18n.service"; -import { StateService } from "../../../abstractions/state.service"; -import { Utils } from "../../../misc/utils"; -import { SymmetricCryptoKey } from "../../../models/domain/symmetric-crypto-key"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { I18nService } from "../../../platform/abstractions/i18n.service"; +import { StateService } from "../../../platform/abstractions/state.service"; +import { Utils } from "../../../platform/misc/utils"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { CipherService } from "../../../vault/abstractions/cipher.service"; import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction"; import { CipherData } from "../../../vault/models/data/cipher.data"; @@ -173,7 +173,7 @@ export class FolderService implements InternalFolderServiceAbstraction { this._folders.next(folders); - if (await this.cryptoService.hasKey()) { + if (await this.cryptoService.hasUserKey()) { this._folderViews.next(await this.decryptFolders(folders)); } } diff --git a/libs/common/src/vault/services/sync/sync.service.ts b/libs/common/src/vault/services/sync/sync.service.ts index 3cf75bcd079..c8bd274a4d9 100644 --- a/libs/common/src/vault/services/sync/sync.service.ts +++ b/libs/common/src/vault/services/sync/sync.service.ts @@ -1,22 +1,14 @@ import { ApiService } from "../../../abstractions/api.service"; -import { CryptoService } from "../../../abstractions/crypto.service"; -import { LogService } from "../../../abstractions/log.service"; -import { MessagingService } from "../../../abstractions/messaging.service"; import { SettingsService } from "../../../abstractions/settings.service"; -import { StateService } from "../../../abstractions/state.service"; -import { CollectionService } from "../../../admin-console/abstractions/collection.service"; -import { InternalOrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction"; +import { InternalOrganizationServiceAbstraction } from "../../../admin-console/abstractions/organization/organization.service.abstraction"; import { InternalPolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; import { ProviderService } from "../../../admin-console/abstractions/provider.service"; -import { CollectionData } from "../../../admin-console/models/data/collection.data"; import { OrganizationData } from "../../../admin-console/models/data/organization.data"; import { PolicyData } from "../../../admin-console/models/data/policy.data"; import { ProviderData } from "../../../admin-console/models/data/provider.data"; -import { CollectionDetailsResponse } from "../../../admin-console/models/response/collection.response"; import { PolicyResponse } from "../../../admin-console/models/response/policy.response"; import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service"; import { ForceResetPasswordReason } from "../../../auth/models/domain/force-reset-password-reason"; -import { sequentialize } from "../../../misc/sequentialize"; import { DomainsResponse } from "../../../models/response/domains.response"; import { SyncCipherNotification, @@ -24,6 +16,11 @@ import { SyncSendNotification, } from "../../../models/response/notification.response"; import { ProfileResponse } from "../../../models/response/profile.response"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { LogService } from "../../../platform/abstractions/log.service"; +import { MessagingService } from "../../../platform/abstractions/messaging.service"; +import { StateService } from "../../../platform/abstractions/state.service"; +import { sequentialize } from "../../../platform/misc/sequentialize"; import { SendData } from "../../../tools/send/models/data/send.data"; import { SendResponse } from "../../../tools/send/models/response/send.response"; import { SendApiService } from "../../../tools/send/services/send-api.service.abstraction"; @@ -36,6 +33,9 @@ import { CipherData } from "../../../vault/models/data/cipher.data"; import { FolderData } from "../../../vault/models/data/folder.data"; import { CipherResponse } from "../../../vault/models/response/cipher.response"; import { FolderResponse } from "../../../vault/models/response/folder.response"; +import { CollectionService } from "../../abstractions/collection.service"; +import { CollectionData } from "../../models/data/collection.data"; +import { CollectionDetailsResponse } from "../../models/response/collection.response"; export class SyncService implements SyncServiceAbstraction { syncInProgress = false; @@ -55,7 +55,7 @@ export class SyncService implements SyncServiceAbstraction { private stateService: StateService, private providerService: ProviderService, private folderApiService: FolderApiServiceAbstraction, - private organizationService: InternalOrganizationService, + private organizationService: InternalOrganizationServiceAbstraction, private sendApiService: SendApiService, private logoutCallback: (expired: boolean) => Promise ) {} @@ -303,8 +303,8 @@ export class SyncService implements SyncServiceAbstraction { throw new Error("Stamp has changed"); } - await this.cryptoService.setEncKey(response.key); - await this.cryptoService.setEncPrivateKey(response.privateKey); + await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); + await this.cryptoService.setPrivateKey(response.privateKey); await this.cryptoService.setProviderKeys(response.providers); await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations); await this.stateService.setAvatarColor(response.avatarColor); diff --git a/libs/common/test.setup.ts b/libs/common/test.setup.ts index dfcf7c86101..c50c7ca227e 100644 --- a/libs/common/test.setup.ts +++ b/libs/common/test.setup.ts @@ -12,16 +12,6 @@ expect.extend({ toEqualBuffer: toEqualBuffer, }); -interface CustomMatchers { +export interface CustomMatchers { toEqualBuffer(expected: Uint8Array | ArrayBuffer): R; } - -/* eslint-disable */ -declare global { - namespace jest { - interface Expect extends CustomMatchers {} - interface Matchers extends CustomMatchers {} - interface InverseAsymmetricMatchers extends CustomMatchers {} - } -} -/* eslint-enable */ diff --git a/libs/common/tsconfig.json b/libs/common/tsconfig.json index 6004a56fb55..11cdb4e44c0 100644 --- a/libs/common/tsconfig.json +++ b/libs/common/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "../shared/tsconfig.libs", - "include": ["src", "spec"], + "include": ["src", "spec", "./custom-matchers.d.ts"], "exclude": ["node_modules", "dist"] } diff --git a/libs/components/src/async-actions/bit-action.directive.ts b/libs/components/src/async-actions/bit-action.directive.ts index 640063971b5..2ff3a8d3eb9 100644 --- a/libs/components/src/async-actions/bit-action.directive.ts +++ b/libs/components/src/async-actions/bit-action.directive.ts @@ -1,8 +1,8 @@ import { Directive, HostListener, Input, OnDestroy, Optional } from "@angular/core"; import { BehaviorSubject, finalize, Subject, takeUntil, tap } from "rxjs"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; import { FunctionReturningAwaitable, functionToObservable } from "../utils/function-to-observable"; @@ -20,7 +20,7 @@ export class BitActionDirective implements OnDestroy { disabled = false; - @Input("bitAction") protected handler: FunctionReturningAwaitable; + @Input("bitAction") handler: FunctionReturningAwaitable; readonly loading$ = this._loading$.asObservable(); diff --git a/libs/components/src/async-actions/bit-submit.directive.ts b/libs/components/src/async-actions/bit-submit.directive.ts index 23a8a9695af..d691fdb1c25 100644 --- a/libs/components/src/async-actions/bit-submit.directive.ts +++ b/libs/components/src/async-actions/bit-submit.directive.ts @@ -2,8 +2,8 @@ import { Directive, Input, OnDestroy, OnInit, Optional } from "@angular/core"; import { FormGroupDirective } from "@angular/forms"; import { BehaviorSubject, catchError, filter, of, Subject, switchMap, takeUntil } from "rxjs"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { FunctionReturningAwaitable, functionToObservable } from "../utils/function-to-observable"; @@ -18,7 +18,7 @@ export class BitSubmitDirective implements OnInit, OnDestroy { private _loading$ = new BehaviorSubject(false); private _disabled$ = new BehaviorSubject(false); - @Input("bitSubmit") protected handler: FunctionReturningAwaitable; + @Input("bitSubmit") handler: FunctionReturningAwaitable; @Input() allowDisabledFormSubmit?: boolean = false; diff --git a/libs/components/src/async-actions/in-forms.stories.ts b/libs/components/src/async-actions/in-forms.stories.ts index f72f1b64794..ff060c6a7de 100644 --- a/libs/components/src/async-actions/in-forms.stories.ts +++ b/libs/components/src/async-actions/in-forms.stories.ts @@ -4,8 +4,8 @@ import { action } from "@storybook/addon-actions"; import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; import { delay, of } from "rxjs"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; -import { I18nService } from "@bitwarden/common/src/abstractions/i18n.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { I18nService } from "@bitwarden/common/src/platform/abstractions/i18n.service"; import { ButtonModule } from "../button"; import { FormFieldModule } from "../form-field"; diff --git a/libs/components/src/async-actions/standalone.stories.ts b/libs/components/src/async-actions/standalone.stories.ts index e4dec780e0b..5e15135dc5d 100644 --- a/libs/components/src/async-actions/standalone.stories.ts +++ b/libs/components/src/async-actions/standalone.stories.ts @@ -3,8 +3,8 @@ import { action } from "@storybook/addon-actions"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; import { delay, of } from "rxjs"; -import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ButtonModule } from "../button"; import { IconButtonModule } from "../icon-button"; diff --git a/libs/components/src/avatar/avatar.component.ts b/libs/components/src/avatar/avatar.component.ts index 8e67fd66ea9..32e92f8a95e 100644 --- a/libs/components/src/avatar/avatar.component.ts +++ b/libs/components/src/avatar/avatar.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnChanges } from "@angular/core"; import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; type SizeTypes = "xlarge" | "large" | "default" | "small" | "xsmall"; diff --git a/libs/components/src/avatar/avatar.mdx b/libs/components/src/avatar/avatar.mdx new file mode 100644 index 00000000000..c6c5ff78ba4 --- /dev/null +++ b/libs/components/src/avatar/avatar.mdx @@ -0,0 +1,67 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./avatar.stories"; + + + +# Avatar + +Avatars display a unique color that helps a user visually recognize their logged in account. + +A variance in color across the avatar component is important as it is used in Account Switching as a +visual indicator to recognize which of a personal or work account a user is logged into. + + + + +## Size + +### Large: 64px + + + +### Default: 48px + + + +### Small 28px + + + +## Background color + +The Background color can be set 3 ways. The color is generated using the following order of +priority: + +- Color +- ID +- Text, usually set to the user's Name field + + +Use the user 'ID' field if `Name` is not defined. + + +## Outline + +If the avatar is displayed on one of the theme's `background` color variables or is interactive, +display the avatar with a 1 pixel `secondary-500` border to meet WCAG AA graphic contrast guidelines +for interactive elements. + + + +## Avatar as a button + +The Avatar can be used as a button. + +Typically this is only in the navigation on client apps where account switching is used and in the +web app for the account menu indicator. + +When the avatar is used as a button, the following states should be used: + +`TODO:` [Jira add stories](https://bitwarden.atlassian.net/browse/CL-101) for button avatars. +[See Figma](https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?type=design&node-id=9730-31746&mode=design&t=IjDIHDb6FZl6bUQW-4) + +## Accessibility + +Avatar background color should have 3.1:1 contrast with it’s background; or include the +`secondary-500` border Avatar text should have 4.5:1 contrast with the avatar background color diff --git a/libs/components/src/badge-list/badge-list.component.html b/libs/components/src/badge-list/badge-list.component.html index cd0310d889c..3e429b94301 100644 --- a/libs/components/src/badge-list/badge-list.component.html +++ b/libs/components/src/badge-list/badge-list.component.html @@ -1,8 +1,10 @@
    - - {{ item }} + + + {{ item }} + , - + {{ "plusNMore" | i18n : (items.length - filteredItems.length).toString() }} diff --git a/libs/components/src/badge-list/badge-list.component.ts b/libs/components/src/badge-list/badge-list.component.ts index 09cbfb9d971..64deae21b9f 100644 --- a/libs/components/src/badge-list/badge-list.component.ts +++ b/libs/components/src/badge-list/badge-list.component.ts @@ -14,6 +14,7 @@ export class BadgeListComponent implements OnChanges { @Input() badgeType: BadgeTypes = "primary"; @Input() items: string[] = []; + @Input() truncate = true; @Input() get maxItems(): number | undefined { diff --git a/libs/components/src/badge-list/badge-list.stories.ts b/libs/components/src/badge-list/badge-list.stories.ts index 4e580badb1a..92e9e148753 100644 --- a/libs/components/src/badge-list/badge-list.stories.ts +++ b/libs/components/src/badge-list/badge-list.stories.ts @@ -1,6 +1,6 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { BadgeModule } from "../badge"; import { SharedModule } from "../shared"; @@ -29,6 +29,7 @@ export default { ], args: { badgeType: "primary", + truncate: false, }, parameters: { design: { @@ -44,7 +45,7 @@ export const Default: Story = { render: (args) => ({ props: args, template: ` - + `, }), @@ -52,5 +53,16 @@ export const Default: Story = { badgeType: "info", maxItems: 3, items: ["Badge 1", "Badge 2", "Badge 3", "Badge 4", "Badge 5"], + truncate: false, + }, +}; + +export const Truncated: Story = { + ...Default, + args: { + badgeType: "info", + maxItems: 3, + items: ["Badge 1", "Badge 2 containing lengthy text", "Badge 3", "Badge 4", "Badge 5"], + truncate: true, }, }; diff --git a/libs/components/src/badge/badge.directive.ts b/libs/components/src/badge/badge.directive.ts index e8c771c48ca..14dc96edd53 100644 --- a/libs/components/src/badge/badge.directive.ts +++ b/libs/components/src/badge/badge.directive.ts @@ -26,11 +26,12 @@ const hoverStyles: Record = { export class BadgeDirective { @HostBinding("class") get classList() { return [ - "tw-inline", + "tw-inline-block", "tw-py-0.5", "tw-px-1.5", "tw-font-bold", "tw-text-center", + "tw-align-text-top", "!tw-text-contrast", "tw-rounded", "tw-border-none", @@ -44,14 +45,19 @@ export class BadgeDirective { "focus:tw-ring-primary-700", ] .concat(styles[this.badgeType]) - .concat(this.hasHoverEffects ? hoverStyles[this.badgeType] : []); + .concat(this.hasHoverEffects ? hoverStyles[this.badgeType] : []) + .concat(this.truncate ? ["tw-truncate", "tw-max-w-40"] : []); + } + @HostBinding("attr.title") get title() { + return this.truncate ? this.el.nativeElement.textContent.trim() : null; } @Input() badgeType: BadgeTypes = "primary"; + @Input() truncate = true; private hasHoverEffects = false; - constructor(el: ElementRef) { + constructor(private el: ElementRef) { this.hasHoverEffects = el?.nativeElement?.nodeName != "SPAN"; } } diff --git a/libs/components/src/badge/badge.mdx b/libs/components/src/badge/badge.mdx new file mode 100644 index 00000000000..1c919449978 --- /dev/null +++ b/libs/components/src/badge/badge.mdx @@ -0,0 +1,67 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./badge.stories"; + + + +# Badge + +The Badge directive can be used on a `` (non clickable events), or an `` or ` + Button `, }), }; @@ -72,3 +73,10 @@ export const Info: Story = { badgeType: "info", }, }; + +export const Truncated: Story = { + ...Primary, + args: { + truncate: true, + }, +}; diff --git a/libs/components/src/banner/banner.component.spec.ts b/libs/components/src/banner/banner.component.spec.ts index 0fe2391a5fc..29f10016a15 100644 --- a/libs/components/src/banner/banner.component.spec.ts +++ b/libs/components/src/banner/banner.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SharedModule } from "../shared/shared.module"; import { I18nMockService } from "../utils/i18n-mock.service"; diff --git a/libs/components/src/banner/banner.mdx b/libs/components/src/banner/banner.mdx index 84c71cde954..9f6aeb2aa7c 100644 --- a/libs/components/src/banner/banner.mdx +++ b/libs/components/src/banner/banner.mdx @@ -10,7 +10,7 @@ Banners are used for important communication with the user that needs to be seen little effect on the experience. Banners appear at the top of the user's screen on page load and persist across all pages a user navigates to. -- They should always be dismissable and never use a timeout. If a user dismisses a banner, it should +- They should always be dismissible and never use a timeout. If a user dismisses a banner, it should not reappear during that same active session. - Use banners sparingly, as they can feel intrusive to the user if they appear unexpectedly. Their effectiveness may decrease if too many are used. diff --git a/libs/components/src/banner/banner.stories.ts b/libs/components/src/banner/banner.stories.ts index e2c8a0c82b2..eeab84b4ed5 100644 --- a/libs/components/src/banner/banner.stories.ts +++ b/libs/components/src/banner/banner.stories.ts @@ -1,6 +1,6 @@ import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { IconButtonModule } from "../icon-button"; import { LinkModule } from "../link"; diff --git a/libs/components/src/breadcrumbs/breadcrumbs.mdx b/libs/components/src/breadcrumbs/breadcrumbs.mdx new file mode 100644 index 00000000000..9dd23c530d1 --- /dev/null +++ b/libs/components/src/breadcrumbs/breadcrumbs.mdx @@ -0,0 +1,54 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./breadcrumbs.stories"; + + + +# Breadcrumbs + +Breadcrumbs are used to help users understand where they are in a products navigation. Typically +Bitwarden uses this component to indicate the user's current location in a set of data organized in +containers (Collections, Folders, or Projects). + + + + +## Display + +Breadcrumbs display above the page title. The current page should not appear as a breadcrumb link. +See [Header with Breadcrumbs](?path=/story/web-header--with-breadcrumbs). + +### Top Level + +When a user is 1 level deep into a tree, the top level is displayed as a single link above the page +title. + + + +### Second Level + +When a user is 2 or more levels deep into a tree, the top level is displayed followed by an + + icon, and the following pages. + + + +### Overflow + +When a user is several levels deep into a tree, the top level or 2 are displayed followed by an + + icon button, and then the page directly above the current page. + +When the user selects the icon button, a menu opens displaying +the pages between the top level and the previous page. + + + +### Small screens + +If a screen's width is not large enough to display the full breadcrumb path, display a link to the +previous page and an icon to take the user back to the previous +page. + +`TODO:` [Jira add stories](https://bitwarden.atlassian.net/browse/CL-102) for responsive screen +width/small screens diff --git a/libs/components/src/button/button.mdx b/libs/components/src/button/button.mdx index fddbda36fb5..6dcbbbbfac7 100644 --- a/libs/components/src/button/button.mdx +++ b/libs/components/src/button/button.mdx @@ -33,31 +33,6 @@ Groups within page content, dialog footers or forms should have the `primary` ca to left. Groups in headers and navigational areas should have the `primary` call to action on the right. -## Accessibility - -Please follow these guidelines to ensure that buttons are accessible to all users. - -### Color contrast - -All button styles are WCAG compliant when displayed on `background` and `background-alt` colors. To -use a button on a different background, double check that the color contrast is sufficient in both -the light and dark themes. - -### Loading Buttons - -Include an `aria-label` attribute that defaults to “loading” but can be configurable per -implementation. On click, the screen reader should announce the `aria-label`. Once the action is -compelted, use another messaging pattern to alert the user that the action is complete (example: -success toast). - -### Submit and async actions - -Both submit and async action buttons use a loading button state while an action is taken. If your -button is preforming a long running task in the background like a server API call, be sure to review -the [Async Actions Directive](?path=/story/component-library-async-actions-overview--page). - - - ## Styles There are 3 main styles for the button: Primary, Secondary, and Danger. @@ -96,3 +71,39 @@ Typically button widths expand with their text. In some causes though buttons ma where the width is fixed and the text wraps to 2 lines if exceeding the button’s width. + +## Accessibility + +Please follow these guidelines to ensure that buttons are accessible to all users. + +### Color contrast + +All button styles are WCAG compliant when displayed on `background` and `background-alt` colors. To +use a button on a different background, double check that the color contrast is sufficient in both +the light and dark themes. + +### Loading Buttons + +Include an `aria-label` attribute that defaults to "loading" but can be configurable per +implementation. On click, the screen reader should announce the `aria-label`. Once the action is +completed, use another messaging pattern to alert the user that the action is complete (example: +success toast). + +### Submit and async actions + +Both submit and async action buttons use a loading button state while an action is taken. If your +button is preforming a long running task in the background like a server API call, be sure to review +the [Async Actions Directive](?path=/story/component-library-async-actions-overview--page). + + + +### appA11yTitle + +`appA11yTitle` is a directive that auto assigns the same string to the `title` and `aria-label` +attributes. + +When a button uses accessible content (e.i. actual text), DO NOT include this as it adds redundant +content for someone using assistive technology. + +`appA11yTitle` should only be used if the element it applies to does not include accessible text, +e.i. an icon. diff --git a/libs/components/src/callout/callout.component.html b/libs/components/src/callout/callout.component.html index 3d66642fa5a..57da850d8bb 100644 --- a/libs/components/src/callout/callout.component.html +++ b/libs/components/src/callout/callout.component.html @@ -1,14 +1,16 @@ -
    -

    {{ title }} -

    + -
    + diff --git a/libs/components/src/callout/callout.component.spec.ts b/libs/components/src/callout/callout.component.spec.ts index cc54ff6dc23..36abae437d5 100644 --- a/libs/components/src/callout/callout.component.spec.ts +++ b/libs/components/src/callout/callout.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nMockService } from "../utils/i18n-mock.service"; diff --git a/libs/components/src/callout/callout.component.ts b/libs/components/src/callout/callout.component.ts index 33dc2414ef8..7ce79071bf0 100644 --- a/libs/components/src/callout/callout.component.ts +++ b/libs/components/src/callout/callout.component.ts @@ -1,6 +1,6 @@ import { Component, Input, OnInit } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; type CalloutTypes = "success" | "info" | "warning" | "danger"; @@ -16,6 +16,9 @@ const defaultI18n: Partial> = { danger: "error", }; +// Increments for each instance of this component +let nextId = 0; + @Component({ selector: "bit-callout", templateUrl: "callout.component.html", @@ -25,6 +28,7 @@ export class CalloutComponent implements OnInit { @Input() icon: string; @Input() title: string; @Input() useAlertRole = false; + protected titleId = `bit-callout-title-${nextId++}`; constructor(private i18nService: I18nService) {} diff --git a/libs/components/src/callout/callout.mdx b/libs/components/src/callout/callout.mdx new file mode 100644 index 00000000000..a40a970f895 --- /dev/null +++ b/libs/components/src/callout/callout.mdx @@ -0,0 +1,66 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./callout.stories"; + + + +# Callouts + +Callouts are used to communicate important information to the user. Callouts should be used +sparingly, as they command a large amount of visual attention. Avoid using more than 1 callout in +the same location. + +## Styles + +Icons should remain consistent across these types. Do not change the icon without consulting a +designer. Use the following guidelines to help choose the correct type of callout. + +### Success + +Use the success callout to communicate a positive messaging to the user. + +**Example:** a positive report results shows a success callout. + +The success callout may also be used for the information related to a premium membership. In this +case, replace the icon with + + + +### Info + +Use an info callout to call attention to important information the user should be aware of, but has +low risk of the user receiving and unintended or irreversible results if they do not read the +information. + +**Example:** in the Domain Claiming modal, an info callout is used to tell the user the domain will +automatically be checked. + + + +### Warning + +Use a warning callout if the user is about to perform an action that may have unintended or +irreversible results. + +**Example:** the warning callout is used before the change master password and encryption key form +to alert the user that they will be logged out. + + + +### Danger + +Use the danger callout to communicate an action the user is about to take is dangerous and typically +not reversible. + +The danger callout can also be used to alert the user of an error or errors, such as a server side +errors after form submit or failed communication request. + + + +## Accessibility + +Use the `role=”alert”` only if the callout is appearing on a page after the user takes an action. If +the content is static, do not use the alert role. This will cause a screen reader to announce the +callout content on page load. + +Ensure the title's color contrast remains WCAG compliant with the callout's background. diff --git a/libs/components/src/callout/callout.stories.ts b/libs/components/src/callout/callout.stories.ts index f8738e46b3a..cb51e96e8da 100644 --- a/libs/components/src/callout/callout.stories.ts +++ b/libs/components/src/callout/callout.stories.ts @@ -1,6 +1,6 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nMockService } from "../utils/i18n-mock.service"; diff --git a/libs/components/src/checkbox/checkbox.component.ts b/libs/components/src/checkbox/checkbox.component.ts index 8e966fa2272..5e1e17a5cac 100644 --- a/libs/components/src/checkbox/checkbox.component.ts +++ b/libs/components/src/checkbox/checkbox.component.ts @@ -44,21 +44,33 @@ export class CheckboxComponent implements BitFormControlAbstraction { "checked:tw-bg-primary-500", "checked:tw-border-primary-500", - "checked:hover:tw-bg-primary-700", "checked:hover:tw-border-primary-700", "[&>label:hover]:checked:tw-bg-primary-700", "[&>label:hover]:checked:tw-border-primary-700", - "checked:before:tw-bg-text-contrast", - "checked:before:tw-mask-image-[var(--mask-image)]", "checked:before:tw-mask-position-[center]", "checked:before:tw-mask-repeat-[no-repeat]", - "checked:disabled:tw-border-secondary-100", "checked:disabled:tw-bg-secondary-100", - "checked:disabled:before:tw-bg-text-muted", + + "[&:not(:indeterminate)]:checked:before:tw-mask-image-[var(--mask-image)]", + "indeterminate:before:tw-mask-image-[var(--indeterminate-mask-image)]", + + "indeterminate:tw-bg-primary-500", + "indeterminate:tw-border-primary-500", + "indeterminate:hover:tw-bg-primary-700", + "indeterminate:hover:tw-border-primary-700", + "[&>label:hover]:indeterminate:tw-bg-primary-700", + "[&>label:hover]:indeterminate:tw-border-primary-700", + "indeterminate:before:tw-bg-text-contrast", + "indeterminate:before:tw-mask-position-[center]", + "indeterminate:before:tw-mask-repeat-[no-repeat]", + "indeterminate:before:tw-mask-image-[var(--indeterminate-mask-image)]", + "indeterminate:disabled:tw-border-secondary-100", + "indeterminate:disabled:tw-bg-secondary-100", + "indeterminate:disabled:before:tw-bg-text-muted", ]; constructor(@Optional() @Self() private ngControl?: NgControl) {} @@ -66,6 +78,9 @@ export class CheckboxComponent implements BitFormControlAbstraction { @HostBinding("style.--mask-image") protected maskImage = `url('data:image/svg+xml,%3Csvg class="svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="8" viewBox="0 0 10 10"%3E%3Cpath d="M0.5 6.2L2.9 8.6L9.5 1.4" fill="none" stroke="white" stroke-width="2"%3E%3C/path%3E%3C/svg%3E')`; + @HostBinding("style.--indeterminate-mask-image") + protected indeterminateImage = `url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="none" viewBox="0 0 13 13"%3E%3Cpath stroke="%23fff" stroke-width="2" d="M2.5 6.5h8"/%3E%3C/svg%3E%0A')`; + @HostBinding() @Input() get disabled() { diff --git a/libs/components/src/checkbox/checkbox.stories.ts b/libs/components/src/checkbox/checkbox.stories.ts index 246e4ada7b0..11ef32eac77 100644 --- a/libs/components/src/checkbox/checkbox.stories.ts +++ b/libs/components/src/checkbox/checkbox.stories.ts @@ -2,7 +2,7 @@ import { Component, Input } from "@angular/core"; import { FormsModule, ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/src/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/src/platform/abstractions/i18n.service"; import { FormControlModule } from "../form-control"; import { I18nMockService } from "../utils/i18n-mock.service"; @@ -110,3 +110,12 @@ export const Custom: Story = { `, }), }; + +export const Indeterminate: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), +}; diff --git a/libs/components/src/color-password/color-password.component.ts b/libs/components/src/color-password/color-password.component.ts index 04172bfa87d..4c32d0af0d1 100644 --- a/libs/components/src/color-password/color-password.component.ts +++ b/libs/components/src/color-password/color-password.component.ts @@ -1,6 +1,6 @@ import { Component, HostBinding, Input } from "@angular/core"; -import { Utils } from "@bitwarden/common/misc/utils"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; enum CharacterType { Letter, diff --git a/libs/components/src/color-password/color-password.mdx b/libs/components/src/color-password/color-password.mdx new file mode 100644 index 00000000000..d01c81b0073 --- /dev/null +++ b/libs/components/src/color-password/color-password.mdx @@ -0,0 +1,34 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./color-password.stories"; + + + +# Color password + +The color password is used primarily in the Generator pages and in the Login type form. It includes +the logic for displaying letters as `text-main`, numbers as `primary`, and special symbols as +`danger`. + + + + +## Password Count + +The password count option is used in the Login type form. It is used to highlight each character's +position in the password string. + + + +## Wrapped Password + +When the password length is longer than the container's width, it should wrap as shown below. + + + + + +## Accessibility + +The colors used in the colored password should maintain WCAG compliant contrast with theme +`background` and `background-alt` colors. diff --git a/libs/components/src/dialog/dialog.module.ts b/libs/components/src/dialog/dialog.module.ts index d02b4c06492..a4e119bcc29 100644 --- a/libs/components/src/dialog/dialog.module.ts +++ b/libs/components/src/dialog/dialog.module.ts @@ -1,8 +1,8 @@ import { DialogModule as CdkDialogModule } from "@angular/cdk/dialog"; import { NgModule } from "@angular/core"; +import { ReactiveFormsModule } from "@angular/forms"; -import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog"; - +import { AsyncActionsModule } from "../async-actions"; import { ButtonModule } from "../button"; import { IconButtonModule } from "../icon-button"; import { SharedModule } from "../shared"; @@ -11,11 +11,18 @@ import { DialogComponent } from "./dialog/dialog.component"; import { DialogService } from "./dialog.service"; import { DialogCloseDirective } from "./directives/dialog-close.directive"; import { DialogTitleContainerDirective } from "./directives/dialog-title-container.directive"; -import { SimpleConfigurableDialogComponent } from "./simple-configurable-dialog/simple-configurable-dialog.component"; +import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component"; import { IconDirective, SimpleDialogComponent } from "./simple-dialog/simple-dialog.component"; @NgModule({ - imports: [SharedModule, IconButtonModule, CdkDialogModule, ButtonModule], + imports: [ + SharedModule, + AsyncActionsModule, + ButtonModule, + CdkDialogModule, + IconButtonModule, + ReactiveFormsModule, + ], declarations: [ DialogCloseDirective, DialogTitleContainerDirective, @@ -31,11 +38,6 @@ import { IconDirective, SimpleDialogComponent } from "./simple-dialog/simple-dia DialogCloseDirective, IconDirective, ], - providers: [ - { - provide: DialogServiceAbstraction, - useClass: DialogService, - }, - ], + providers: [DialogService], }) export class DialogModule {} diff --git a/libs/components/src/dialog/dialog.service.stories.ts b/libs/components/src/dialog/dialog.service.stories.ts index c189b28bc2a..021e36c6c7e 100644 --- a/libs/components/src/dialog/dialog.service.stories.ts +++ b/libs/components/src/dialog/dialog.service.stories.ts @@ -2,7 +2,7 @@ import { DIALOG_DATA, DialogModule, DialogRef } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonModule } from "../button"; import { IconButtonModule } from "../icon-button"; diff --git a/libs/components/src/dialog/dialog.service.ts b/libs/components/src/dialog/dialog.service.ts index 0f58c1c847f..27930189bfc 100644 --- a/libs/components/src/dialog/dialog.service.ts +++ b/libs/components/src/dialog/dialog.service.ts @@ -18,19 +18,15 @@ import { import { NavigationEnd, Router } from "@angular/router"; import { filter, firstValueFrom, Subject, switchMap, takeUntil } from "rxjs"; -import { - DialogServiceAbstraction, - SimpleDialogCloseType, -} from "@bitwarden/angular/services/dialog"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SimpleDialogOptions } from "../../../angular/src/services/dialog/simple-dialog-options"; - -import { SimpleConfigurableDialogComponent } from "./simple-configurable-dialog/simple-configurable-dialog.component"; +import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component"; +import { SimpleDialogOptions, Translation } from "./simple-dialog/types"; @Injectable() -export class DialogService extends Dialog implements OnDestroy, DialogServiceAbstraction { +export class DialogService extends Dialog implements OnDestroy { private _destroy$ = new Subject(); private backDropClasses = ["tw-fixed", "tw-bg-black", "tw-bg-opacity-30", "tw-inset-0"]; @@ -46,7 +42,9 @@ export class DialogService extends Dialog implements OnDestroy, DialogServiceAbs /** Not in parent class */ @Optional() router: Router, - @Optional() authService: AuthService + @Optional() authService: AuthService, + + protected i18nService: I18nService ) { super(_overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, scrollStrategy); @@ -88,12 +86,12 @@ export class DialogService extends Dialog implements OnDestroy, DialogServiceAbs * @returns `boolean` - True if the user accepted the dialog, false otherwise. */ async openSimpleDialog(simpleDialogOptions: SimpleDialogOptions): Promise { - const dialogRef = this.open(SimpleConfigurableDialogComponent, { + const dialogRef = this.open(SimpleConfigurableDialogComponent, { data: simpleDialogOptions, disableClose: simpleDialogOptions.disableClose, }); - return (await firstValueFrom(dialogRef.closed)) == SimpleDialogCloseType.ACCEPT; + return firstValueFrom(dialogRef.closed); } /** @@ -105,7 +103,7 @@ export class DialogService extends Dialog implements OnDestroy, DialogServiceAbs * @param {SimpleDialogOptions} simpleDialogOptions - An object containing options for the dialog. * @returns `DialogRef` - The reference to the opened dialog. * Contains a closed observable which can be subscribed to for determining which button - * a user pressed (see `SimpleDialogCloseType`) + * a user pressed */ openSimpleDialogRef(simpleDialogOptions: SimpleDialogOptions): DialogRef { return this.open(SimpleConfigurableDialogComponent, { @@ -113,4 +111,21 @@ export class DialogService extends Dialog implements OnDestroy, DialogServiceAbs disableClose: simpleDialogOptions.disableClose, }); } + + protected translate(translation: string | Translation, defaultKey?: string): string { + if (translation == null && defaultKey == null) { + return null; + } + + if (translation == null) { + return this.i18nService.t(defaultKey); + } + + // Translation interface use implies we must localize. + if (typeof translation === "object") { + return this.i18nService.t(translation.key, ...(translation.placeholders ?? [])); + } + + return translation; + } } diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index 773b764b22e..b052cc23b66 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -6,7 +6,7 @@
    -

    +

    - - - - diff --git a/libs/components/src/dialog/simple-configurable-dialog/simple-configurable-dialog.service.stories.ts b/libs/components/src/dialog/simple-configurable-dialog/simple-configurable-dialog.service.stories.ts deleted file mode 100644 index f8910fdb8f1..00000000000 --- a/libs/components/src/dialog/simple-configurable-dialog/simple-configurable-dialog.service.stories.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { DialogModule } from "@angular/cdk/dialog"; -import { Component } from "@angular/core"; -import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; - -import { SimpleDialogType, SimpleDialogOptions } from "@bitwarden/angular/services/dialog"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; - -import { ButtonModule } from "../../button"; -import { CalloutModule } from "../../callout"; -import { IconButtonModule } from "../../icon-button"; -import { SharedModule } from "../../shared/shared.module"; -import { I18nMockService } from "../../utils/i18n-mock.service"; -import { DialogService } from "../dialog.service"; -import { DialogCloseDirective } from "../directives/dialog-close.directive"; -import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive"; -import { SimpleDialogComponent } from "../simple-dialog/simple-dialog.component"; - -@Component({ - template: ` -

    Dialog Type Examples:

    -
    - - - - - - - - - -
    - -

    Custom Button Examples:

    -
    - - - - - -
    - -

    Custom Icon Example:

    -
    - -
    - -

    Additional Examples:

    -
    - -
    - - - {{ dialogCloseResult }} - - `, -}) -class StoryDialogComponent { - primaryLocalizedSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("primaryTypeSimpleDialog"), - content: this.i18nService.t("dialogContent"), - type: SimpleDialogType.PRIMARY, - }; - - successLocalizedSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("successTypeSimpleDialog"), - content: this.i18nService.t("dialogContent"), - type: SimpleDialogType.SUCCESS, - }; - - infoLocalizedSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("infoTypeSimpleDialog"), - content: this.i18nService.t("dialogContent"), - type: SimpleDialogType.INFO, - }; - - warningLocalizedSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("warningTypeSimpleDialog"), - content: this.i18nService.t("dialogContent"), - type: SimpleDialogType.WARNING, - }; - - dangerLocalizedSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("dangerTypeSimpleDialog"), - content: this.i18nService.t("dialogContent"), - type: SimpleDialogType.DANGER, - }; - - primarySingleBtnSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("primaryTypeSimpleDialog"), - content: this.i18nService.t("dialogContent"), - type: SimpleDialogType.PRIMARY, - acceptButtonText: "Ok", - cancelButtonText: null, - }; - - primaryCustomBtnsSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("primaryTypeSimpleDialog"), - content: this.i18nService.t("dialogContent"), - type: SimpleDialogType.PRIMARY, - acceptButtonText: this.i18nService.t("accept"), - cancelButtonText: this.i18nService.t("decline"), - }; - - primaryAcceptBtnOverrideSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("primaryTypeSimpleDialog"), - content: this.i18nService.t("dialogContent"), - type: SimpleDialogType.PRIMARY, - acceptButtonText: "Ok", - }; - - primaryCustomIconSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("primaryTypeSimpleDialog"), - content: this.i18nService.t("dialogContent"), - type: SimpleDialogType.PRIMARY, - icon: "bwi-family", - }; - - primaryDisableCloseSimpleDialogOpts: SimpleDialogOptions = { - title: this.i18nService.t("primaryTypeSimpleDialog"), - content: this.i18nService.t("dialogContent"), - type: SimpleDialogType.PRIMARY, - disableClose: true, - }; - - showCallout = false; - calloutType = "info"; - dialogCloseResult: boolean; - - constructor(public dialogService: DialogService, private i18nService: I18nService) {} - - async openSimpleConfigurableDialog(opts: SimpleDialogOptions) { - this.dialogCloseResult = await this.dialogService.openSimpleDialog(opts); - - this.showCallout = true; - if (this.dialogCloseResult) { - this.calloutType = "success"; - } else { - this.calloutType = "info"; - } - } -} - -export default { - title: "Component Library/Dialogs/Service/SimpleConfigurable", - component: StoryDialogComponent, - decorators: [ - moduleMetadata({ - declarations: [DialogCloseDirective, DialogTitleContainerDirective, SimpleDialogComponent], - imports: [SharedModule, IconButtonModule, ButtonModule, DialogModule, CalloutModule], - providers: [ - DialogService, - { - provide: I18nService, - useFactory: () => { - return new I18nMockService({ - primaryTypeSimpleDialog: "Primary Type Simple Dialog", - successTypeSimpleDialog: "Success Type Simple Dialog", - infoTypeSimpleDialog: "Info Type Simple Dialog", - warningTypeSimpleDialog: "Warning Type Simple Dialog", - dangerTypeSimpleDialog: "Danger Type Simple Dialog", - dialogContent: "Dialog content goes here", - yes: "Yes", - no: "No", - ok: "Ok", - cancel: "Cancel", - accept: "Accept", - decline: "Decline", - }); - }, - }, - ], - }), - ], - parameters: { - design: { - type: "figma", - url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library", - }, - }, -} as Meta; - -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html new file mode 100644 index 00000000000..cb916d8dba2 --- /dev/null +++ b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.html @@ -0,0 +1,26 @@ + + + + + {{ title }} + +
    {{ content }}
    + + + + + + +
    + diff --git a/libs/components/src/dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts similarity index 62% rename from libs/components/src/dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts rename to libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts index 3a627970dda..f9acdbe8c6e 100644 --- a/libs/components/src/dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts +++ b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component.ts @@ -1,37 +1,31 @@ import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; +import { FormGroup } from "@angular/forms"; -import { - SimpleDialogType, - SimpleDialogCloseType, - Translation, -} from "@bitwarden/angular/services/dialog"; -import { SimpleDialogOptions } from "@bitwarden/angular/services/dialog/simple-dialog-options"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { SimpleDialogOptions, SimpleDialogType, Translation } from "../.."; const DEFAULT_ICON: Record = { - [SimpleDialogType.PRIMARY]: "bwi-business", - [SimpleDialogType.SUCCESS]: "bwi-star", - [SimpleDialogType.INFO]: "bwi-info-circle", - [SimpleDialogType.WARNING]: "bwi-exclamation-triangle", - [SimpleDialogType.DANGER]: "bwi-error", + primary: "bwi-business", + success: "bwi-star", + info: "bwi-info-circle", + warning: "bwi-exclamation-triangle", + danger: "bwi-error", }; const DEFAULT_COLOR: Record = { - [SimpleDialogType.PRIMARY]: "tw-text-primary-500", - [SimpleDialogType.SUCCESS]: "tw-text-success", - [SimpleDialogType.INFO]: "tw-text-info", - [SimpleDialogType.WARNING]: "tw-text-warning", - [SimpleDialogType.DANGER]: "tw-text-danger", + primary: "tw-text-primary-500", + success: "tw-text-success", + info: "tw-text-info", + warning: "tw-text-warning", + danger: "tw-text-danger", }; @Component({ templateUrl: "./simple-configurable-dialog.component.html", }) export class SimpleConfigurableDialogComponent { - SimpleDialogType = SimpleDialogType; - SimpleDialogCloseType = SimpleDialogCloseType; - get iconClasses() { return [ this.simpleDialogOpts.icon ?? DEFAULT_ICON[this.simpleDialogOpts.type], @@ -39,12 +33,13 @@ export class SimpleConfigurableDialogComponent { ]; } - title: string; - content: string; - acceptButtonText: string; - cancelButtonText: string; + protected title: string; + protected content: string; + protected acceptButtonText: string; + protected cancelButtonText: string; + protected formGroup = new FormGroup({}); - showCancelButton = this.simpleDialogOpts.cancelButtonText !== null; + protected showCancelButton = this.simpleDialogOpts.cancelButtonText !== null; constructor( public dialogRef: DialogRef, @@ -54,6 +49,14 @@ export class SimpleConfigurableDialogComponent { this.localizeText(); } + protected accept = async () => { + if (this.simpleDialogOpts.acceptAction) { + await this.simpleDialogOpts.acceptAction(); + } + + this.dialogRef.close(true); + }; + private localizeText() { this.title = this.translate(this.simpleDialogOpts.title); this.content = this.translate(this.simpleDialogOpts.content); diff --git a/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.service.stories.ts b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.service.stories.ts new file mode 100644 index 00000000000..81400537be5 --- /dev/null +++ b/libs/components/src/dialog/simple-dialog/simple-configurable-dialog/simple-configurable-dialog.service.stories.ts @@ -0,0 +1,181 @@ +import { Component } from "@angular/core"; +import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { SimpleDialogOptions, DialogService } from "../.."; +import { ButtonModule } from "../../../button"; +import { CalloutModule } from "../../../callout"; +import { I18nMockService } from "../../../utils/i18n-mock.service"; +import { DialogModule } from "../../dialog.module"; + +@Component({ + template: ` +
    +

    {{ group.title }}

    +
    + +
    +
    + + + {{ dialogCloseResult }} + + `, +}) +class StoryDialogComponent { + protected dialogs: { title: string; dialogs: SimpleDialogOptions[] }[] = [ + { + title: "Regular", + dialogs: [ + { + title: this.i18nService.t("primaryTypeSimpleDialog"), + content: this.i18nService.t("dialogContent"), + type: "primary", + }, + { + title: this.i18nService.t("successTypeSimpleDialog"), + content: this.i18nService.t("dialogContent"), + type: "success", + }, + { + title: this.i18nService.t("infoTypeSimpleDialog"), + content: this.i18nService.t("dialogContent"), + type: "info", + }, + { + title: this.i18nService.t("warningTypeSimpleDialog"), + content: this.i18nService.t("dialogContent"), + type: "warning", + }, + { + title: this.i18nService.t("dangerTypeSimpleDialog"), + content: this.i18nService.t("dialogContent"), + type: "danger", + }, + ], + }, + { + title: "Custom", + dialogs: [ + { + title: this.i18nService.t("primaryTypeSimpleDialog"), + content: this.i18nService.t("dialogContent"), + type: "primary", + acceptButtonText: "Ok", + cancelButtonText: null, + }, + { + title: this.i18nService.t("primaryTypeSimpleDialog"), + content: this.i18nService.t("dialogContent"), + type: "primary", + acceptButtonText: this.i18nService.t("accept"), + cancelButtonText: this.i18nService.t("decline"), + }, + { + title: this.i18nService.t("primaryTypeSimpleDialog"), + content: this.i18nService.t("dialogContent"), + type: "primary", + acceptButtonText: "Ok", + }, + ], + }, + { + title: "Icon", + dialogs: [ + { + title: this.i18nService.t("primaryTypeSimpleDialog"), + content: this.i18nService.t("dialogContent"), + type: "primary", + icon: "bwi-family", + }, + ], + }, + { + title: "Additional", + dialogs: [ + { + title: this.i18nService.t("primaryTypeSimpleDialog"), + content: this.i18nService.t("dialogContent"), + type: "primary", + disableClose: true, + }, + { + title: this.i18nService.t("asyncTypeSimpleDialog"), + content: this.i18nService.t("dialogContent"), + acceptAction: () => { + return new Promise((resolve) => setTimeout(resolve, 10000)); + }, + type: "primary", + }, + ], + }, + ]; + + showCallout = false; + calloutType = "info"; + dialogCloseResult: boolean; + + constructor(public dialogService: DialogService, private i18nService: I18nService) {} + + async openSimpleConfigurableDialog(opts: SimpleDialogOptions) { + this.dialogCloseResult = await this.dialogService.openSimpleDialog(opts); + + this.showCallout = true; + if (this.dialogCloseResult) { + this.calloutType = "success"; + } else { + this.calloutType = "info"; + } + } +} + +export default { + title: "Component Library/Dialogs/Service/SimpleConfigurable", + component: StoryDialogComponent, + decorators: [ + moduleMetadata({ + imports: [ButtonModule, DialogModule, CalloutModule], + }), + applicationConfig({ + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + primaryTypeSimpleDialog: "Primary Type Simple Dialog", + successTypeSimpleDialog: "Success Type Simple Dialog", + infoTypeSimpleDialog: "Info Type Simple Dialog", + warningTypeSimpleDialog: "Warning Type Simple Dialog", + dangerTypeSimpleDialog: "Danger Type Simple Dialog", + asyncTypeSimpleDialog: "Async", + dialogContent: "Dialog content goes here", + yes: "Yes", + no: "No", + ok: "Ok", + cancel: "Cancel", + accept: "Accept", + decline: "Decline", + }); + }, + }, + ], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/libs/components/src/dialog/simple-dialog/simple-dialog.mdx b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx new file mode 100644 index 00000000000..5e45b7bef6e --- /dev/null +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.mdx @@ -0,0 +1,47 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./simple-dialog.stories"; + + + +# Simple Dialogs + +Simple Dialogs are used throughout the app for simple alert or confirmation actions such as +speedbumps. + +For dialogs with a high number of interactive elements such as a form or content exceeding 384px, +use the [Dialog component](?path=/docs/component-library-dialogs-dialog--docs). + + + + +## Configurable Simple Dialog + +The Simple Dialog contains the following configuration points: + +- `title`: string +- `content`: string +- `type`: SimpleDialogType +- `icon`: string – if empty, infer from type +- `acceptButtonText`: string – if empty, default to "Yes" +- `cancelButtonText`: string – if empty, default to "No", unless acceptButtonText is overridden, in + which case default to "Cancel" + +To increase consistency, the simple dialog service supports some automation for setting the `icon` +and `color` based on the defined type. See the following for how properties will be configured when +the simple dialog's type is specified. + +| type | icon name | icon | color | +| ------- | ------------------------ | -------------------------------------------- | ----------- | +| primary | bwi-business | | primary-500 | +| success | bwi-star | | success-500 | +| info | bwi-info-circle | | info-500 | +| warning | bwi-exclamation-triangle | | warning-500 | +| danger | bwi-error | | danger-500 | + +## Scrolling Content + +Simple dialogs can support scrolling content if necessary, but typically with larger quantities of +content a [Dialog component](?path=/docs/component-library-dialogs-dialog--docs). + + diff --git a/libs/components/src/dialog/simple-dialog.service.stories.ts b/libs/components/src/dialog/simple-dialog/simple-dialog.service.stories.ts similarity index 77% rename from libs/components/src/dialog/simple-dialog.service.stories.ts rename to libs/components/src/dialog/simple-dialog/simple-dialog.service.stories.ts index e4d60c96de0..b6615184ecb 100644 --- a/libs/components/src/dialog/simple-dialog.service.stories.ts +++ b/libs/components/src/dialog/simple-dialog/simple-dialog.service.stories.ts @@ -2,17 +2,17 @@ import { DialogModule, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; import { Component, Inject } from "@angular/core"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { ButtonModule } from "../button"; -import { IconButtonModule } from "../icon-button"; -import { SharedModule } from "../shared/shared.module"; -import { I18nMockService } from "../utils/i18n-mock.service"; +import { ButtonModule } from "../../button"; +import { IconButtonModule } from "../../icon-button"; +import { SharedModule } from "../../shared/shared.module"; +import { I18nMockService } from "../../utils/i18n-mock.service"; +import { DialogService } from "../dialog.service"; +import { DialogCloseDirective } from "../directives/dialog-close.directive"; +import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive"; -import { DialogService } from "./dialog.service"; -import { DialogCloseDirective } from "./directives/dialog-close.directive"; -import { DialogTitleContainerDirective } from "./directives/dialog-title-container.directive"; -import { SimpleDialogComponent } from "./simple-dialog/simple-dialog.component"; +import { SimpleDialogComponent } from "./simple-dialog.component"; interface Animal { animal: string; diff --git a/libs/angular/src/services/dialog/simple-dialog-options.ts b/libs/components/src/dialog/simple-dialog/types.ts similarity index 79% rename from libs/angular/src/services/dialog/simple-dialog-options.ts rename to libs/components/src/dialog/simple-dialog/types.ts index c56e5b4703c..4032b499cbe 100644 --- a/libs/angular/src/services/dialog/simple-dialog-options.ts +++ b/libs/components/src/dialog/simple-dialog/types.ts @@ -1,5 +1,7 @@ -import { SimpleDialogType } from "./simple-dialog-type.enum"; -import { Translation } from "./translation"; +export interface Translation { + key: string; + placeholders?: Array; +} // Using type lets devs skip optional params w/out having to pass undefined. /** @@ -48,4 +50,12 @@ export type SimpleDialogOptions = { /** Whether or not the user can use escape or clicking the backdrop to close the dialog */ disableClose?: boolean; + + /** + * Custom accept action. Runs when the user clicks the accept button and shows a loading spinner until the promise + * is resolved. + */ + acceptAction?: () => Promise; }; + +export type SimpleDialogType = "primary" | "success" | "info" | "warning" | "danger"; diff --git a/libs/components/src/form-control/form-control.component.ts b/libs/components/src/form-control/form-control.component.ts index ac8971e61d6..934622a4a2a 100644 --- a/libs/components/src/form-control/form-control.component.ts +++ b/libs/components/src/form-control/form-control.component.ts @@ -1,7 +1,7 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { Component, ContentChild, HostBinding, Input } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { BitFormControlAbstraction } from "./form-control.abstraction"; diff --git a/libs/components/src/form-field/bit-validators.stories.ts b/libs/components/src/form-field/bit-validators.stories.ts index 9e717a3510f..1007a56f5e4 100644 --- a/libs/components/src/form-field/bit-validators.stories.ts +++ b/libs/components/src/form-field/bit-validators.stories.ts @@ -1,13 +1,14 @@ import { FormsModule, ReactiveFormsModule, FormBuilder } from "@angular/forms"; import { StoryObj, Meta, moduleMetadata } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonModule } from "../button"; import { InputModule } from "../input/input.module"; import { I18nMockService } from "../utils/i18n-mock.service"; import { forbiddenCharacters } from "./bit-validators/forbidden-characters.validator"; +import { trimValidator } from "./bit-validators/trim.validator"; import { BitFormFieldComponent } from "./form-field.component"; import { FormFieldModule } from "./form-field.module"; @@ -24,6 +25,7 @@ export default { return new I18nMockService({ inputForbiddenCharacters: (chars) => `The following characters are not allowed: ${chars}`, + inputTrimValidator: "Input must not contain only whitespace.", }); }, }, @@ -56,3 +58,20 @@ export const ForbiddenCharacters: StoryObj = { template, }), }; + +export const TrimValidator: StoryObj = { + render: (args: BitFormFieldComponent) => ({ + props: { + formObj: new FormBuilder().group({ + name: [ + "", + { + updateOn: "submit", + validators: [trimValidator], + }, + ], + }), + }, + template, + }), +}; diff --git a/libs/components/src/form-field/bit-validators/index.ts b/libs/components/src/form-field/bit-validators/index.ts index 971649450ca..d70473962d3 100644 --- a/libs/components/src/form-field/bit-validators/index.ts +++ b/libs/components/src/form-field/bit-validators/index.ts @@ -1 +1,2 @@ export { forbiddenCharacters } from "./forbidden-characters.validator"; +export { trimValidator } from "./trim.validator"; diff --git a/libs/components/src/form-field/bit-validators/trim.validator.spec.ts b/libs/components/src/form-field/bit-validators/trim.validator.spec.ts new file mode 100644 index 00000000000..471f5396786 --- /dev/null +++ b/libs/components/src/form-field/bit-validators/trim.validator.spec.ts @@ -0,0 +1,61 @@ +import { FormControl } from "@angular/forms"; + +import { trimValidator as validate } from "./trim.validator"; + +describe("trimValidator", () => { + it("should not error when input is null", () => { + const input = createControl(null); + const errors = validate(input); + + expect(errors).toBe(null); + }); + + it("should not error when input is an empty string", () => { + const input = createControl(""); + const errors = validate(input); + + expect(errors).toBe(null); + }); + + it("should not error when input has no whitespace", () => { + const input = createControl("test value"); + const errors = validate(input); + + expect(errors).toBe(null); + }); + + it("should remove beginning whitespace", () => { + const input = createControl(" test value"); + const errors = validate(input); + + expect(errors).toBe(null); + expect(input.value).toBe("test value"); + }); + + it("should remove trailing whitespace", () => { + const input = createControl("test value "); + const errors = validate(input); + + expect(errors).toBe(null); + expect(input.value).toBe("test value"); + }); + + it("should remove beginning and trailing whitespace", () => { + const input = createControl(" test value "); + const errors = validate(input); + + expect(errors).toBe(null); + expect(input.value).toBe("test value"); + }); + + it("should error when input is just whitespace", () => { + const input = createControl(" "); + const errors = validate(input); + + expect(errors).toEqual({ trim: { message: "input is only whitespace" } }); + }); +}); + +function createControl(input: string) { + return new FormControl(input); +} diff --git a/libs/components/src/form-field/bit-validators/trim.validator.ts b/libs/components/src/form-field/bit-validators/trim.validator.ts new file mode 100644 index 00000000000..2256c19c000 --- /dev/null +++ b/libs/components/src/form-field/bit-validators/trim.validator.ts @@ -0,0 +1,27 @@ +import { AbstractControl, FormControl, ValidatorFn } from "@angular/forms"; + +/** + * Automatically trims FormControl value. Errors if value only contains whitespace. + * + * Should be used with `updateOn: "submit"` + */ +export const trimValidator: ValidatorFn = (control: AbstractControl) => { + if (!(control instanceof FormControl)) { + throw new Error("trimValidator only supports validating FormControls"); + } + const value = control.value; + if (value === null || value === undefined || value === "") { + return null; + } + if (!value.trim().length) { + return { + trim: { + message: "input is only whitespace", + }, + }; + } + if (value !== value.trim()) { + control.setValue(value.trim()); + } + return null; +}; diff --git a/libs/components/src/form-field/error-summary.stories.ts b/libs/components/src/form-field/error-summary.stories.ts index 16bfd99ac45..4e1031abaf6 100644 --- a/libs/components/src/form-field/error-summary.stories.ts +++ b/libs/components/src/form-field/error-summary.stories.ts @@ -1,7 +1,7 @@ import { UntypedFormBuilder, FormsModule, ReactiveFormsModule, Validators } from "@angular/forms"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonModule } from "../button"; import { InputModule } from "../input/input.module"; @@ -24,7 +24,7 @@ export default { required: "required", inputRequired: "Input is required.", inputEmail: "Input is not an email-address.", - fieldsNeedAttention: "$COUNT$ field(s) above need your attention.", + fieldsNeedAttention: "__$1__ field(s) above need your attention.", }); }, }, @@ -63,12 +63,12 @@ export const Default: StoryObj = { Name - + Email - + diff --git a/libs/components/src/form-field/error.component.ts b/libs/components/src/form-field/error.component.ts index 0686987404a..39005903e6d 100644 --- a/libs/components/src/form-field/error.component.ts +++ b/libs/components/src/form-field/error.component.ts @@ -1,6 +1,6 @@ import { Component, HostBinding, Input } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; // Increments for each instance of this component let nextId = 0; @@ -38,6 +38,8 @@ export class BitErrorComponent { return this.i18nService.t("inputForbiddenCharacters", this.error[1]?.characters.join(", ")); case "multipleEmails": return this.i18nService.t("multipleInputEmails"); + case "trim": + return this.i18nService.t("inputTrimValidator"); default: // Attempt to show a custom error message. if (this.error[1]?.message) { diff --git a/libs/components/src/form-field/form-field-control.ts b/libs/components/src/form-field/form-field-control.ts index e510b4570f8..4fc0e522397 100644 --- a/libs/components/src/form-field/form-field-control.ts +++ b/libs/components/src/form-field/form-field-control.ts @@ -6,7 +6,9 @@ export type InputTypes = | "email" | "checkbox" | "search" - | "file"; + | "file" + | "date" + | "time"; export abstract class BitFormFieldControl { ariaDescribedBy: string; diff --git a/libs/components/src/form-field/form-field.component.ts b/libs/components/src/form-field/form-field.component.ts index 9553641188e..6fcb4090ddd 100644 --- a/libs/components/src/form-field/form-field.component.ts +++ b/libs/components/src/form-field/form-field.component.ts @@ -1,8 +1,11 @@ +import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { AfterContentChecked, Component, ContentChild, ContentChildren, + HostBinding, + Input, QueryList, ViewChild, } from "@angular/core"; @@ -17,9 +20,6 @@ import { BitSuffixDirective } from "./suffix.directive"; @Component({ selector: "bit-form-field", templateUrl: "./form-field.component.html", - host: { - class: "tw-mb-6 tw-block", - }, }) export class BitFormFieldComponent implements AfterContentChecked { @ContentChild(BitFormFieldControl) input: BitFormFieldControl; @@ -30,6 +30,19 @@ export class BitFormFieldComponent implements AfterContentChecked { @ContentChildren(BitPrefixDirective) prefixChildren: QueryList; @ContentChildren(BitSuffixDirective) suffixChildren: QueryList; + private _disableMargin = false; + @Input() set disableMargin(value: boolean | "") { + this._disableMargin = coerceBooleanProperty(value); + } + get disableMargin() { + return this._disableMargin; + } + + @HostBinding("class") + get classList() { + return ["tw-block"].concat(this.disableMargin ? [] : ["tw-mb-6"]); + } + ngAfterContentChecked(): void { if (this.error) { this.input.ariaDescribedBy = this.error.id; diff --git a/libs/components/src/form-field/form-field.stories.ts b/libs/components/src/form-field/form-field.stories.ts index c8c520bb817..305b514266e 100644 --- a/libs/components/src/form-field/form-field.stories.ts +++ b/libs/components/src/form-field/form-field.stories.ts @@ -9,7 +9,7 @@ import { } from "@angular/forms"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { AsyncActionsModule } from "../async-actions"; import { ButtonModule } from "../button"; @@ -157,6 +157,24 @@ export const Disabled: Story = { args: {}, }; +export const Readonly: Story = { + render: (args) => ({ + props: args, + template: ` + + Input + + + + + Textarea + + + `, + }), + args: {}, +}; + export const InputGroup: Story = { render: (args) => ({ props: args, diff --git a/libs/components/src/form-field/multi-select.stories.ts b/libs/components/src/form-field/multi-select.stories.ts index 123f6602aec..9248f7ab473 100644 --- a/libs/components/src/form-field/multi-select.stories.ts +++ b/libs/components/src/form-field/multi-select.stories.ts @@ -9,7 +9,7 @@ import { NgSelectModule } from "@ng-select/ng-select"; import { action } from "@storybook/addon-actions"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { BadgeModule } from "../badge"; import { ButtonModule } from "../button"; diff --git a/libs/components/src/form-field/password-input-toggle.directive.ts b/libs/components/src/form-field/password-input-toggle.directive.ts index 3a3e3f116f6..bd029effcb8 100644 --- a/libs/components/src/form-field/password-input-toggle.directive.ts +++ b/libs/components/src/form-field/password-input-toggle.directive.ts @@ -10,7 +10,7 @@ import { Output, } from "@angular/core"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { BitIconButtonComponent } from "../icon-button/icon-button.component"; diff --git a/libs/components/src/form-field/password-input-toggle.spec.ts b/libs/components/src/form-field/password-input-toggle.spec.ts index 8c4976e8336..a3956e930ad 100644 --- a/libs/components/src/form-field/password-input-toggle.spec.ts +++ b/libs/components/src/form-field/password-input-toggle.spec.ts @@ -2,7 +2,7 @@ import { Component, DebugElement } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { IconButtonModule } from "../icon-button"; import { BitIconButtonComponent } from "../icon-button/icon-button.component"; diff --git a/libs/components/src/form-field/password-input-toggle.stories.ts b/libs/components/src/form-field/password-input-toggle.stories.ts index a1e916b5e10..412213110f8 100644 --- a/libs/components/src/form-field/password-input-toggle.stories.ts +++ b/libs/components/src/form-field/password-input-toggle.stories.ts @@ -1,7 +1,7 @@ import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { IconButtonModule } from "../icon-button"; import { InputModule } from "../input/input.module"; diff --git a/libs/components/src/form/form.stories.ts b/libs/components/src/form/form.stories.ts index 4c0b05a083a..cc4e9b62cf1 100644 --- a/libs/components/src/form/form.stories.ts +++ b/libs/components/src/form/form.stories.ts @@ -9,7 +9,7 @@ import { } from "@angular/forms"; import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; -import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonModule } from "../button"; import { CheckboxModule } from "../checkbox"; diff --git a/libs/components/src/form/forms.mdx b/libs/components/src/form/forms.mdx new file mode 100644 index 00000000000..08451565611 --- /dev/null +++ b/libs/components/src/form/forms.mdx @@ -0,0 +1,178 @@ +import { Meta, Story, Source } from "@storybook/addon-docs"; + + + +# Forms + +Component Library forms should always be built using [Angular Reactive Forms][reactive]. Please read +[ADR-0001][adr-0001] for a background to this decision. In practice this means that forms should +always use the native `form` element and bind a `formGroup`. + + + + + +## Form spacing and sections + +Forms consists of 1 or more inputs, and ends with 1 or 2 buttons. + +If there are many inputs in a form, they should should be organized into sections as content +relates. **Example:** Item type form + +Each input within a section should follow the following spacing guidelines (see +[Tailwind CSS spacing documentation](https://tailwindcss.com/docs/customizing-spacing)): + +- 1.5rem of vertical spacing between form elements: `mb-6` +- 1.5rem of horizontal spacing between form elements: `mr-6` +- 3rem of vertical spacing below a form section: `mb-12` +- 1rem of vertical spacing between a form group divider and the group's title; so title tag has: + `my-4` +- Form section titles should be styled using `text-lg` +- Each form sections may have a single column, double or triple column layout. No form should have + more than 3 columns. Do NOT use different column layouts within the same form section. Choose the + best layout based on the number of fields and type of fields included. + +## Input Types + +### Field + +A form field is the most common input in a form. It consists of a label, control and an optional +hint. + +The styling of form fields applies to all field types: `text`, `number`, `select`, `text-area`, +`date`, etc. + +Be sure to use an appropriate type attribute on fields when defining new field components (e.g. +`email` for email address or `number` for numerical information) to take advantage of newer input +controls like email verification, number selection, and more. + +#### Default with required attribute + + + +#### Password Toggle + + + +#### Search + + + +### Selects + +#### Searchable single select (default) + + + +#### Multi-select + + + +### Radio group + +Radio buttons should always be in radio groups. + +Radio groups are form fields that consists of a main label and multiple radio buttons. Each radio +button consists of a label and a radio input. + +The full form control + label should be selectable to allow the user a larger click target. + +Radio groups should always have a default selected value. + +Radio groups may optionally include extra helper text below each radio button. + +If a radio group has more than 4 options and the options do not need helper text, a +[select menu](?path=/docs/component-library-form-multi-select--docs) should be used instead. Avoid +using a radio group for more than 5 options even if the options require additional explanation text. + +`TODO: extend the select component to support a dropdown menu with descriptions below each option` + +#### Block + + + +#### Inline + + + +[reactive]: https://angular.io/guide/reactive-forms +[adr-0001]: https://contributing.bitwarden.com/architecture/adr/reactive-forms + +### Checkbox + +The checkbox input is used to toggle an action on/off. + +Checkboxes can be displayed on their own or in a group (select multiple form question). When +displayed in a group, include an input Label and any associated required/validation logic for the +field. + +Unlike radio groups, checkbox groups are not required to have a default selected value. + +Checkbox groups can include extra explanation text below each radio button or just the checkbox +button itself. + +If a checkbox group has more than 4 options a +[multi-select components](?path=/docs/component-library-form-multi-select--docs) should be used. + +#### Single checkbox + + + +## Accessibility + +### Required Fields + +- Use "(required)" in the label of each required form field styled the same as the field's helper + text (`.muted-text`). +- If whether or not a form field is required depends on another field, add this to the field's + helper text. + - **Example:** "Billing Email is required if owned by a business". + +### Form Field Errors + +- When a resting field is filled out, validation is triggered when the user de-focuses the field + (`onblur`). If the control is invalid, assistive technology should announce the error (consider + using `role="alert"` or an `aria-live="assertive"`). +- Validation should not be triggered if the control is left untouched; this allows a user of + assistive technology to read the entire form if they wish without triggering validation that could + interrupt them. - **TODO:** research how we might implement this behavior; as previous research + has shown Angular may not allow both validation when `dirty` `onblur` AND validation on Submit + which is a requirement +- A form control with an error should change to the error UI and the error text should be displayed + below the element and be associated to their respective fields (consider using `aria-describedby`) +- When a field with an error is focused, assistive technology should announce the label and + elements' invalid state and then the error text. + - **Example:** "URL required, Error, URL format is not acceptable." +- Once the user has re-focused the field, and starts typing. The error will disappear. Validation + should not occur when typing in most cases. Once th user unfocuses the field, validation triggers + again. + +### Validation on Submit + +- Validation must also occur on submit. A user may select the submit button directly without + changing focus from a form input. Or a user may disable their browser's javascript which is what + supports the inline onblur validation. Finally, there may be a server side error that can only be + checked on submit. +- On submit, a summary error should appear near the submit button or at the top of the form alerting + the user of what errors need to be addressed. This summary should be read out by assistive + technology after submit regardless of whether or not it was already on screen. +- Any invalid form control will display an inline error following the field's helper text (or in + place of) +- If submit is successful, use a success toast to alert the user of the successful action. +- For any server side errors, the Danger toast may still be used. Be sure to adjust the toast's + timeout to follow the 6 second + +* 1 second for each additional 120 words rule. + +### Helper Text + +Similar to a field error, helper text should be associated to a field using `aria-describedby`. This +allows assistive technology to read out the instructional text and field requirements in addition to +the field’s label. + +### Visual style + +- All field inputs are interactive elements that must follow the WCAG graphic contrast guidelines. + Maintain a ratio of 3:1 with the form's background. +- Error styling should not rely only on using the `danger-500`color change. Use + as a prefix to highlight the text as error text versus helper diff --git a/libs/components/src/icon-button/icon-button.mdx b/libs/components/src/icon-button/icon-button.mdx new file mode 100644 index 00000000000..817594a7728 --- /dev/null +++ b/libs/components/src/icon-button/icon-button.mdx @@ -0,0 +1,110 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./icon-button.stories"; + + + +# Icon Button + +Icon buttons are used when no text accompanies the button. It consists of an icon that may be +updated to any icon in the `bwi-font`, a `title` attribute, and an `aria-label`. + +The most common use of the icon button is in the banner, toast, and modal components as a close +button. It can also be found in tables as the 3 dot option menu, or on navigation list items when +there are options that need to be collapsed into a menu. + +Similar to the main button components, spacing between multiple icon buttons should be .5rem. + + + + +## Usage + +Icon buttons can be found in other components such as: the +[banner](?path=/docs/component-library-banner--docs) +[dialog](?path=/docs/component-library-dialogs--docs), and +[table](?path=/docs/component-library-table--docs). + + + +## Styles + +There are 4 common styles for button main, muted, contrast, and danger. The other styles follow the +button component styles. + +### Main + +Used for general icon buttons appearing on the theme’s main `background` + + + +### Muted + +Used for low emphasis icon buttons appearing on the theme’s main `background` + + + +### Contrast + +Used on a theme’s colored or contrasting backgrounds such as in the navigation or on toasts and +banners. + + + +### Danger + +Danger is used for “trash” actions throughout the experience, most commonly in the bottom right of +the dialog component. + + + +### Primary + +Used in place of the main button component if no text is used. This allows the button to display +square. + + + +### Secondary + +Used in place of the main button component if no text is used. This allows the button to display +square. + + + +### Light + +Used on a background that is dark in both light theme and dark theme. Example: end user navigation +styles. + + + +**Note:** Main and contrast styles appear on backgrounds where using `primary-700` as a focus +indicator does not meet WCAG graphic contrast guidelines. + +## Sizes + +There are 2 sizes for the icon button: `small` and `default`. + +Default is typically used for most instances. Small is used if the implementation needs a variant +with less padding around the icon, such as in the navigation component. + +### Small + + + +### Default + + + +## Accessibility + +Follow guidelines outlined in the [Button docs](?path=/docs/component-library-button--doc) + +Always use the `appA11yTitle` directive set to a string that describes the action of the +icon-button. This will auto assign the same string to the `title` and `aria-label` attributes. + +`aria-label` allows assistive technology to announce the action the button takes to the users. + +`title` attribute provides a user with the browser tool tip if they do not understand what the icon +is indicating. diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts index 4a0c56fbdef..19bc972a70f 100644 --- a/libs/components/src/icon-button/icon-button.stories.ts +++ b/libs/components/src/icon-button/icon-button.stories.ts @@ -1,96 +1,47 @@ import { Meta, StoryObj } from "@storybook/angular"; -import { BitIconButtonComponent, IconButtonType } from "./icon-button.component"; - -const buttonTypes: IconButtonType[] = [ - "contrast", - "main", - "muted", - "primary", - "secondary", - "danger", - "light", -]; +import { BitIconButtonComponent } from "./icon-button.component"; export default { title: "Component Library/Icon Button", component: BitIconButtonComponent, - parameters: { - design: { - type: "figma", - url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4369%3A16686", - }, - }, args: { bitIconButton: "bwi-plus", size: "default", disabled: false, }, - argTypes: { - buttonTypes: { table: { disable: true } }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=4369%3A16686", + }, }, -} as Meta; +} as Meta; type Story = StoryObj; export const Default: Story = { - render: (args: BitIconButtonComponent) => ({ - props: { ...args, buttonTypes }, + render: (args) => ({ + props: args, template: ` - - - - - - - - - - - - - - - - - - - - - - - - -
    {{buttonType}}
    Default - -
    Disabled - -
    Loading - -
    +
    + + + + + +
    + +
    +
    + +
    +
    `, }), args: { size: "default", + buttonType: "primary", }, }; @@ -98,5 +49,90 @@ export const Small: Story = { ...Default, args: { size: "small", + buttonType: "primary", + }, +}; + +export const Primary: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), + args: { + buttonType: "primary", + }, +}; + +export const Secondary: Story = { + ...Primary, + args: { + buttonType: "secondary", + }, +}; + +export const Danger: Story = { + ...Primary, + args: { + buttonType: "danger", + }, +}; + +export const Main: Story = { + ...Primary, + args: { + buttonType: "main", + }, +}; + +export const Muted: Story = { + ...Primary, + args: { + buttonType: "muted", + }, +}; + +export const Light: Story = { + render: (args) => ({ + props: args, + template: ` +
    + +
    + `, + }), + args: { + buttonType: "light", + }, +}; + +export const Contrast: Story = { + render: (args) => ({ + props: args, + template: ` +
    + +
    + `, + }), + args: { + buttonType: "contrast", + }, +}; + +export const Loading: Story = { + ...Default, + args: { + disabled: false, + loading: true, + }, +}; + +export const Disabled: Story = { + ...Default, + args: { + disabled: true, + loading: true, }, }; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index dbc4f0b4948..d4fdda08a2a 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -13,6 +13,7 @@ export * from "./form-field"; export * from "./icon-button"; export * from "./icon"; export * from "./input"; +export * from "./layout"; export * from "./link"; export * from "./menu"; export * from "./multi-select"; diff --git a/libs/components/src/input/input.directive.ts b/libs/components/src/input/input.directive.ts index 60589208d52..b9f71ff8d59 100644 --- a/libs/components/src/input/input.directive.ts +++ b/libs/components/src/input/input.directive.ts @@ -44,6 +44,7 @@ export class BitInputDirective implements BitFormFieldControl { "focus:tw-ring-primary-700", "focus:tw-z-10", "disabled:tw-bg-secondary-100", + "[&:is(input,textarea):read-only]:tw-bg-secondary-100", ].filter((s) => s != ""); } diff --git a/libs/components/src/stories/input.mdx b/libs/components/src/input/input.mdx similarity index 95% rename from libs/components/src/stories/input.mdx rename to libs/components/src/input/input.mdx index 0fd1a4890b7..1cb3d16bc7b 100644 --- a/libs/components/src/stories/input.mdx +++ b/libs/components/src/input/input.mdx @@ -1,6 +1,6 @@ import { Meta } from "@storybook/addon-docs"; - + # Input diff --git a/libs/components/src/layout/index.ts b/libs/components/src/layout/index.ts new file mode 100644 index 00000000000..6994a4f639f --- /dev/null +++ b/libs/components/src/layout/index.ts @@ -0,0 +1 @@ +export * from "./layout.component"; diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html new file mode 100644 index 00000000000..addbede7b44 --- /dev/null +++ b/libs/components/src/layout/layout.component.html @@ -0,0 +1,10 @@ +
    + +
    + +
    +
    diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts new file mode 100644 index 00000000000..337cbf080b6 --- /dev/null +++ b/libs/components/src/layout/layout.component.ts @@ -0,0 +1,9 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "bit-layout", + templateUrl: "layout.component.html", + standalone: true, + imports: [], +}) +export class LayoutComponent {} diff --git a/libs/components/src/layout/layout.stories.ts b/libs/components/src/layout/layout.stories.ts new file mode 100644 index 00000000000..7ac986aa72a --- /dev/null +++ b/libs/components/src/layout/layout.stories.ts @@ -0,0 +1,64 @@ +import { RouterTestingModule } from "@angular/router/testing"; +import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { CalloutModule } from "../callout"; +import { NavigationModule } from "../navigation"; +import { I18nMockService } from "../utils/i18n-mock.service"; + +import { LayoutComponent } from "./layout.component"; + +export default { + title: "Component Library/Layout", + component: LayoutComponent, + decorators: [ + componentWrapperDecorator( + /** + * Applying a CSS transform makes a `position: fixed` element act like it is `position: relative` + * https://github.com/storybookjs/storybook/issues/8011#issue-490251969 + */ + (story) => /* HTML */ `
    + ${story} +
    ` + ), + moduleMetadata({ + imports: [NavigationModule, RouterTestingModule, CalloutModule], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({}); + }, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Empty: Story = { + render: (args) => ({ + props: args, + template: /* HTML */ ``, + }), +}; + +export const WithContent: Story = { + render: (args) => ({ + props: args, + template: /* HTML */ ` + + + Hello world! + + `, + }), +}; diff --git a/libs/components/src/link/link.directive.ts b/libs/components/src/link/link.directive.ts index b2e551ddeff..0841388a948 100644 --- a/libs/components/src/link/link.directive.ts +++ b/libs/components/src/link/link.directive.ts @@ -58,7 +58,6 @@ const commonStyles = [ "before:tw-rounded-md", "before:tw-transition", "focus-visible:before:tw-ring-2", - "focus-visible:before:tw-ring-text-contrast", "focus-visible:tw-z-10", ]; diff --git a/libs/components/src/link/link.mdx b/libs/components/src/link/link.mdx new file mode 100644 index 00000000000..48c8c2abd5e --- /dev/null +++ b/libs/components/src/link/link.mdx @@ -0,0 +1,39 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./link.stories"; + + + +# Link / Text button + +Text Links and Buttons use the `primary-500` color and can use either the `` or `
    diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts index 47960f874fb..7fdbadce31a 100644 --- a/libs/components/src/navigation/nav-item.stories.ts +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -61,7 +61,7 @@ export const WithChildButtons: Story = { template: `