diff --git a/.github/renovate.json b/.github/renovate.json index c9cfd54895..0172403f0f 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -41,16 +41,12 @@ }, { "matchPackageNames": [ - "@ngtools/webpack", "base64-loader", "buffer", "bufferutil", - "copy-webpack-plugin", "core-js", "css-loader", "html-loader", - "html-webpack-injector", - "html-webpack-plugin", "mini-css-extract-plugin", "ngx-infinite-scroll", "postcss", @@ -60,20 +56,15 @@ "sass-loader", "style-loader", "ts-loader", - "tsconfig-paths-webpack-plugin", "url", - "util", - "webpack", - "webpack-cli", - "webpack-dev-server", - "webpack-node-externals" + "util" ], "description": "Admin Console owned dependencies", "commitMessagePrefix": "[deps] AC:", "reviewers": ["team:team-admin-console-dev"] }, { - "matchPackageNames": ["@types/node-ipc", "node-ipc", "qrious"], + "matchPackageNames": ["qrious"], "description": "Auth owned dependencies", "commitMessagePrefix": "[deps] Auth:", "reviewers": ["team:team-auth-dev"] @@ -110,27 +101,43 @@ }, { "matchPackageNames": [ + "@babel/core", + "@babel/preset-env", "@electron/notarize", "@electron/rebuild", - "@types/argon2-browser", + "@ngtools/webpack", "@types/chrome", "@types/firefox-webext-browser", + "@types/glob", "@types/jquery", + "@types/lowdb", "@types/node", "@types/node-forge", - "argon2", - "argon2-browser", - "big-integer", + "@types/node-ipc", + "@yao-pkg", + "babel-loader", + "browserslist", + "copy-webpack-plugin", + "electron", "electron-builder", "electron-log", "electron-reload", "electron-store", "electron-updater", - "electron", + "html-webpack-injector", + "html-webpack-plugin", + "lowdb", "node-forge", + "node-ipc", + "pkg", "rxjs", + "tsconfig-paths-webpack-plugin", "type-fest", - "typescript" + "typescript", + "webpack", + "webpack-cli", + "webpack-dev-server", + "webpack-node-externals" ], "description": "Platform owned dependencies", "commitMessagePrefix": "[deps] Platform:", @@ -231,7 +238,6 @@ "@types/koa__router", "@types/koa-bodyparser", "@types/koa-json", - "@types/lowdb", "@types/lunr", "@types/node-fetch", "@types/proper-lockfile", @@ -244,18 +250,22 @@ "koa", "koa-bodyparser", "koa-json", - "lowdb", "lunr", "multer", "node-fetch", "open", - "pkg", "proper-lockfile", "qrcode-parser" ], "description": "Vault owned dependencies", "commitMessagePrefix": "[deps] Vault:", "reviewers": ["team:team-vault-dev"] + }, + { + "matchPackageNames": ["@types/argon2-browser", "argon2", "argon2-browser", "big-integer"], + "description": "Key Management owned dependencies", + "commitMessagePrefix": "[deps] KM:", + "reviewers": ["team:team-key-management-dev"] } ], "ignoreDeps": ["@types/koa-bodyparser", "bootstrap", "node-ipc", "node", "npm"] diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 34c69912f5..ecd1e40494 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -1,7 +1,8 @@ name: Build Browser on: - pull_request: + pull_request_target: + types: [opened, synchronize] branches-ignore: - 'l10n_master' - 'cf-pages' @@ -33,6 +34,10 @@ defaults: shell: bash jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + setup: name: Setup runs-on: ubuntu-22.04 @@ -41,8 +46,10 @@ jobs: adj_build_number: ${{ steps.gen_vars.outputs.adj_build_number }} node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Get Package Version id: gen_vars @@ -71,8 +78,10 @@ jobs: run: working-directory: apps/browser steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Testing locales - extName length run: | @@ -109,8 +118,10 @@ jobs: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -225,12 +236,15 @@ jobs: needs: - setup - locales-test + - check-run env: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -342,8 +356,10 @@ jobs: - build - build-safari steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -381,7 +397,10 @@ jobs: - crowdin-push steps: - name: Check if any job failed - if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure') + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') + && contains(needs.*.result, 'failure') run: exit 1 - name: Login to Azure - Prod Subscription diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 7994e508b3..98ba5b9fd8 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -1,7 +1,8 @@ name: Build CLI on: - pull_request: + pull_request_target: + types: [opened, synchronize] branches-ignore: - 'l10n_master' - 'cf-pages' @@ -34,6 +35,10 @@ defaults: working-directory: apps/cli jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + setup: name: Setup runs-on: ubuntu-22.04 @@ -41,8 +46,10 @@ jobs: package_version: ${{ steps.retrieve-package-version.outputs.package_version }} node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Get Package Version id: retrieve-package-version @@ -58,7 +65,6 @@ jobs: NODE_VERSION=${NODE_NVMRC/v/''} echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - cli: name: "${{ matrix.os.base }} - ${{ matrix.license_type.readable }}" strategy: @@ -82,8 +88,10 @@ jobs: _WIN_PKG_FETCH_VERSION: 20.11.1 _WIN_PKG_VERSION: 3.5 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Setup Unix Vars run: | @@ -160,8 +168,10 @@ jobs: _WIN_PKG_FETCH_VERSION: 20.11.1 _WIN_PKG_VERSION: 3.5 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Setup Windows builder run: | @@ -310,8 +320,10 @@ jobs: env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Print environment run: | @@ -386,10 +398,14 @@ jobs: - cli - cli-windows - snap + - check-run steps: - name: Check if any job failed working-directory: ${{ github.workspace }} - if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure') + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') + && contains(needs.*.result, 'failure') run: exit 1 - name: Login to Azure - Prod Subscription diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 4667a93711..83389c5bbe 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1,7 +1,8 @@ name: Build Desktop on: - pull_request: + pull_request_target: + types: [opened, synchronize] branches-ignore: - 'l10n_master' - 'cf-pages' @@ -32,12 +33,18 @@ defaults: shell: bash jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + electron-verify: name: Verify Electron Version runs-on: ubuntu-22.04 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Verify run: | @@ -65,8 +72,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Get Package Version id: retrieve-version @@ -138,8 +147,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -238,7 +249,9 @@ jobs: windows: name: Windows Build runs-on: windows-2022 - needs: setup + needs: + - setup + - check-run defaults: run: shell: pwsh @@ -248,8 +261,10 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} NODE_OPTIONS: --max_old_space_size=4096 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -447,7 +462,9 @@ jobs: macos-build: name: MacOS Build runs-on: macos-13 - needs: setup + needs: + - setup + - check-run env: _PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -456,8 +473,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -622,8 +641,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -841,8 +862,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -1088,8 +1111,10 @@ jobs: run: working-directory: apps/desktop steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -1279,8 +1304,10 @@ jobs: - macos-package-mas runs-on: ubuntu-22.04 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -1323,7 +1350,10 @@ jobs: - crowdin-push steps: - name: Check if any job failed - if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure') + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') + && contains(needs.*.result, 'failure') run: exit 1 - name: Login to Azure - Prod Subscription diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 31f800d5b3..4ce5bad790 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -1,7 +1,8 @@ name: Build Web on: - pull_request: + pull_request_target: + types: [opened, synchronize] branches-ignore: - 'l10n_master' - 'cf-pages' @@ -36,6 +37,10 @@ env: _AZ_REGISTRY: bitwardenprod.azurecr.io jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + setup: name: Setup runs-on: ubuntu-22.04 @@ -43,8 +48,10 @@ jobs: version: ${{ steps.version.outputs.value }} node_version: ${{ steps.retrieve-node-version.outputs.node_version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Get GitHub sha as version id: version @@ -89,8 +96,10 @@ jobs: git_metadata: true steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 @@ -142,6 +151,7 @@ jobs: needs: - setup - build-artifacts + - check-run strategy: fail-fast: false matrix: @@ -155,8 +165,10 @@ jobs: env: _VERSION: ${{ needs.setup.outputs.version }} steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Check Branch to Publish env: @@ -250,11 +262,15 @@ jobs: crowdin-push: name: Crowdin Push if: github.ref == 'refs/heads/main' - needs: build-artifacts + needs: + - build-artifacts + - check-run runs-on: ubuntu-22.04 steps: - - name: Checkout repo + - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Login to Azure uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -282,9 +298,11 @@ jobs: trigger-web-vault-deploy: name: Trigger web vault deploy - if: github.ref == 'refs/heads/main' + if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' runs-on: ubuntu-22.04 - needs: build-artifacts + needs: + - build-artifacts + - check-run steps: - name: Login to Azure - CI Subscription uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 @@ -326,7 +344,10 @@ jobs: - trigger-web-vault-deploy steps: - name: Check if any job failed - if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure') + if: | + github.event_name != 'pull_request_target' + && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') + && contains(needs.*.result, 'failure') run: exit 1 - name: Login to Azure - Prod Subscription diff --git a/.github/workflows/version-bump.yml b/.github/workflows/repository-management.yml similarity index 55% rename from .github/workflows/version-bump.yml rename to .github/workflows/repository-management.yml index d0be0373e6..21de47f13b 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/repository-management.yml @@ -1,124 +1,130 @@ -name: Version Bump +name: Repository management on: workflow_dispatch: inputs: + task: + default: "Version Bump" + description: "Task to execute" + options: + - "Version Bump" + - "Version Bump and Cut rc" + required: true + type: choice bump_browser: - description: "Bump Browser?" + description: "Bump Browser version?" type: boolean default: false bump_cli: - description: "Bump CLI?" + description: "Bump CLI version?" type: boolean default: false bump_desktop: - description: "Bump Desktop?" + description: "Bump Desktop version?" type: boolean default: false bump_web: - description: "Bump Web?" + description: "Bump Web version?" type: boolean default: false + target_ref: + default: "main" + description: "Branch/Tag to target for cut" + required: true + type: string version_number_override: description: "New version override (leave blank for automatic calculation, example: '2024.1.0')" required: false type: string - cut_rc_branch: - description: "Cut RC branch?" - default: true - type: boolean - enable_slack_notification: - description: "Enable Slack notifications for upcoming release?" - default: false - type: boolean + jobs: + setup: + name: Setup + runs-on: ubuntu-24.04 + outputs: + branch: ${{ steps.set-branch.outputs.branch }} + token: ${{ steps.app-token.outputs.token }} + steps: + - name: Set branch + id: set-branch + env: + TASK: ${{ inputs.task }} + run: | + if [[ "$TASK" == "Version Bump" ]]; then + BRANCH="none" + elif [[ "$TASK" == "Version Bump and Cut rc" ]]; then + BRANCH="rc" + fi + + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + + - name: Generate GH App token + uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 + id: app-token + with: + app-id: ${{ secrets.BW_GHAPP_ID }} + private-key: ${{ secrets.BW_GHAPP_KEY }} + + + cut_branch: + name: Cut branch + if: ${{ needs.setup.outputs.branch == 'rc' }} + needs: setup + runs-on: ubuntu-24.04 + steps: + - name: Check out target ref + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ inputs.target_ref }} + token: ${{ needs.setup.outputs.token }} + + - name: Check if ${{ needs.setup.outputs.branch }} branch exists + env: + BRANCH_NAME: ${{ needs.setup.outputs.branch }} + run: | + if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then + echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + - name: Cut branch + env: + BRANCH_NAME: ${{ needs.setup.outputs.branch }} + run: | + git switch --quiet --create $BRANCH_NAME + git push --quiet --set-upstream origin $BRANCH_NAME + + bump_version: name: Bump Version - runs-on: ubuntu-22.04 + if: ${{ always() }} + runs-on: ubuntu-24.04 + needs: + - cut_branch + - setup outputs: version_browser: ${{ steps.set-final-version-output.outputs.version_browser }} version_cli: ${{ steps.set-final-version-output.outputs.version_cli }} version_desktop: ${{ steps.set-final-version-output.outputs.version_desktop }} version_web: ${{ steps.set-final-version-output.outputs.version_web }} steps: - - name: Validate version input + - name: Validate version input format if: ${{ inputs.version_number_override != '' }} uses: bitwarden/gh-actions/version-check@main with: version: ${{ inputs.version_number_override }} - - name: Slack Notification Check - run: | - if [[ "${{ inputs.enable_slack_notification }}" == true ]]; then - echo "Slack notifications enabled." - else - echo "Slack notifications disabled." - fi - - - name: Checkout Branch + - name: Check out branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: main + token: ${{ needs.setup.outputs.token }} - - name: Check if RC branch exists - if: ${{ inputs.cut_rc_branch == true }} + - name: Configure Git run: | - remote_rc_branch_check=$(git ls-remote --heads origin rc | wc -l) - if [[ "${remote_rc_branch_check}" -gt 0 ]]; then - echo "Remote RC branch exists." - echo "Please delete current RC branch before running again." - exit 1 - fi - - - name: Login to Azure - CI Subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 - with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - - name: Retrieve secrets - id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "github-gpg-private-key, - github-gpg-private-key-passphrase" - - - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0 - with: - gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }} - passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }} - git_user_signingkey: true - git_commit_gpgsign: true - - - name: Setup git - run: | - git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com" - git config --local user.name "bitwarden-devops-bot" - - - name: Create Version Branch - id: create-branch - run: | - 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 - - NAME=version_bump_${{ github.ref_name }}_$(date +"%Y-%m-%d") - git switch -c $NAME - echo "name=$NAME" >> $GITHUB_OUTPUT + git config --local user.email "actions@github.com" + git config --local user.name "Github Actions" ######################## # VERSION BUMP SECTION # @@ -165,7 +171,9 @@ jobs: - name: Bump Browser Version - Version Override if: ${{ inputs.bump_browser == true && inputs.version_number_override != '' }} id: bump-browser-version-override - run: npm version --workspace=@bitwarden/browser ${{ inputs.version_number_override }} + env: + VERSION: ${{ inputs.version_number_override }} + run: npm version --workspace=@bitwarden/browser $VERSION - name: Bump Browser Version - Automatic Calculation if: ${{ inputs.bump_browser == true && inputs.version_number_override == '' }} @@ -250,7 +258,9 @@ jobs: - name: Bump CLI Version - Version Override if: ${{ inputs.bump_cli == true && inputs.version_number_override != '' }} id: bump-cli-version-override - run: npm version --workspace=@bitwarden/cli ${{ inputs.version_number_override }} + env: + VERSION: ${{ inputs.version_number_override }} + run: npm version --workspace=@bitwarden/cli $VERSION - name: Bump CLI Version - Automatic Calculation if: ${{ inputs.bump_cli == true && inputs.version_number_override == '' }} @@ -300,7 +310,9 @@ jobs: - name: Bump Desktop Version - Root - Version Override if: ${{ inputs.bump_desktop == true && inputs.version_number_override != '' }} id: bump-desktop-version-override - run: npm version --workspace=@bitwarden/desktop ${{ inputs.version_number_override }} + env: + VERSION: ${{ inputs.version_number_override }} + run: npm version --workspace=@bitwarden/desktop $VERSION - name: Bump Desktop Version - Root - Automatic Calculation if: ${{ inputs.bump_desktop == true && inputs.version_number_override == '' }} @@ -311,7 +323,9 @@ jobs: - name: Bump Desktop Version - App - Version Override if: ${{ inputs.bump_desktop == true && inputs.version_number_override != '' }} - run: npm version ${{ inputs.version_number_override }} + env: + VERSION: ${{ inputs.version_number_override }} + run: npm version $VERSION working-directory: "apps/desktop/src" - name: Bump Desktop Version - App - Automatic Calculation @@ -362,7 +376,9 @@ jobs: - name: Bump Web Version - Version Override if: ${{ inputs.bump_web == true && inputs.version_number_override != '' }} id: bump-web-version-override - run: npm version --workspace=@bitwarden/web-vault ${{ inputs.version_number_override }} + env: + VERSION: ${{ inputs.version_number_override }} + run: npm version --workspace=@bitwarden/web-vault $VERSION - name: Bump Web Version - Automatic Calculation if: ${{ inputs.bump_web == true && inputs.version_number_override == '' }} @@ -375,27 +391,29 @@ jobs: - name: Set final version output id: set-final-version-output + env: + VERSION: ${{ inputs.version_number_override }} run: | if [[ "${{ steps.bump-browser-version-override.outcome }}" = "success" ]]; then - echo "version_browser=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + echo "version_browser=$VERSION" >> $GITHUB_OUTPUT elif [[ "${{ steps.bump-browser-version-automatic.outcome }}" = "success" ]]; then echo "version_browser=${{ steps.calculate-next-browser-version.outputs.version }}" >> $GITHUB_OUTPUT fi if [[ "${{ steps.bump-cli-version-override.outcome }}" = "success" ]]; then - echo "version_cli=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + echo "version_cli=$VERSION" >> $GITHUB_OUTPUT elif [[ "${{ steps.bump-cli-version-automatic.outcome }}" = "success" ]]; then echo "version_cli=${{ steps.calculate-next-cli-version.outputs.version }}" >> $GITHUB_OUTPUT fi if [[ "${{ steps.bump-desktop-version-override.outcome }}" = "success" ]]; then - echo "version_desktop=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + echo "version_desktop=$VERSION" >> $GITHUB_OUTPUT elif [[ "${{ steps.bump-desktop-version-automatic.outcome }}" = "success" ]]; then echo "version_desktop=${{ steps.calculate-next-desktop-version.outputs.version }}" >> $GITHUB_OUTPUT fi if [[ "${{ steps.bump-web-version-override.outcome }}" = "success" ]]; then - echo "version_web=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT + echo "version_web=$VERSION" >> $GITHUB_OUTPUT elif [[ "${{ steps.bump-web-version-automatic.outcome }}" = "success" ]]; then echo "version_web=${{ steps.calculate-next-web-version.outputs.version }}" >> $GITHUB_OUTPUT fi @@ -416,199 +434,52 @@ jobs: - name: Push changes if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - env: - PR_BRANCH: ${{ steps.create-branch.outputs.name }} - run: git push -u origin $PR_BRANCH + run: git push - - name: Generate PR message - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - id: pr-message - run: | - MESSAGE="" - if [[ "${{ inputs.bump_browser }}" == "true" ]]; then - MESSAGE+=$' Browser version bump to ${{ steps.set-final-version-output.outputs.version_browser }}\n' - fi - if [[ "${{ inputs.bump_cli }}" == "true" ]]; then - MESSAGE+=$' CLI version bump to ${{ steps.set-final-version-output.outputs.version_cli }}\n' - fi - - if [[ "${{ inputs.bump_desktop }}" == "true" ]]; then - MESSAGE+=$' Desktop version bump to ${{ steps.set-final-version-output.outputs.version_desktop }}\n' - fi - - if [[ "${{ inputs.bump_web }}" == "true" ]]; then - MESSAGE+=$' Web version bump to ${{ steps.set-final-version-output.outputs.version_web }}\n' - fi - - echo "MESSAGE<> $GITHUB_ENV - echo "$MESSAGE" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - - - name: Generate GH App token - uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 - id: app-token - with: - app-id: ${{ secrets.BW_GHAPP_ID }} - private-key: ${{ secrets.BW_GHAPP_KEY }} - owner: ${{ github.repository_owner }} - - - name: Create Version PR - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - id: create-pr - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - PR_BRANCH: ${{ steps.create-branch.outputs.name }} - TITLE: "Bump client(s) version" - run: | - PR_URL=$(gh pr create --title "$TITLE" \ - --base "main" \ - --head "$PR_BRANCH" \ - --label "version update" \ - --label "automated pr" \ - --body " - ## Type of change - - [ ] Bug fix - - [ ] New feature development - - [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc) - - [ ] Build/deploy pipeline (DevOps) - - [X] Other - - ## Objective - $MESSAGE") - - echo "pr_number=${PR_URL##*/}" >> $GITHUB_OUTPUT - - - name: Approve PR - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }} - run: gh pr review $PR_NUMBER --approve - - - name: Merge PR - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }} - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }} - run: gh pr merge $PR_NUMBER --squash --auto --delete-branch - - - name: Report upcoming browser release version to Slack - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' && steps.set-final-version-output.outputs.version_browser != '' && inputs.enable_slack_notification == true }} - uses: bitwarden/gh-actions/report-upcoming-release-version@main - with: - version: ${{ steps.set-final-version-output.outputs.version_browser }} - project: browser - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - - name: Report upcoming cli release version to Slack - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' && steps.set-final-version-output.outputs.version_cli != '' && inputs.enable_slack_notification == true }} - uses: bitwarden/gh-actions/report-upcoming-release-version@main - with: - version: ${{ steps.set-final-version-output.outputs.version_cli }} - project: cli - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - - name: Report upcoming desktop release version to Slack - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' && steps.set-final-version-output.outputs.version_desktop != '' && inputs.enable_slack_notification == true }} - uses: bitwarden/gh-actions/report-upcoming-release-version@main - with: - version: ${{ steps.set-final-version-output.outputs.version_desktop }} - project: desktop - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - - name: Report upcoming web release version to Slack - if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' && steps.set-final-version-output.outputs.version_web != '' && inputs.enable_slack_notification == true }} - uses: bitwarden/gh-actions/report-upcoming-release-version@main - with: - version: ${{ steps.set-final-version-output.outputs.version_web }} - project: web - AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} - - cut_rc: - name: Cut RC branch - if: ${{ inputs.cut_rc_branch == true }} - needs: bump_version - runs-on: ubuntu-22.04 + cherry_pick: + name: Cherry-Pick Commit(s) + if: ${{ needs.setup.outputs.branch == 'rc' }} + runs-on: ubuntu-24.04 + needs: + - bump_version + - setup steps: - - name: Checkout Branch + - name: Check out main branch uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: main + token: ${{ needs.setup.outputs.token }} - ### Browser - - name: Browser - Verify version has been updated - if: ${{ inputs.bump_browser == true }} - env: - NEW_VERSION: ${{ needs.bump_version.outputs.version_browser }} + - name: Configure Git run: | - # Wait for version to change. - while : ; do - echo "Waiting for version to be updated..." - git pull --force - CURRENT_VERSION=$(cat package.json | jq -r '.version') + git config --local user.email "actions@github.com" + git config --local user.name "Github Actions" - # If the versions don't match we continue the loop, otherwise we break out of the loop. - [[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] || break - sleep 10 - done - working-directory: apps/browser - - ### CLI - - name: CLI - Verify version has been updated - if: ${{ inputs.bump_cli == true }} - env: - NEW_VERSION: ${{ needs.bump_version.outputs.version_cli }} + - name: Perform cherry-pick(s) run: | - # Wait for version to change. - while : ; do - echo "Waiting for version to be updated..." - git pull --force - CURRENT_VERSION=$(cat package.json | jq -r '.version') + # Function for cherry-picking + cherry_pick () { + local package_path="apps/$1/package.json" + local source_branch=$2 + local destination_branch=$3 - # If the versions don't match we continue the loop, otherwise we break out of the loop. - [[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] || break - sleep 10 - done - working-directory: apps/cli + # Get project commit/version from source branch + git switch $source_branch + SOURCE_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 $package_path) + SOURCE_VERSION=$(cat $package_path | jq -r '.version') - ### Desktop - - name: Desktop - Verify version has been updated - if: ${{ inputs.bump_desktop == true }} - env: - NEW_VERSION: ${{ needs.bump_version.outputs.version_desktop }} - run: | - # Wait for version to change. - while : ; do - echo "Waiting for version to be updated..." - git pull --force - CURRENT_VERSION=$(cat package.json | jq -r '.version') + # Get project commit/version from destination branch + git switch $destination_branch + DESTINATION_VERSION=$(cat $package_path | jq -r '.version') - # If the versions don't match we continue the loop, otherwise we break out of the loop. - [[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] || break - sleep 10 - done - working-directory: apps/desktop + if [[ "$DESTINATION_VERSION" != "$SOURCE_VERSION" ]]; then + git cherry-pick --strategy-option=theirs -x $SOURCE_COMMIT + git push -u origin $destination_branch + fi - ### Web - - name: Web - Verify version has been updated - if: ${{ inputs.bump_web == true }} - env: - NEW_VERSION: ${{ needs.bump_version.outputs.version_web }} - run: | - # Wait for version to change. - while : ; do - echo "Waiting for version to be updated..." - git pull --force - CURRENT_VERSION=$(cat package.json | jq -r '.version') - - # If the versions don't match we continue the loop, otherwise we break out of the loop. - [[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] || break - sleep 10 - done - working-directory: apps/web - - - name: Cut RC branch - run: | - git switch --quiet --create rc - git push --quiet --set-upstream origin rc + # Cherry-pick from 'main' into 'rc' + cherry_pick browser main rc + cherry_pick cli main rc + cherry_pick desktop main rc + cherry_pick web main rc diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index cc6feeba02..f41261cb39 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -8,27 +8,55 @@ on: jobs: bump-version: name: Bump Desktop Version - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - name: Login to Azure - CI Subscription - uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0 + - name: Generate GH App token + uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 + id: app-token with: - creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + app-id: ${{ secrets.BW_GHAPP_ID }} + private-key: ${{ secrets.BW_GHAPP_KEY }} - - name: Retrieve bot secrets - id: retrieve-bot-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main + - name: Check out target ref + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - keyvault: bitwarden-ci - secrets: "github-pat-bitwarden-devops-bot-repo-scope" + ref: main + token: ${{ steps.app-token.outputs.token }} - - name: Trigger Version Bump workflow - env: - GH_TOKEN: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + - name: Configure Git run: | - echo '{"cut_rc_branch": "false", - "bump_browser": "false", - "bump_cli": "false", - "bump_desktop": "true", - "bump_web": "false"}' | \ - gh workflow run version-bump.yml --json --repo bitwarden/clients + git config --local user.email "actions@github.com" + git config --local user.name "Github Actions" + + - name: Get current Desktop version + id: current-desktop-version + run: | + CURRENT_VERSION=$(cat package.json | jq -r '.version') + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + working-directory: apps/desktop + + - name: Calculate next Desktop release version + id: calculate-next-desktop-version + uses: bitwarden/gh-actions/version-next@main + with: + version: ${{ steps.current-desktop-version.outputs.version }} + + - name: Bump Desktop Version - Root - Automatic Calculation + id: bump-desktop-version-automatic + env: + VERSION: ${{ steps.calculate-next-desktop-version.outputs.version }} + run: npm version --workspace=@bitwarden/desktop $VERSION + + - name: Bump Desktop Version - App - Automatic Calculation + env: + VERSION: ${{ steps.calculate-next-desktop-version.outputs.version }} + run: npm version $VERSION + working-directory: "apps/desktop/src" + + - name: Commit files + env: + VERSION: ${{ steps.calculate-next-desktop-version.outputs.version }} + run: git commit -m "Bumped Desktop client to $VERSION" -a + + - name: Push changes + run: git push diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 0686063d56..df664cf8d3 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -2878,7 +2878,7 @@ "message": "E-Mail generieren" }, "generatorBoundariesHint": { - "message": "Value must be between $MIN$ and $MAX$", + "message": "Wert muss zwischen $MIN$ und $MAX$ liegen", "description": "Explains spin box minimum and maximum values to the user", "placeholders": { "min": { diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index cbb1475c4d..42e56a56ba 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -20,16 +20,16 @@ "message": "Crear cuenta" }, "newToBitwarden": { - "message": "New to Bitwarden?" + "message": "¿Nuevo en Bitwarden?" }, "logInWithPasskey": { - "message": "Log in with passkey" + "message": "Iniciar sesión con clave de acceso" }, "useSingleSignOn": { - "message": "Use single sign-on" + "message": "Usar inicio de sesión único" }, "welcomeBack": { - "message": "Welcome back" + "message": "Bienvenido de nuevo" }, "setAStrongPassword": { "message": "Establece una contraseña fuerte" @@ -84,7 +84,7 @@ "message": "Incorporarse a la organización" }, "joinOrganizationName": { - "message": "Join $ORGANIZATIONNAME$", + "message": "Unirse a $ORGANIZATIONNAME$", "placeholders": { "organizationName": { "content": "$1", @@ -120,7 +120,7 @@ "message": "Copiar contraseña" }, "copyPassphrase": { - "message": "Copy passphrase" + "message": "Copiar frase de contraseña" }, "copyNote": { "message": "Copiar nota" @@ -153,7 +153,7 @@ "message": "Copiar número de licencia" }, "copyCustomField": { - "message": "Copy $FIELD$", + "message": "Copiar $FIELD$", "placeholders": { "field": { "content": "$1", @@ -162,13 +162,13 @@ } }, "copyWebsite": { - "message": "Copy website" + "message": "Copiar sitio web" }, "copyNotes": { - "message": "Copy notes" + "message": "Copiar notas" }, "fill": { - "message": "Fill", + "message": "Rellenar", "description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible." }, "autoFill": { @@ -223,13 +223,13 @@ "message": "Añadir elemento" }, "accountEmail": { - "message": "Account email" + "message": "Correo electrónico de la cuenta" }, "requestHint": { - "message": "Request hint" + "message": "Solicitar pista" }, "requestPasswordHint": { - "message": "Request password hint" + "message": "Solicitar pista de la contraseña" }, "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": { "message": "Enter your account email address and your password hint will be sent to you" @@ -427,7 +427,7 @@ "message": "Generar contraseña" }, "generatePassphrase": { - "message": "Generate passphrase" + "message": "Generar frase de contraseña" }, "regeneratePassword": { "message": "Regenerar contraseña" @@ -567,7 +567,7 @@ "message": "Notas" }, "privateNote": { - "message": "Private note" + "message": "Nota privada" }, "note": { "message": "Nota" @@ -624,7 +624,7 @@ "message": "Tiempo de sesión agotado" }, "vaultTimeoutHeader": { - "message": "Vault timeout" + "message": "Tiempo de espera de la caja fuerte" }, "otherOptions": { "message": "Otras opciones" @@ -645,13 +645,13 @@ "message": "Tu caja fuerte está bloqueada. Verifica tu identidad para continuar." }, "yourVaultIsLockedV2": { - "message": "Your vault is locked" + "message": "Tu caja fuerte está bloqueada" }, "yourAccountIsLocked": { - "message": "Your account is locked" + "message": "Tu cuenta está bloqueada" }, "or": { - "message": "or" + "message": "o" }, "unlock": { "message": "Desbloquear" @@ -676,7 +676,7 @@ "message": "Tiempo de espera de la caja fuerte" }, "vaultTimeout1": { - "message": "Timeout" + "message": "Tiempo de espera" }, "lockNow": { "message": "Bloquear" @@ -4708,11 +4708,11 @@ "description": "Represents the - key in screen reader content as a readable word" }, "plusCharacterDescriptor": { - "message": "Plus", + "message": "Más", "description": "Represents the + key in screen reader content as a readable word" }, "equalsCharacterDescriptor": { - "message": "Equals", + "message": "Igual", "description": "Represents the = key in screen reader content as a readable word" }, "braceLeftCharacterDescriptor": { @@ -4736,15 +4736,15 @@ "description": "Represents the | key in screen reader content as a readable word" }, "backSlashCharacterDescriptor": { - "message": "Back slash", + "message": "Contrabarra", "description": "Represents the back slash key in screen reader content as a readable word" }, "colonCharacterDescriptor": { - "message": "Colon", + "message": "Dos puntos", "description": "Represents the : key in screen reader content as a readable word" }, "semicolonCharacterDescriptor": { - "message": "Semicolon", + "message": "Punto y coma", "description": "Represents the ; key in screen reader content as a readable word" }, "doubleQuoteCharacterDescriptor": { diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 96a10c167a..054362a4a2 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -1424,7 +1424,7 @@ "message": "Palvelimen URL" }, "selfHostBaseUrl": { - "message": "Self-host server URL", + "message": "Itse ylläpidetyn palvelimen URL-osoite", "description": "Label for field requesting a self-hosted integration service URL" }, "apiUrl": { @@ -1795,13 +1795,13 @@ "message": "Salasanahistoria" }, "generatorHistory": { - "message": "Generator history" + "message": "Generaattorihistoria" }, "clearGeneratorHistoryTitle": { - "message": "Clear generator history" + "message": "Tyhjennä generaattorihistoria" }, "cleargGeneratorHistoryDescription": { - "message": "If you continue, all entries will be permanently deleted from generator's history. Are you sure you want to continue?" + "message": "Jos jatkat, kaikki generaattorihistorian kohteet poistetaan. Haluatko varmasti jatkaa?" }, "back": { "message": "Takaisin" @@ -1920,7 +1920,7 @@ "message": "Tyhjennä historia" }, "nothingToShow": { - "message": "Nothing to show" + "message": "Mitään näytettävää ei ole" }, "nothingGeneratedRecently": { "message": "Et ole luonut mitään hiljattain" @@ -2710,7 +2710,7 @@ "description": "Used as a card title description on the set password page to explain why the user is there" }, "cardMetrics": { - "message": "out of $TOTAL$", + "message": "/$TOTAL$", "placeholders": { "total": { "content": "$1", diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index c05410956f..e58a262d59 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -147,7 +147,7 @@ "message": "Kopiera personnummer" }, "copyPassportNumber": { - "message": "Copy passport number" + "message": "Kopiera passnummer" }, "copyLicenseNumber": { "message": "Copy license number" @@ -4624,7 +4624,7 @@ "message": "Items that have been in trash more than 30 days will automatically be deleted" }, "restore": { - "message": "Restore" + "message": "Återställ" }, "deleteForever": { "message": "Delete forever" @@ -4744,7 +4744,7 @@ "description": "Represents the : key in screen reader content as a readable word" }, "semicolonCharacterDescriptor": { - "message": "Semicolon", + "message": "Semikolon", "description": "Represents the ; key in screen reader content as a readable word" }, "doubleQuoteCharacterDescriptor": { @@ -4756,11 +4756,11 @@ "description": "Represents the ' key in screen reader content as a readable word" }, "lessThanCharacterDescriptor": { - "message": "Less than", + "message": "Mindre än", "description": "Represents the < key in screen reader content as a readable word" }, "greaterThanCharacterDescriptor": { - "message": "Greater than", + "message": "Större än", "description": "Represents the > key in screen reader content as a readable word" }, "commaCharacterDescriptor": { @@ -4772,7 +4772,7 @@ "description": "Represents the . key in screen reader content as a readable word" }, "questionCharacterDescriptor": { - "message": "Question mark", + "message": "Frågetecken", "description": "Represents the ? key in screen reader content as a readable word" }, "forwardSlashCharacterDescriptor": { diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 76d2526292..26bbbd0054 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -168,7 +168,7 @@ "message": "複製備註" }, "fill": { - "message": "Fill", + "message": "填入", "description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible." }, "autoFill": { @@ -458,7 +458,7 @@ "description": "deprecated. Use specialCharactersLabel instead." }, "include": { - "message": "Include", + "message": "包含", "description": "Card header for password generator include block" }, "uppercaseDescription": { @@ -730,10 +730,10 @@ "message": "安全" }, "confirmMasterPassword": { - "message": "Confirm master password" + "message": "確認主密碼" }, "masterPassword": { - "message": "Master password" + "message": "主密碼" }, "masterPassImportant": { "message": "Your master password cannot be recovered if you forget it!" @@ -1092,10 +1092,10 @@ "message": "This file export will be password protected and require the file password to decrypt." }, "filePassword": { - "message": "File password" + "message": "檔案密碼" }, "exportPasswordDescription": { - "message": "This password will be used to export and import this file" + "message": "此密碼將用於匯出和匯入此檔案" }, "accountRestrictedOptionDescription": { "message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account." @@ -3542,7 +3542,7 @@ "description": "Screen reader text (aria-label) for new item button in overlay" }, "newLogin": { - "message": "New login", + "message": "新增登入資訊", "description": "Button text to display within inline menu when there are no matching items on a login field" }, "addNewLoginItemAria": { diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index d5273fd9fb..4a206b36fa 100644 --- a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -9,7 +9,7 @@ - + diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts index db85b28fa6..0301a76431 100644 --- a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.ts @@ -15,6 +15,7 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; import { CurrentAccountComponent } from "../account-switching/current-account.component"; +import { AccountSwitcherService } from "../account-switching/services/account-switcher.service"; import { ExtensionBitwardenLogo } from "./extension-bitwarden-logo.icon"; @@ -50,6 +51,7 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { protected pageIcon: Icon; protected showReadonlyHostname: boolean; protected maxWidth: "md" | "3xl"; + protected hasLoggedInAccount: boolean = false; protected theme: string; protected logo = ExtensionBitwardenLogo; @@ -59,6 +61,7 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private i18nService: I18nService, private extensionAnonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private accountSwitcherService: AccountSwitcherService, ) {} async ngOnInit(): Promise { @@ -68,6 +71,12 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy { // Listen for page changes and update the page data appropriately this.listenForPageDataChanges(); this.listenForServiceDataChanges(); + + this.accountSwitcherService.availableAccounts$ + .pipe(takeUntil(this.destroy$)) + .subscribe((accounts) => { + this.hasLoggedInAccount = accounts.some((account) => account.id !== "addAccount"); + }); } private listenForPageDataChanges() { diff --git a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index cc4aa2f731..ad7e6f6736 100644 --- a/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -27,6 +27,7 @@ import { ButtonModule, I18nMockService } from "@bitwarden/components"; import { RegistrationCheckEmailIcon } from "../../../../../../libs/auth/src/angular/icons/registration-check-email.icon"; import { PopupRouterCacheService } from "../../../platform/popup/view-cache/popup-router-cache.service"; +import { AccountSwitcherService } from "../account-switching/services/account-switcher.service"; import { ExtensionAnonLayoutWrapperDataService } from "./extension-anon-layout-wrapper-data.service"; import { @@ -45,6 +46,7 @@ const decorators = (options: { applicationVersion?: string; clientType?: ClientType; hostName?: string; + accounts?: any[]; }) => { return [ componentWrapperDecorator( @@ -83,6 +85,13 @@ const decorators = (options: { }), }, }, + { + provide: AccountSwitcherService, + useValue: { + availableAccounts$: of(options.accounts || []), + SPECIAL_ADD_ACCOUNT_ID: "addAccount", + } as Partial, + }, { provide: AuthService, useValue: { @@ -300,3 +309,64 @@ export const DynamicContentExample: Story = { ], }), }; + +export const HasLoggedInAccountExample: Story = { + render: (args) => ({ + props: args, + template: "", + }), + decorators: decorators({ + components: [DefaultPrimaryOutletExampleComponent], + routes: [ + { + path: "**", + redirectTo: "has-logged-in-account", + pathMatch: "full", + }, + { + path: "", + component: ExtensionAnonLayoutWrapperComponent, + children: [ + { + path: "has-logged-in-account", + data: { + hasLoggedInAccount: true, + showAcctSwitcher: true, + }, + children: [ + { + path: "", + component: DefaultPrimaryOutletExampleComponent, + }, + { + path: "", + component: DefaultSecondaryOutletExampleComponent, + outlet: "secondary", + }, + { + path: "", + component: DefaultEnvSelectorOutletExampleComponent, + outlet: "environment-selector", + }, + ], + }, + ], + }, + ], + accounts: [ + { + name: "Test User", + email: "testuser@bitwarden.com", + id: "123e4567-e89b-12d3-a456-426614174000", + server: "bitwarden.com", + status: 2, + isActive: false, + }, + { + name: "addAccount", + id: "addAccount", + isActive: false, + }, + ], + }), +}; diff --git a/apps/browser/src/auth/popup/home.component.ts b/apps/browser/src/auth/popup/home.component.ts index cd9dfc3702..4d185fcbfc 100644 --- a/apps/browser/src/auth/popup/home.component.ts +++ b/apps/browser/src/auth/popup/home.component.ts @@ -1,10 +1,12 @@ import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; -import { Router } from "@angular/router"; -import { Subject, firstValueFrom, switchMap, takeUntil } from "rxjs"; +import { ActivatedRoute, Router } from "@angular/router"; +import { Subject, firstValueFrom, switchMap, takeUntil, tap } from "rxjs"; import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components/environment-selector.component"; import { LoginEmailServiceAbstraction, RegisterRouteService } from "@bitwarden/auth/common"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ToastService } from "@bitwarden/components"; @@ -38,9 +40,13 @@ export class HomeComponent implements OnInit, OnDestroy { private accountSwitcherService: AccountSwitcherService, private registerRouteService: RegisterRouteService, private toastService: ToastService, + private configService: ConfigService, + private route: ActivatedRoute, ) {} async ngOnInit(): Promise { + this.listenForUnauthUiRefreshFlagChanges(); + const email = await firstValueFrom(this.loginEmailService.loginEmail$); const rememberEmail = this.loginEmailService.getRememberEmail(); @@ -70,6 +76,29 @@ export class HomeComponent implements OnInit, OnDestroy { this.destroyed$.complete(); } + private listenForUnauthUiRefreshFlagChanges() { + this.configService + .getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh) + .pipe( + tap(async (flag) => { + // If the flag is turned ON, we must force a reload to ensure the correct UI is shown + if (flag) { + const uniqueQueryParams = { + ...this.route.queryParams, + // adding a unique timestamp to the query params to force a reload + t: new Date().getTime().toString(), + }; + + await this.router.navigate(["/login"], { + queryParams: uniqueQueryParams, + }); + } + }), + takeUntil(this.destroyed$), + ) + .subscribe(); + } + get availableAccounts$() { return this.accountSwitcherService.availableAccounts$; } diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.ts b/apps/browser/src/autofill/popup/settings/autofill.component.ts index b395808f57..ac247609b1 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.ts +++ b/apps/browser/src/autofill/popup/settings/autofill.component.ts @@ -219,7 +219,11 @@ export class AutofillComponent implements OnInit { : AutofillOverlayVisibility.Off; await this.autofillSettingsService.setInlineMenuVisibility(newInlineMenuVisibilityValue); - await this.requestPrivacyPermission(); + + // No need to initiate browser permission request if a feature is being turned off + if (newInlineMenuVisibilityValue !== AutofillOverlayVisibility.Off) { + await this.requestPrivacyPermission(); + } } async updateAutofillOnPageLoad() { diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index 33f18ce572..072ef74004 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -58,11 +58,33 @@ export class BrowserApi { } static async createWindow(options: chrome.windows.CreateData): Promise { - return new Promise((resolve) => - chrome.windows.create(options, (window) => { - resolve(window); - }), - ); + return new Promise((resolve) => { + chrome.windows.create(options, async (newWindow) => { + if (!BrowserApi.isSafariApi) { + return resolve(newWindow); + } + // Safari doesn't close the default extension popup when a new window is created so we need to + // manually trigger the close by focusing the main window after the new window is created + const allWindows = await new Promise((resolve) => { + chrome.windows.getAll({ windowTypes: ["normal"] }, (windows) => resolve(windows)); + }); + + const mainWindow = allWindows.find((window) => window.id !== newWindow.id); + + // No main window found, resolve the new window + if (mainWindow == null || !mainWindow.id) { + return resolve(newWindow); + } + + // Focus the main window to close the extension popup + chrome.windows.update(mainWindow.id, { focused: true }, () => { + // Refocus the newly created window + chrome.windows.update(newWindow.id, { focused: true }, () => { + resolve(newWindow); + }); + }); + }); + }); } /** diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index 585f6067e3..d100588365 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -24,9 +24,9 @@ import { SendFormConfig, SendFormConfigService, SendFormMode, + SendFormModule, } from "@bitwarden/send-ui"; -import { SendFormModule } from "../../../../../../../libs/tools/send/send-ui/src/send-form/send-form.module"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index afe83b3f59..74c75d38e7 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.86" +version = "1.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" [[package]] name = "arboard" @@ -388,9 +388,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.9.4" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" dependencies = [ "core-foundation-sys", "libc", @@ -1154,9 +1154,9 @@ dependencies = [ [[package]] name = "napi" -version = "2.16.11" +version = "2.16.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53575dfa17f208dd1ce3a2da2da4659aae393b256a472f2738a8586a6c4107fd" +checksum = "214f07a80874bb96a8433b3cdfc84980d56c7b02e1a0d7ba4ba0db5cef785e2b" dependencies = [ "bitflags", "ctor", @@ -1667,9 +1667,9 @@ checksum = "a3cf7c11c38cb994f3d40e8a8cde3bbd1f72a435e4c49e85d6553d8312306152" [[package]] name = "security-framework" -version = "2.11.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +checksum = "f9d0283c0a4a22a0f1b0e4edca251aa20b92fc96eaa09b84bec052f9415e9d71" dependencies = [ "bitflags", "core-foundation", @@ -1680,9 +1680,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" dependencies = [ "core-foundation-sys", "libc", @@ -1867,18 +1867,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.61" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" +checksum = "02dd99dc800bbb97186339685293e1cc5d9df1f8fae2d0aecd9ff1c77efea892" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.61" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" +checksum = "a7c61ec9a6f64d2793d8a45faba21efbe3ced62a886d44c36a009b2b519b4c7e" dependencies = [ "proc-macro2", "quote", @@ -2487,9 +2487,9 @@ dependencies = [ [[package]] name = "zbus" -version = "4.3.1" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851238c133804e0aa888edf4a0229481c753544ca12a60fd1c3230c8a500fe40" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" dependencies = [ "async-broadcast", "async-executor", @@ -2525,9 +2525,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "4.3.1" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d5a3f12c20bd473be3194af6b49d50d7bb804ef3192dc70eddedb26b85d9da7" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2583,9 +2583,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "4.1.2" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1724a2b330760dc7d2a8402d841119dc869ef120b139d29862d6980e9c75bfc9" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" dependencies = [ "endi", "enumflags2", @@ -2596,9 +2596,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "4.1.2" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55025a7a518ad14518fb243559c058a2e5b848b015e31f1d90414f36e3317859" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2609,9 +2609,9 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc242db087efc22bd9ade7aa7809e4ba828132edc312871584a6b4391bdf8786" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index 7c9c5de155..4196e415e7 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -23,7 +23,7 @@ sys = [ [dependencies] aes = "=0.8.4" -anyhow = "=1.0.86" +anyhow = "=1.0.93" arboard = { version = "=3.4.1", default-features = false, features = [ "wayland-data-control", ] } @@ -38,7 +38,7 @@ rand = "=0.8.5" retry = "=2.0.0" scopeguard = "=1.2.0" sha2 = "=0.10.8" -thiserror = "=1.0.61" +thiserror = "=1.0.68" tokio = { version = "=1.41.0", features = ["io-util", "sync", "macros"] } tokio-util = "=0.7.12" typenum = "=1.17.0" @@ -61,12 +61,12 @@ windows = { version = "=0.57.0", features = [ keytar = "=0.1.6" [target.'cfg(target_os = "macos")'.dependencies] -core-foundation = { version = "=0.9.4", optional = true } -security-framework = { version = "=2.11.0", optional = true } -security-framework-sys = { version = "=2.11.0", optional = true } +core-foundation = { version = "=0.10.0", optional = true } +security-framework = { version = "=3.0.0", optional = true } +security-framework-sys = { version = "=2.12.0", optional = true } [target.'cfg(target_os = "linux")'.dependencies] gio = { version = "=0.19.5", optional = true } libsecret = { version = "=0.5.0", optional = true } -zbus = { version = "=4.3.1", optional = true } +zbus = { version = "=4.4.0", optional = true } zbus_polkit = { version = "=4.0.0", optional = true } diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 6da4fcb015..64ab106e57 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -14,9 +14,9 @@ default = [] manual_test = [] [dependencies] -anyhow = "=1.0.86" +anyhow = "=1.0.93" desktop_core = { path = "../core" } -napi = { version = "=2.16.11", features = ["async"] } +napi = { version = "=2.16.13", features = ["async"] } napi-derive = "=2.16.12" tokio = { version = "1.38.0" } tokio-util = "0.7.11" diff --git a/apps/desktop/desktop_native/proxy/Cargo.toml b/apps/desktop/desktop_native/proxy/Cargo.toml index 6f8005811d..a1cefca7a3 100644 --- a/apps/desktop/desktop_native/proxy/Cargo.toml +++ b/apps/desktop/desktop_native/proxy/Cargo.toml @@ -7,7 +7,7 @@ version = "0.0.0" publish = false [dependencies] -anyhow = "=1.0.86" +anyhow = "=1.0.93" desktop_core = { path = "../core", default-features = false } futures = "0.3.30" log = "0.4.22" diff --git a/apps/desktop/scripts/after-pack.js b/apps/desktop/scripts/after-pack.js index d4cbc00c81..fd16cd5ffb 100644 --- a/apps/desktop/scripts/after-pack.js +++ b/apps/desktop/scripts/after-pack.js @@ -58,30 +58,46 @@ async function run(context) { id = identities[0].id; } - console.log(`Signing proxy binary before the main bundle, using identity '${id}'`); + console.log( + `Signing proxy binary before the main bundle, using identity '${id}', for build ${context.electronPlatformName}`, + ); const appName = context.packager.appInfo.productFilename; const appPath = `${context.appOutDir}/${appName}.app`; const proxyPath = path.join(appPath, "Contents", "MacOS", "desktop_proxy"); + const inheritProxyPath = path.join(appPath, "Contents", "MacOS", "desktop_proxy.inherit"); const packageId = "com.bitwarden.desktop"; - const entitlementsName = "entitlements.desktop_proxy.plist"; - const entitlementsPath = path.join(__dirname, "..", "resources", entitlementsName); - child_process.execSync( - `codesign -s '${id}' -i ${packageId} -f --timestamp --options runtime --entitlements ${entitlementsPath} ${proxyPath}`, - ); - const inheritProxyPath = path.join(appPath, "Contents", "MacOS", "desktop_proxy.inherit"); - const inheritEntitlementsName = "entitlements.desktop_proxy.inherit.plist"; - const inheritEntitlementsPath = path.join( - __dirname, - "..", - "resources", - inheritEntitlementsName, - ); - child_process.execSync( - `codesign -s '${id}' -i ${packageId} -f --timestamp --options runtime --entitlements ${inheritEntitlementsPath} ${inheritProxyPath}`, - ); + if (is_mas) { + const entitlementsName = "entitlements.desktop_proxy.plist"; + const entitlementsPath = path.join(__dirname, "..", "resources", entitlementsName); + child_process.execSync( + `codesign -s '${id}' -i ${packageId} -f --timestamp --options runtime --entitlements ${entitlementsPath} ${proxyPath}`, + ); + + const inheritEntitlementsName = "entitlements.desktop_proxy.inherit.plist"; + const inheritEntitlementsPath = path.join( + __dirname, + "..", + "resources", + inheritEntitlementsName, + ); + child_process.execSync( + `codesign -s '${id}' -i ${packageId} -f --timestamp --options runtime --entitlements ${inheritEntitlementsPath} ${inheritProxyPath}`, + ); + } else { + // For non-Appstore builds, we don't need the inherit binary as they are not sandboxed, + // but we sign and include it anyway for consistency. It should be removed once DDG supports the proxy directly. + const entitlementsName = "entitlements.mac.plist"; + const entitlementsPath = path.join(__dirname, "..", "resources", entitlementsName); + child_process.execSync( + `codesign -s '${id}' -i ${packageId} -f --timestamp --options runtime --entitlements ${entitlementsPath} ${proxyPath}`, + ); + child_process.execSync( + `codesign -s '${id}' -i ${packageId} -f --timestamp --options runtime --entitlements ${entitlementsPath} ${inheritProxyPath}`, + ); + } } } diff --git a/apps/desktop/src/auth/login/login-v1.component.ts b/apps/desktop/src/auth/login/login-v1.component.ts index 6eb069d9bc..132b430f32 100644 --- a/apps/desktop/src/auth/login/login-v1.component.ts +++ b/apps/desktop/src/auth/login/login-v1.component.ts @@ -1,7 +1,7 @@ import { Component, NgZone, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { Subject, takeUntil, tap } from "rxjs"; import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component"; import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; @@ -14,8 +14,10 @@ import { import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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"; @@ -76,6 +78,7 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe webAuthnLoginService: WebAuthnLoginServiceAbstraction, registerRouteService: RegisterRouteService, toastService: ToastService, + private configService: ConfigService, ) { super( devicesApiService, @@ -105,6 +108,8 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe } async ngOnInit() { + this.listenForUnauthUiRefreshFlagChanges(); + await super.ngOnInit(); await this.getLoginWithDevice(this.loggedEmail); this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { @@ -137,6 +142,29 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe this.componentDestroyed$.complete(); } + private listenForUnauthUiRefreshFlagChanges() { + this.configService + .getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh) + .pipe( + tap(async (flag) => { + // If the flag is turned ON, we must force a reload to ensure the correct UI is shown + if (flag) { + const uniqueQueryParams = { + ...this.route.queryParams, + // adding a unique timestamp to the query params to force a reload + t: new Date().getTime().toString(), + }; + + await this.router.navigate(["/"], { + queryParams: uniqueQueryParams, + }); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + async settings() { const [modal, childComponent] = await this.modalService.openViewRef( EnvironmentComponent, diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index 6e7282b9a8..070da06de0 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -1684,10 +1684,10 @@ "message": "Die Kontolöschung ist dauerhaft. Sie kann nicht rückgängig gemacht werden." }, "cannotDeleteAccount": { - "message": "Cannot delete account" + "message": "Konto kann nicht gelöscht werden" }, "cannotDeleteAccountDesc": { - "message": "This action cannot be completed because your account is owned by an organization. Contact your organization administrator for additional details." + "message": "Diese Aktion kann nicht abgeschlossen werden, da dein Konto im Besitz einer Organisation ist. Kontaktiere deinen Organisationsadministrator für weitere Details." }, "accountDeleted": { "message": "Konto gelöscht" @@ -2394,7 +2394,7 @@ "message": "E-Mail generieren" }, "generatorBoundariesHint": { - "message": "Value must be between $MIN$ and $MAX$", + "message": "Wert muss zwischen $MIN$ und $MAX$ liegen", "description": "Explains spin box minimum and maximum values to the user", "placeholders": { "min": { diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 063a63b1ac..cf6025e042 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -1800,7 +1800,7 @@ "description": "Used as a card title description on the set password page to explain why the user is there" }, "cardMetrics": { - "message": "out of $TOTAL$", + "message": "/$TOTAL$", "placeholders": { "total": { "content": "$1", diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index 23e9c6df17..9cd94c5208 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -40,9 +40,9 @@ > ; hideNewOrgButton$: Observable; organizationIsUnmanaged$: Observable; - isAccessIntelligenceFeatureEnabled = false; + isRiskInsightsFeatureEnabled = false; private _destroy = new Subject(); @@ -71,7 +71,7 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy { async ngOnInit() { document.body.classList.remove("layout_frontend"); - this.isAccessIntelligenceFeatureEnabled = await this.configService.getFeatureFlag( + this.isRiskInsightsFeatureEnabled = await this.configService.getFeatureFlag( FeatureFlag.AccessIntelligence, ); diff --git a/apps/web/src/app/admin-console/organizations/organization-routing.module.ts b/apps/web/src/app/admin-console/organizations/organization-routing.module.ts index a36b267e2f..1725148d47 100644 --- a/apps/web/src/app/admin-console/organizations/organization-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/organization-routing.module.ts @@ -63,10 +63,10 @@ const routes: Routes = [ ), }, { - path: "access-intelligence", + path: "risk-insights", loadChildren: () => - import("../../tools/access-intelligence/access-intelligence.module").then( - (m) => m.AccessIntelligenceModule, + import("../../tools/risk-insights/risk-insights.module").then( + (m) => m.RiskInsightsModule, ), }, { diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.ts b/apps/web/src/app/admin-console/organizations/settings/account.component.ts index ffcf0cef9c..2d97f95e51 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.ts @@ -123,20 +123,22 @@ export class AccountComponent implements OnInit, OnDestroy { this.canEditSubscription = organization.canEditSubscription; this.canUseApi = organization.useApi; - // Update disabled states - reactive forms prefers not using disabled attribute // Disabling these fields for self hosted orgs is deprecated // This block can be completely removed as part of // https://bitwarden.atlassian.net/browse/PM-10863 if (!this.limitCollectionCreationDeletionSplitFeatureFlagIsEnabled) { if (!this.selfHosted) { - this.formGroup.get("orgName").enable(); this.collectionManagementFormGroup.get("limitCollectionCreationDeletion").enable(); this.collectionManagementFormGroup.get("allowAdminAccessToAllCollectionItems").enable(); } } - if (!this.selfHosted && this.canEditSubscription) { - this.formGroup.get("billingEmail").enable(); + // Update disabled states - reactive forms prefers not using disabled attribute + if (!this.selfHosted) { + this.formGroup.get("orgName").enable(); + if (this.canEditSubscription) { + this.formGroup.get("billingEmail").enable(); + } } // Org Response diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index eeb64ffe77..1c1382cd81 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -48,16 +48,7 @@ }}
{{ "nextCharge" | i18n }}
-
- {{ - nextInvoice - ? (nextInvoice.date | date: "mediumDate") + - ", " + - (nextInvoice.amount | currency: "$") - : "-" - }} -
-
+
{{ nextInvoice ? (sub.subscription.periodEndDate | date: "mediumDate") + diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index e04b7c8b01..942767946b 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -38,13 +38,9 @@ export class UserSubscriptionComponent implements OnInit { sub: SubscriptionResponse; selfHosted = false; cloudWebVaultUrl: string; - enableTimeThreshold: boolean; cancelPromise: Promise; reinstatePromise: Promise; - protected enableTimeThreshold$ = this.configService.getFeatureFlag$( - FeatureFlag.EnableTimeThreshold, - ); protected deprecateStripeSourcesAPI$ = this.configService.getFeatureFlag$( FeatureFlag.AC2476_DeprecateStripeSourcesAPI, @@ -69,7 +65,6 @@ export class UserSubscriptionComponent implements OnInit { async ngOnInit() { this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$); await this.load(); - this.enableTimeThreshold = await firstValueFrom(this.enableTimeThreshold$); this.firstLoaded = true; } diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index cd95e88704..0cd21d0f68 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -48,10 +48,7 @@
{{ "subscriptionExpiration" | i18n }}
-
- {{ nextInvoice ? (nextInvoice.date | date: "mediumDate") : "-" }} -
-
+
{{ nextInvoice ? (sub.subscription.periodEndDate | date: "mediumDate") : "-" }}
diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 1000084df9..d4d11d91e0 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -52,7 +52,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy loading = true; locale: string; showUpdatedSubscriptionStatusSection$: Observable; - enableTimeThreshold: boolean; preSelectedProductTier: ProductTierType = ProductTierType.Free; showSubscription = true; showSelfHost = false; @@ -65,10 +64,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy FeatureFlag.EnableConsolidatedBilling, ); - protected enableTimeThreshold$ = this.configService.getFeatureFlag$( - FeatureFlag.EnableTimeThreshold, - ); - protected enableUpgradePasswordManagerSub$ = this.configService.getFeatureFlag$( FeatureFlag.EnableUpgradePasswordManagerSub, ); @@ -117,7 +112,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy this.showUpdatedSubscriptionStatusSection$ = this.configService.getFeatureFlag$( FeatureFlag.AC1795_UpdatedSubscriptionStatusSection, ); - this.enableTimeThreshold = await firstValueFrom(this.enableTimeThreshold$); } ngOnDestroy() { @@ -298,9 +292,6 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return this.i18nService.t("subscriptionUpgrade", this.sub.seats.toString()); } } else if (this.sub.maxAutoscaleSeats === this.sub.seats && this.sub.seats != null) { - if (!this.enableTimeThreshold) { - return this.i18nService.t("subscriptionMaxReached", this.sub.seats.toString()); - } const seatAdjustmentMessage = this.sub.plan.isAnnual ? "annualSubscriptionUserSeatsMessage" : "monthlySubscriptionUserSeatsMessage"; @@ -311,21 +302,11 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } else if (this.userOrg.productTierType === ProductTierType.TeamsStarter) { return this.i18nService.t("subscriptionUserSeatsWithoutAdditionalSeatsOption", 10); } else if (this.sub.maxAutoscaleSeats == null) { - if (!this.enableTimeThreshold) { - return this.i18nService.t("subscriptionUserSeatsUnlimitedAutoscale"); - } - const seatAdjustmentMessage = this.sub.plan.isAnnual ? "annualSubscriptionUserSeatsMessage" : "monthlySubscriptionUserSeatsMessage"; return this.i18nService.t(seatAdjustmentMessage); } else { - if (!this.enableTimeThreshold) { - return this.i18nService.t( - "subscriptionUserSeatsLimitedAutoscale", - this.sub.maxAutoscaleSeats.toString(), - ); - } const seatAdjustmentMessage = this.sub.plan.isAnnual ? "annualSubscriptionUserSeatsMessage" : "monthlySubscriptionUserSeatsMessage"; diff --git a/apps/web/src/app/tools/access-intelligence/access-intelligence.module.ts b/apps/web/src/app/tools/access-intelligence/access-intelligence.module.ts deleted file mode 100644 index 32b66935b6..0000000000 --- a/apps/web/src/app/tools/access-intelligence/access-intelligence.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NgModule } from "@angular/core"; - -import { AccessIntelligenceRoutingModule } from "./access-intelligence-routing.module"; -import { AccessIntelligenceComponent } from "./access-intelligence.component"; - -@NgModule({ - imports: [AccessIntelligenceComponent, AccessIntelligenceRoutingModule], -}) -export class AccessIntelligenceModule {} diff --git a/apps/web/src/app/tools/access-intelligence/password-health-members.component.html b/apps/web/src/app/tools/access-intelligence/password-health-members.component.html deleted file mode 100644 index 611443c993..0000000000 --- a/apps/web/src/app/tools/access-intelligence/password-health-members.component.html +++ /dev/null @@ -1,120 +0,0 @@ -

{{ "passwordsReportDesc" | i18n }}

-
- - {{ "loading" | i18n }} -
-
-
- - - - -
-
- - -
-
-
- - - - -
-
- - -
- - - - - {{ "name" | i18n }} - {{ "weakness" | i18n }} - {{ "timesReused" | i18n }} - {{ "timesExposed" | i18n }} - {{ "totalMembers" | i18n }} - - - - - - - - - - {{ r.name }} - -
- {{ r.subTitle }} - - - - {{ passwordStrengthMap.get(r.id)[0] | i18n }} - - - - - {{ "reusedXTimes" | i18n: passwordUseMap.get(r.login.password) }} - - - - - {{ "exposedXTimes" | i18n: exposedPasswordMap.get(r.id) }} - - - - {{ totalMembersMap.get(r.id) || 0 }} - - -
-
-
-
diff --git a/apps/web/src/app/tools/access-intelligence/all-applications.component.html b/apps/web/src/app/tools/risk-insights/all-applications.component.html similarity index 95% rename from apps/web/src/app/tools/access-intelligence/all-applications.component.html rename to apps/web/src/app/tools/risk-insights/all-applications.component.html index 5dfaa20240..4ed31adea7 100644 --- a/apps/web/src/app/tools/access-intelligence/all-applications.component.html +++ b/apps/web/src/app/tools/risk-insights/all-applications.component.html @@ -57,6 +57,7 @@ type="button" buttonType="secondary" bitButton + *ngIf="isCritialAppsFeatureEnabled" [disabled]="!selectedIds.size" [loading]="markingAsCritical" (click)="markAppsAsCritical()" @@ -68,7 +69,7 @@ - + {{ "application" | i18n }} {{ "atRiskPasswords" | i18n }} {{ "totalPasswords" | i18n }} @@ -78,7 +79,7 @@ - + { - await this.router.navigate([`organizations/${this.organizationId}/access-intelligence`], { - queryParams: { tabIndex: AccessIntelligenceTabType.AllApps }, + await this.router.navigate([`organizations/${this.organizationId}/risk-insights`], { + queryParams: { tabIndex: RiskInsightsTabType.AllApps }, queryParamsHandling: "merge", }); }; diff --git a/apps/web/src/app/tools/access-intelligence/notified-members-table.component.html b/apps/web/src/app/tools/risk-insights/notified-members-table.component.html similarity index 100% rename from apps/web/src/app/tools/access-intelligence/notified-members-table.component.html rename to apps/web/src/app/tools/risk-insights/notified-members-table.component.html diff --git a/apps/web/src/app/tools/access-intelligence/notified-members-table.component.ts b/apps/web/src/app/tools/risk-insights/notified-members-table.component.ts similarity index 100% rename from apps/web/src/app/tools/access-intelligence/notified-members-table.component.ts rename to apps/web/src/app/tools/risk-insights/notified-members-table.component.ts diff --git a/apps/web/src/app/tools/access-intelligence/password-health-members-uri.component.html b/apps/web/src/app/tools/risk-insights/password-health-members-uri.component.html similarity index 100% rename from apps/web/src/app/tools/access-intelligence/password-health-members-uri.component.html rename to apps/web/src/app/tools/risk-insights/password-health-members-uri.component.html diff --git a/apps/web/src/app/tools/access-intelligence/password-health-members-uri.component.spec.ts b/apps/web/src/app/tools/risk-insights/password-health-members-uri.component.spec.ts similarity index 98% rename from apps/web/src/app/tools/access-intelligence/password-health-members-uri.component.spec.ts rename to apps/web/src/app/tools/risk-insights/password-health-members-uri.component.spec.ts index 376ae16e23..b34730bd32 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health-members-uri.component.spec.ts +++ b/apps/web/src/app/tools/risk-insights/password-health-members-uri.component.spec.ts @@ -4,7 +4,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; // eslint-disable-next-line no-restricted-imports -import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/risk-insights"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; diff --git a/apps/web/src/app/tools/access-intelligence/password-health-members-uri.component.ts b/apps/web/src/app/tools/risk-insights/password-health-members-uri.component.ts similarity index 99% rename from apps/web/src/app/tools/access-intelligence/password-health-members-uri.component.ts rename to apps/web/src/app/tools/risk-insights/password-health-members-uri.component.ts index 0059964f41..c977c82953 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health-members-uri.component.ts +++ b/apps/web/src/app/tools/risk-insights/password-health-members-uri.component.ts @@ -6,7 +6,7 @@ import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; // eslint-disable-next-line no-restricted-imports -import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/risk-insights"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; diff --git a/apps/web/src/app/tools/risk-insights/password-health-members.component.html b/apps/web/src/app/tools/risk-insights/password-health-members.component.html new file mode 100644 index 0000000000..7f9b37f2a8 --- /dev/null +++ b/apps/web/src/app/tools/risk-insights/password-health-members.component.html @@ -0,0 +1,64 @@ +

{{ "passwordsReportDesc" | i18n }}

+
+ + {{ "loading" | i18n }} +
+
+ + + + + {{ "name" | i18n }} + {{ "weakness" | i18n }} + {{ "timesReused" | i18n }} + {{ "timesExposed" | i18n }} + {{ "totalMembers" | i18n }} + + + + + + + + + + {{ r.name }} + +
+ {{ r.subTitle }} + + + + {{ passwordStrengthMap.get(r.id)[0] | i18n }} + + + + + {{ "reusedXTimes" | i18n: passwordUseMap.get(r.login.password) }} + + + + + {{ "exposedXTimes" | i18n: exposedPasswordMap.get(r.id) }} + + + + {{ totalMembersMap.get(r.id) || 0 }} + + +
+
+
diff --git a/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts b/apps/web/src/app/tools/risk-insights/password-health-members.component.ts similarity index 99% rename from apps/web/src/app/tools/access-intelligence/password-health-members.component.ts rename to apps/web/src/app/tools/risk-insights/password-health-members.component.ts index 9d457013e8..2581de78ed 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health-members.component.ts +++ b/apps/web/src/app/tools/risk-insights/password-health-members.component.ts @@ -5,7 +5,7 @@ import { ActivatedRoute } from "@angular/router"; import { debounceTime, map } from "rxjs"; // eslint-disable-next-line no-restricted-imports -import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/risk-insights"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; diff --git a/apps/web/src/app/tools/access-intelligence/password-health.component.html b/apps/web/src/app/tools/risk-insights/password-health.component.html similarity index 100% rename from apps/web/src/app/tools/access-intelligence/password-health.component.html rename to apps/web/src/app/tools/risk-insights/password-health.component.html diff --git a/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts b/apps/web/src/app/tools/risk-insights/password-health.component.spec.ts similarity index 98% rename from apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts rename to apps/web/src/app/tools/risk-insights/password-health.component.spec.ts index d41807e7d2..50295b435b 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health.component.spec.ts +++ b/apps/web/src/app/tools/risk-insights/password-health.component.spec.ts @@ -4,7 +4,7 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; // eslint-disable-next-line no-restricted-imports -import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/risk-insights"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; diff --git a/apps/web/src/app/tools/access-intelligence/password-health.component.ts b/apps/web/src/app/tools/risk-insights/password-health.component.ts similarity index 98% rename from apps/web/src/app/tools/access-intelligence/password-health.component.ts rename to apps/web/src/app/tools/risk-insights/password-health.component.ts index 4b7b8e394d..c3c1732854 100644 --- a/apps/web/src/app/tools/access-intelligence/password-health.component.ts +++ b/apps/web/src/app/tools/risk-insights/password-health.component.ts @@ -6,7 +6,7 @@ import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; // eslint-disable-next-line no-restricted-imports -import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/access-intelligence"; +import { PasswordHealthService } from "@bitwarden/bit-common/tools/reports/risk-insights"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; diff --git a/apps/web/src/app/tools/access-intelligence/access-intelligence-routing.module.ts b/apps/web/src/app/tools/risk-insights/risk-insights-routing.module.ts similarity index 70% rename from apps/web/src/app/tools/access-intelligence/access-intelligence-routing.module.ts rename to apps/web/src/app/tools/risk-insights/risk-insights-routing.module.ts index 88efb2b483..19cc6f6832 100644 --- a/apps/web/src/app/tools/access-intelligence/access-intelligence-routing.module.ts +++ b/apps/web/src/app/tools/risk-insights/risk-insights-routing.module.ts @@ -4,15 +4,15 @@ import { RouterModule, Routes } from "@angular/router"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { AccessIntelligenceComponent } from "./access-intelligence.component"; +import { RiskInsightsComponent } from "./risk-insights.component"; const routes: Routes = [ { path: "", - component: AccessIntelligenceComponent, + component: RiskInsightsComponent, canActivate: [canAccessFeature(FeatureFlag.AccessIntelligence)], data: { - titleId: "accessIntelligence", + titleId: "RiskInsights", }, }, ]; @@ -21,4 +21,4 @@ const routes: Routes = [ imports: [RouterModule.forChild(routes)], exports: [RouterModule], }) -export class AccessIntelligenceRoutingModule {} +export class RiskInsightsRoutingModule {} diff --git a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html b/apps/web/src/app/tools/risk-insights/risk-insights.component.html similarity index 92% rename from apps/web/src/app/tools/access-intelligence/access-intelligence.component.html rename to apps/web/src/app/tools/risk-insights/risk-insights.component.html index 0f62a43464..c2cd0cac70 100644 --- a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.html +++ b/apps/web/src/app/tools/risk-insights/risk-insights.component.html @@ -1,4 +1,4 @@ -
{{ "accessIntelligence" | i18n }}
+
{{ "riskInsights" | i18n }}

{{ "passwordRisk" | i18n }}

{{ "discoverAtRiskPasswords" | i18n }}
@@ -19,7 +19,7 @@ - + {{ "criticalApplicationsWithCount" | i18n: criticalApps.length }} diff --git a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.ts b/apps/web/src/app/tools/risk-insights/risk-insights.component.ts similarity index 74% rename from apps/web/src/app/tools/access-intelligence/access-intelligence.component.ts rename to apps/web/src/app/tools/risk-insights/risk-insights.component.ts index 557ae73625..1c6a36b445 100644 --- a/apps/web/src/app/tools/access-intelligence/access-intelligence.component.ts +++ b/apps/web/src/app/tools/risk-insights/risk-insights.component.ts @@ -1,9 +1,11 @@ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { AsyncActionsModule, ButtonModule, TabsModule } from "@bitwarden/components"; import { HeaderModule } from "../../layouts/header/header.module"; @@ -15,7 +17,7 @@ import { PasswordHealthMembersURIComponent } from "./password-health-members-uri import { PasswordHealthMembersComponent } from "./password-health-members.component"; import { PasswordHealthComponent } from "./password-health.component"; -export enum AccessIntelligenceTabType { +export enum RiskInsightsTabType { AllApps = 0, CriticalApps = 1, NotifiedMembers = 2, @@ -23,7 +25,7 @@ export enum AccessIntelligenceTabType { @Component({ standalone: true, - templateUrl: "./access-intelligence.component.html", + templateUrl: "./risk-insights.component.html", imports: [ AllApplicationsComponent, AsyncActionsModule, @@ -39,9 +41,10 @@ export enum AccessIntelligenceTabType { TabsModule, ], }) -export class AccessIntelligenceComponent { - tabIndex: AccessIntelligenceTabType; +export class RiskInsightsComponent implements OnInit { + tabIndex: RiskInsightsTabType; dataLastUpdated = new Date(); + isCritialAppsFeatureEnabled = false; apps: any[] = []; criticalApps: any[] = []; @@ -65,12 +68,19 @@ export class AccessIntelligenceComponent { }); }; + async ngOnInit() { + this.isCritialAppsFeatureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.CriticalApps, + ); + } + constructor( protected route: ActivatedRoute, private router: Router, + private configService: ConfigService, ) { route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => { - this.tabIndex = !isNaN(tabIndex) ? tabIndex : AccessIntelligenceTabType.AllApps; + this.tabIndex = !isNaN(tabIndex) ? tabIndex : RiskInsightsTabType.AllApps; }); } } diff --git a/apps/web/src/app/tools/risk-insights/risk-insights.module.ts b/apps/web/src/app/tools/risk-insights/risk-insights.module.ts new file mode 100644 index 0000000000..23d3cd8089 --- /dev/null +++ b/apps/web/src/app/tools/risk-insights/risk-insights.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from "@angular/core"; + +import { RiskInsightsRoutingModule } from "./risk-insights-routing.module"; +import { RiskInsightsComponent } from "./risk-insights.component"; + +@NgModule({ + imports: [RiskInsightsComponent, RiskInsightsRoutingModule], +}) +export class RiskInsightsModule {} diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index ccf853837c..56acc421de 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -7,6 +7,7 @@ *ngIf="showCipherView" [cipher]="cipher" [collections]="collections" + [isAdminConsole]="formConfig.isAdminConsole" > - {{ "name" | i18n }} + + + {{ "name" | i18n }} + + + + {{ "name" | i18n }} + {{ "owner" | i18n }} {{ "collections" | i18n }} - {{ "groups" | i18n }} - + + {{ "groups" | i18n }} + + {{ "permission" | i18n }} diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 71a97f1ff4..9f19a0319a 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -1,13 +1,17 @@ import { SelectionModel } from "@angular/cdk/collections"; import { Component, EventEmitter, Input, Output } from "@angular/core"; -import { CollectionView, Unassigned } from "@bitwarden/admin-console/common"; +import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { TableDataSource } from "@bitwarden/components"; +import { SortDirection, TableDataSource } from "@bitwarden/components"; import { GroupView } from "../../../admin-console/organizations/core"; +import { + CollectionPermission, + convertToPermission, +} from "./../../../admin-console/organizations/shared/components/access-selector/access-selector.models"; import { VaultItem } from "./vault-item"; import { VaultItemEvent } from "./vault-item-event"; @@ -17,6 +21,8 @@ export const RowHeightClass = `tw-h-[65px]`; const MaxSelectionCount = 500; +type ItemPermission = CollectionPermission | "NoAccess"; + @Component({ selector: "app-vault-items", templateUrl: "vault-items.component.html", @@ -333,6 +339,119 @@ export class VaultItemsComponent { return (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && collectionNotSelected; } + /** + * Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name. + */ + protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + // Collections before ciphers + const collectionCompare = this.prioritizeCollections(a, b, direction); + if (collectionCompare !== 0) { + return collectionCompare; + } + + return this.compareNames(a, b); + }; + + /** + * Sorts VaultItems based on group names + */ + protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + if ( + !(a.collection instanceof CollectionAdminView) && + !(b.collection instanceof CollectionAdminView) + ) { + return 0; + } + + const getFirstGroupName = (collection: CollectionAdminView): string => { + if (collection.groups.length > 0) { + return collection.groups.map((group) => this.getGroupName(group.id) || "").sort()[0]; + } + return null; + }; + + // Collections before ciphers + const collectionCompare = this.prioritizeCollections(a, b, direction); + if (collectionCompare !== 0) { + return collectionCompare; + } + + const aGroupName = getFirstGroupName(a.collection as CollectionAdminView); + const bGroupName = getFirstGroupName(b.collection as CollectionAdminView); + + // Collections with groups come before collections without groups. + // If a collection has no groups, getFirstGroupName returns null. + if (aGroupName === null) { + return 1; + } + + if (bGroupName === null) { + return -1; + } + + return aGroupName.localeCompare(bGroupName); + }; + + /** + * Sorts VaultItems based on their permissions, with higher permissions taking precedence. + * If permissions are equal, it falls back to sorting by name. + */ + protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + const getPermissionPriority = (item: VaultItem): number => { + const permission = item.collection + ? this.getCollectionPermission(item.collection) + : this.getCipherPermission(item.cipher); + + const priorityMap = { + [CollectionPermission.Manage]: 5, + [CollectionPermission.Edit]: 4, + [CollectionPermission.EditExceptPass]: 3, + [CollectionPermission.View]: 2, + [CollectionPermission.ViewExceptPass]: 1, + NoAccess: 0, + }; + + return priorityMap[permission] ?? -1; + }; + + // Collections before ciphers + const collectionCompare = this.prioritizeCollections(a, b, direction); + if (collectionCompare !== 0) { + return collectionCompare; + } + + const priorityA = getPermissionPriority(a); + const priorityB = getPermissionPriority(b); + + // Higher priority first + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + + return this.compareNames(a, b); + }; + + private compareNames(a: VaultItem, b: VaultItem): number { + const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; + return getName(a).localeCompare(getName(b)); + } + + /** + * Sorts VaultItems by prioritizing collections over ciphers. + * Collections are always placed before ciphers, regardless of the sorting direction. + */ + private prioritizeCollections(a: VaultItem, b: VaultItem, direction: SortDirection): number { + if (a.collection && !b.collection) { + return direction === "asc" ? -1 : 1; + } + + if (!a.collection && b.collection) { + return direction === "asc" ? 1 : -1; + } + + return 0; + } + private hasPersonalItems(): boolean { return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null); } @@ -346,4 +465,58 @@ export class VaultItemsComponent { private getUniqueOrganizationIds(): Set { return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? [])); } + + private getGroupName(groupId: string): string | undefined { + return this.allGroups.find((g) => g.id === groupId)?.name; + } + + private getCollectionPermission(collection: CollectionView): ItemPermission { + const organization = this.allOrganizations.find((o) => o.id === collection.organizationId); + + if (collection.id == Unassigned && organization?.canEditUnassignedCiphers) { + return CollectionPermission.Edit; + } + + if (collection.assigned) { + return convertToPermission(collection); + } + + return "NoAccess"; + } + + private getCipherPermission(cipher: CipherView): ItemPermission { + if (!cipher.organizationId || cipher.collectionIds.length === 0) { + return CollectionPermission.Manage; + } + + const filteredCollections = this.allCollections?.filter((collection) => { + if (collection.assigned) { + return cipher.collectionIds.find((id) => { + if (collection.id === id) { + return collection; + } + }); + } + }); + + if (filteredCollections?.length === 1) { + return convertToPermission(filteredCollections[0]); + } + + if (filteredCollections?.length > 0) { + const permissions = filteredCollections.map((collection) => convertToPermission(collection)); + + const orderedPermissions = [ + CollectionPermission.Manage, + CollectionPermission.Edit, + CollectionPermission.EditExceptPass, + CollectionPermission.View, + CollectionPermission.ViewExceptPass, + ]; + + return orderedPermissions.find((perm) => permissions.includes(perm)); + } + + return "NoAccess"; + } } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts index 3b7db72a09..57eb9b1bdd 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/organization-options.component.ts @@ -1,5 +1,5 @@ import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; -import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs"; +import { combineLatest, map, Observable, of, Subject, switchMap, takeUntil } from "rxjs"; import { OrganizationUserApiService, @@ -8,11 +8,14 @@ import { import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -53,6 +56,8 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy { private resetPasswordService: OrganizationUserResetPasswordService, private userVerificationService: UserVerificationService, private toastService: ToastService, + private configService: ConfigService, + private organizationService: OrganizationService, ) {} async ngOnInit() { @@ -60,23 +65,39 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy { map((policies) => policies.filter((policy) => policy.type === PolicyType.ResetPassword)), ); + const managingOrg$ = this.configService + .getFeatureFlag$(FeatureFlag.AccountDeprovisioning) + .pipe( + switchMap((isAccountDeprovisioningEnabled) => + isAccountDeprovisioningEnabled + ? this.organizationService.organizations$.pipe( + map((organizations) => + organizations.find((o) => o.userIsManagedByOrganization === true), + ), + ) + : of(null), + ), + ); + combineLatest([ this.organization$, resetPasswordPolicies$, this.userDecryptionOptionsService.userDecryptionOptions$, + managingOrg$, ]) .pipe(takeUntil(this.destroy$)) - .subscribe(([organization, resetPasswordPolicies, decryptionOptions]) => { + .subscribe(([organization, resetPasswordPolicies, decryptionOptions, managingOrg]) => { this.organization = organization; this.resetPasswordPolicy = resetPasswordPolicies.find( (p) => p.organizationId === organization.id, ); - // A user can leave an organization if they are NOT using TDE and Key Connector, or they have a master password. + // A user can leave an organization if they are NOT a managed user and they are NOT using TDE and Key Connector, or they have a master password. this.showLeaveOrgOption = - (decryptionOptions.trustedDeviceOption == undefined && + managingOrg?.id !== organization.id && + ((decryptionOptions.trustedDeviceOption == undefined && decryptionOptions.keyConnectorOption == undefined) || - decryptionOptions.hasMasterPassword; + decryptionOptions.hasMasterPassword); // Hide the 3 dot menu if the user has no available actions this.hideMenu = diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts index 02d280f5ff..05c40fe2e7 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.spec.ts @@ -1,14 +1,13 @@ import { TestBed } from "@angular/core/testing"; import { BehaviorSubject } from "rxjs"; -import { CollectionAdminService } from "@bitwarden/admin-console/common"; +import { CollectionAdminService, CollectionAdminView } from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.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 { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; @@ -35,27 +34,41 @@ describe("AdminConsoleCipherFormConfigService", () => { status: OrganizationUserStatusType.Confirmed, }; const policyAppliesToActiveUser$ = new BehaviorSubject(true); + const collection = { + id: "12345-5555", + organizationId: "234534-34334", + name: "Test Collection 1", + assigned: false, + readOnly: true, + } as CollectionAdminView; + const collection2 = { + id: "12345-6666", + organizationId: "22222-2222", + name: "Test Collection 2", + assigned: true, + readOnly: false, + } as CollectionAdminView; + const organization$ = new BehaviorSubject(testOrg as Organization); const organizations$ = new BehaviorSubject([testOrg, testOrg2] as Organization[]); const getCipherAdmin = jest.fn().mockResolvedValue(null); - const getCipher = jest.fn().mockResolvedValue(null); beforeEach(async () => { getCipherAdmin.mockClear(); - getCipher.mockClear(); - getCipher.mockResolvedValue({ id: cipherId, name: "Test Cipher - (non-admin)" }); getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" }); await TestBed.configureTestingModule({ providers: [ AdminConsoleCipherFormConfigService, + { provide: OrganizationService, useValue: { get$: () => organization$, organizations$ } }, + { + provide: CollectionAdminService, + useValue: { getAll: () => Promise.resolve([collection, collection2]) }, + }, { provide: PolicyService, useValue: { policyAppliesToActiveUser$: () => policyAppliesToActiveUser$ }, }, - { provide: OrganizationService, useValue: { get$: () => organization$, organizations$ } }, - { provide: CipherService, useValue: { get: getCipher } }, - { provide: CollectionAdminService, useValue: { getAll: () => Promise.resolve([]) } }, { provide: RoutedVaultFilterService, useValue: { filter$: new BehaviorSubject({ organizationId: testOrg.id }) }, @@ -86,6 +99,12 @@ describe("AdminConsoleCipherFormConfigService", () => { expect(mode).toBe("edit"); }); + it("returns all collections", async () => { + const { collections } = await adminConsoleConfigService.buildConfig("edit", cipherId); + + expect(collections).toEqual([collection, collection2]); + }); + it("sets admin flag based on `canEditAllCiphers`", async () => { // Disable edit all ciphers on org testOrg.canEditAllCiphers = false; @@ -153,33 +172,14 @@ describe("AdminConsoleCipherFormConfigService", () => { expect(result.organizations).toEqual([testOrg, testOrg2]); }); - describe("getCipher", () => { - it("retrieves the cipher from the cipher service", async () => { - testOrg.canEditAllCiphers = false; + it("retrieves the cipher from the admin service", async () => { + getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" }); - adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); + adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); - const result = await adminConsoleConfigService.buildConfig("clone", cipherId); + await adminConsoleConfigService.buildConfig("add", cipherId); - expect(getCipher).toHaveBeenCalledWith(cipherId); - expect(result.originalCipher.name).toBe("Test Cipher - (non-admin)"); - - // Admin service not needed when cipher service can return the cipher - expect(getCipherAdmin).not.toHaveBeenCalled(); - }); - - it("retrieves the cipher from the admin service", async () => { - getCipher.mockResolvedValueOnce(null); - getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" }); - - adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService); - - await adminConsoleConfigService.buildConfig("add", cipherId); - - expect(getCipherAdmin).toHaveBeenCalledWith(cipherId); - - expect(getCipher).toHaveBeenCalledWith(cipherId); - }); + expect(getCipherAdmin).toHaveBeenCalledWith(cipherId); }); }); }); diff --git a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts index 328ab4475d..457b4e83d0 100644 --- a/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts +++ b/apps/web/src/app/vault/org-vault/services/admin-console-cipher-form-config.service.ts @@ -6,9 +6,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.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 { PolicyType, OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { CipherId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -25,7 +23,6 @@ import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/se export class AdminConsoleCipherFormConfigService implements CipherFormConfigService { private policyService: PolicyService = inject(PolicyService); private organizationService: OrganizationService = inject(OrganizationService); - private cipherService: CipherService = inject(CipherService); private routedVaultFilterService: RoutedVaultFilterService = inject(RoutedVaultFilterService); private collectionAdminService: CollectionAdminService = inject(CollectionAdminService); private apiService: ApiService = inject(ApiService); @@ -51,20 +48,8 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ map(([orgs, orgId]) => orgs.find((o) => o.id === orgId)), ); - private editableCollections$ = this.organization$.pipe( - switchMap(async (org) => { - if (!org) { - return []; - } - - const collections = await this.collectionAdminService.getAll(org.id); - // Users that can edit all ciphers can implicitly add to / edit within any collection - if (org.canEditAllCiphers) { - return collections; - } - // The user is only allowed to add/edit items to assigned collections that are not readonly - return collections.filter((c) => c.assigned && !c.readOnly); - }), + private allCollections$ = this.organization$.pipe( + switchMap(async (org) => await this.collectionAdminService.getAll(org.id)), ); async buildConfig( @@ -72,21 +57,17 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ cipherId?: CipherId, cipherType?: CipherType, ): Promise { + const cipher = await this.getCipher(cipherId); const [organization, allowPersonalOwnership, allOrganizations, allCollections] = await firstValueFrom( combineLatest([ this.organization$, this.allowPersonalOwnership$, this.allOrganizations$, - this.editableCollections$, + this.allCollections$, ]), ); - const cipher = await this.getCipher(organization, cipherId); - - const collections = allCollections.filter( - (c) => c.organizationId === organization.id && c.assigned && !c.readOnly, - ); // When cloning from within the Admin Console, all organizations should be available. // Otherwise only the one in context should be const organizations = mode === "clone" ? allOrganizations : [organization]; @@ -100,7 +81,7 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ admin: organization.canEditAllCiphers ?? false, allowPersonalOwnership: allowPersonalOwnershipOnlyForClone, originalCipher: cipher, - collections, + collections: allCollections, organizations, folders: [], // folders not applicable in the admin console hideIndividualVaultFields: true, @@ -108,19 +89,11 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ }; } - private async getCipher(organization: Organization, id?: CipherId): Promise { + private async getCipher(id?: CipherId): Promise { if (id == null) { return Promise.resolve(null); } - // Check to see if the user has direct access to the cipher - const cipherFromCipherService = await this.cipherService.get(id); - - // If the organization doesn't allow admin/owners to edit all ciphers return the cipher - if (!organization.canEditAllCiphers && cipherFromCipherService != null) { - return cipherFromCipherService; - } - // Retrieve the cipher through the means of an admin const cipherResponse = await this.apiService.getCipherAdmin(id); cipherResponse.edit = true; diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index d34c71757e..6ecc27b201 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5,8 +5,8 @@ "criticalApplications": { "message": "Critical applications" }, - "accessIntelligence": { - "message": "Access Intelligence" + "riskInsights": { + "message": "Risk Insights" }, "passwordRisk": { "message": "Password Risk" diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/index.ts similarity index 100% rename from bitwarden_license/bit-common/src/tools/reports/access-intelligence/index.ts rename to bitwarden_license/bit-common/src/tools/reports/risk-insights/index.ts diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/response/member-cipher-details.response.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/response/member-cipher-details.response.ts similarity index 100% rename from bitwarden_license/bit-common/src/tools/reports/access-intelligence/response/member-cipher-details.response.ts rename to bitwarden_license/bit-common/src/tools/reports/risk-insights/response/member-cipher-details.response.ts diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/ciphers.mock.ts similarity index 100% rename from bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/ciphers.mock.ts rename to bitwarden_license/bit-common/src/tools/reports/risk-insights/services/ciphers.mock.ts diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts similarity index 100% rename from bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/index.ts rename to bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/member-cipher-details-api.service.spec.ts similarity index 100% rename from bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.spec.ts rename to bitwarden_license/bit-common/src/tools/reports/risk-insights/services/member-cipher-details-api.service.spec.ts diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/member-cipher-details-api.service.ts similarity index 100% rename from bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-api.service.ts rename to bitwarden_license/bit-common/src/tools/reports/risk-insights/services/member-cipher-details-api.service.ts diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/member-cipher-details-response.mock.ts similarity index 100% rename from bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/member-cipher-details-response.mock.ts rename to bitwarden_license/bit-common/src/tools/reports/risk-insights/services/member-cipher-details-response.mock.ts diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/password-health.service.spec.ts similarity index 100% rename from bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.spec.ts rename to bitwarden_license/bit-common/src/tools/reports/risk-insights/services/password-health.service.spec.ts diff --git a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/password-health.service.ts similarity index 97% rename from bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts rename to bitwarden_license/bit-common/src/tools/reports/risk-insights/services/password-health.service.ts index 0eaed89b71..1709261922 100644 --- a/bitwarden_license/bit-common/src/tools/reports/access-intelligence/services/password-health.service.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/password-health.service.ts @@ -1,9 +1,9 @@ import { Inject, Injectable } from "@angular/core"; // eslint-disable-next-line no-restricted-imports -import { mockCiphers } from "@bitwarden/bit-common/tools/reports/access-intelligence/services/ciphers.mock"; +import { mockCiphers } from "@bitwarden/bit-common/tools/reports/risk-insights/services/ciphers.mock"; // eslint-disable-next-line no-restricted-imports -import { mockMemberCipherDetailsResponse } from "@bitwarden/bit-common/tools/reports/access-intelligence/services/member-cipher-details-response.mock"; +import { mockMemberCipherDetailsResponse } from "@bitwarden/bit-common/tools/reports/risk-insights/services/member-cipher-details-response.mock"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; diff --git a/jest.config.js b/jest.config.js index 829adf1bf7..3ed082bcbc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -41,6 +41,7 @@ module.exports = { "/libs/platform/jest.config.js", "/libs/node/jest.config.js", "/libs/vault/jest.config.js", + "/libs/key-management/jest.config.js", ], // Workaround for a memory leak that crashes tests in CI: diff --git a/libs/angular/src/auth/components/environment-selector.component.html b/libs/angular/src/auth/components/environment-selector.component.html index 6a93e2a221..786afe4037 100644 --- a/libs/angular/src/auth/components/environment-selector.component.html +++ b/libs/angular/src/auth/components/environment-selector.component.html @@ -38,18 +38,20 @@
- +
- +
{{ "masterPass" | i18n }} @@ -140,5 +140,5 @@
-
+
diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 239383ddd0..0193e4c403 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, ElementRef, Input, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { firstValueFrom, Subject, take, takeUntil } from "rxjs"; +import { firstValueFrom, Subject, take, takeUntil, tap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -19,9 +19,11 @@ import { CaptchaIFrame } from "@bitwarden/common/auth/captcha-iframe"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { ClientType, HttpStatusCode } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -139,12 +141,16 @@ export class LoginComponent implements OnInit, OnDestroy { private toastService: ToastService, private logService: LogService, private validationService: ValidationService, + private configService: ConfigService, ) { this.clientType = this.platformUtilsService.getClientType(); this.loginViaAuthRequestSupported = this.loginComponentService.isLoginViaAuthRequestSupported(); } async ngOnInit(): Promise { + // TODO: remove this when the UnauthenticatedExtensionUIRefresh feature flag is removed. + this.listenForUnauthUiRefreshFlagChanges(); + await this.defaultOnInit(); if (this.clientType === ClientType.Desktop) { @@ -162,6 +168,29 @@ export class LoginComponent implements OnInit, OnDestroy { this.destroy$.complete(); } + private listenForUnauthUiRefreshFlagChanges() { + this.configService + .getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh) + .pipe( + tap(async (flag) => { + // If the flag is turned OFF, we must force a reload to ensure the correct UI is shown + if (!flag) { + const uniqueQueryParams = { + ...this.activatedRoute.queryParams, + // adding a unique timestamp to the query params to force a reload + t: new Date().getTime().toString(), // Adding a unique timestamp as a query parameter + }; + + await this.router.navigate(["/"], { + queryParams: uniqueQueryParams, + }); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + submit = async (): Promise => { if (this.clientType === ClientType.Desktop) { if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) { diff --git a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html index 5135fb6192..35bb4236c5 100644 --- a/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html +++ b/libs/auth/src/angular/registration/registration-env-selector/registration-env-selector.component.html @@ -1,7 +1,7 @@
{{ "creatingAccountOn" | i18n }} - + - this.handleSelfHostedEnvConfigDialogResult(result, prevSelectedRegion), - ), - ); + if (selectedRegion !== Region.SelfHosted) { + this.selectedRegionChange.emit(selectedRegion); + return from(this.environmentService.setEnvironment(selectedRegion.key)); } - this.selectedRegionChange.emit(selectedRegion); - return from(this.environmentService.setEnvironment(selectedRegion.key)); + return of(null); }, ), takeUntil(this.destroy$), @@ -170,6 +169,17 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy { } } + /** + * Handles the event when the select is closed. + * If the selected region is self-hosted, opens the self-hosted environment settings dialog. + */ + protected async onSelectClosed() { + if (this.selectedRegion.value === Region.SelfHosted) { + const result = await SelfHostedEnvConfigDialogComponent.open(this.dialogService); + return this.handleSelfHostedEnvConfigDialogResult(result, this.selectedRegion.value); + } + } + ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index c275e403f0..b1ae6818d3 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -17,7 +17,6 @@ export enum FeatureFlag { InlineMenuFieldQualification = "inline-menu-field-qualification", MemberAccessReport = "ac-2059-member-access-report", TwoFactorComponentRefactor = "two-factor-component-refactor", - EnableTimeThreshold = "PM-5864-dollar-threshold", InlineMenuPositioningImprovements = "inline-menu-positioning-improvements", ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner", VaultBulkManagementAction = "vault-bulk-management-action", @@ -36,6 +35,7 @@ export enum FeatureFlag { AccessIntelligence = "pm-13227-access-intelligence", Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions", LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split", + CriticalApps = "pm-14466-risk-insights-critical-application", DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship", } @@ -64,7 +64,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.InlineMenuFieldQualification]: FALSE, [FeatureFlag.MemberAccessReport]: FALSE, [FeatureFlag.TwoFactorComponentRefactor]: FALSE, - [FeatureFlag.EnableTimeThreshold]: FALSE, [FeatureFlag.InlineMenuPositioningImprovements]: FALSE, [FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE, [FeatureFlag.VaultBulkManagementAction]: FALSE, @@ -83,6 +82,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.AccessIntelligence]: FALSE, [FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE, [FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE, + [FeatureFlag.CriticalApps]: FALSE, [FeatureFlag.DisableFreeFamiliesSponsorship]: FALSE, } satisfies Record; diff --git a/libs/common/src/platform/abstractions/config/config.service.ts b/libs/common/src/platform/abstractions/config/config.service.ts index 9b16cee385..05a3dcd148 100644 --- a/libs/common/src/platform/abstractions/config/config.service.ts +++ b/libs/common/src/platform/abstractions/config/config.service.ts @@ -3,6 +3,7 @@ import { SemVer } from "semver"; import { FeatureFlag, FeatureFlagValueType } from "../../../enums/feature-flag.enum"; import { UserId } from "../../../types/guid"; +import { ServerSettings } from "../../models/domain/server-settings"; import { Region } from "../environment.service"; import { ServerConfig } from "./server-config"; @@ -10,6 +11,8 @@ import { ServerConfig } from "./server-config"; export abstract class ConfigService { /** The server config of the currently active user */ serverConfig$: Observable; + /** The server settings of the currently active user */ + serverSettings$: Observable; /** The cloud region of the currently active user */ cloudRegion$: Observable; /** diff --git a/libs/common/src/platform/abstractions/config/server-config.ts b/libs/common/src/platform/abstractions/config/server-config.ts index bb18605964..b51628cbf5 100644 --- a/libs/common/src/platform/abstractions/config/server-config.ts +++ b/libs/common/src/platform/abstractions/config/server-config.ts @@ -6,6 +6,7 @@ import { ThirdPartyServerConfigData, EnvironmentServerConfigData, } from "../../models/data/server-config.data"; +import { ServerSettings } from "../../models/domain/server-settings"; const dayInMilliseconds = 24 * 3600 * 1000; @@ -16,6 +17,7 @@ export class ServerConfig { environment?: EnvironmentServerConfigData; utcDate: Date; featureStates: { [key: string]: AllowedFeatureFlagTypes } = {}; + settings: ServerSettings; constructor(serverConfigData: ServerConfigData) { this.version = serverConfigData.version; @@ -24,6 +26,7 @@ export class ServerConfig { this.utcDate = new Date(serverConfigData.utcDate); this.environment = serverConfigData.environment; this.featureStates = serverConfigData.featureStates; + this.settings = serverConfigData.settings; if (this.server?.name == null && this.server?.url == null) { this.server = null; diff --git a/libs/common/src/platform/misc/flags.ts b/libs/common/src/platform/misc/flags.ts index b3269c8f4e..297e616e66 100644 --- a/libs/common/src/platform/misc/flags.ts +++ b/libs/common/src/platform/misc/flags.ts @@ -3,6 +3,7 @@ export type SharedFlags = { showPasswordless?: boolean; sdk?: boolean; + prereleaseBuild?: boolean; }; // required to avoid linting errors when there are no flags diff --git a/libs/common/src/platform/models/data/server-config.data.spec.ts b/libs/common/src/platform/models/data/server-config.data.spec.ts index b94092662a..13d1420408 100644 --- a/libs/common/src/platform/models/data/server-config.data.spec.ts +++ b/libs/common/src/platform/models/data/server-config.data.spec.ts @@ -16,6 +16,9 @@ describe("ServerConfigData", () => { name: "test", url: "https://test.com", }, + settings: { + disableUserRegistration: false, + }, environment: { cloudRegion: Region.EU, vault: "https://vault.com", diff --git a/libs/common/src/platform/models/data/server-config.data.ts b/libs/common/src/platform/models/data/server-config.data.ts index 57e8fbc628..d5f17fd0ac 100644 --- a/libs/common/src/platform/models/data/server-config.data.ts +++ b/libs/common/src/platform/models/data/server-config.data.ts @@ -2,6 +2,7 @@ import { Jsonify } from "type-fest"; import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum"; import { Region } from "../../abstractions/environment.service"; +import { ServerSettings } from "../domain/server-settings"; import { ServerConfigResponse, ThirdPartyServerConfigResponse, @@ -15,6 +16,7 @@ export class ServerConfigData { environment?: EnvironmentServerConfigData; utcDate: string; featureStates: { [key: string]: AllowedFeatureFlagTypes } = {}; + settings: ServerSettings; constructor(serverConfigResponse: Partial) { this.version = serverConfigResponse?.version; @@ -27,6 +29,7 @@ export class ServerConfigData { ? new EnvironmentServerConfigData(serverConfigResponse.environment) : null; this.featureStates = serverConfigResponse?.featureStates; + this.settings = new ServerSettings(serverConfigResponse.settings); } static fromJSON(obj: Jsonify): ServerConfigData { diff --git a/libs/common/src/platform/models/domain/server-settings.spec.ts b/libs/common/src/platform/models/domain/server-settings.spec.ts new file mode 100644 index 0000000000..3e6295fa5c --- /dev/null +++ b/libs/common/src/platform/models/domain/server-settings.spec.ts @@ -0,0 +1,20 @@ +import { ServerSettings } from "./server-settings"; + +describe("ServerSettings", () => { + describe("disableUserRegistration", () => { + it("defaults disableUserRegistration to false", () => { + const settings = new ServerSettings(); + expect(settings.disableUserRegistration).toBe(false); + }); + + it("sets disableUserRegistration to true when provided", () => { + const settings = new ServerSettings({ disableUserRegistration: true }); + expect(settings.disableUserRegistration).toBe(true); + }); + + it("sets disableUserRegistration to false when provided", () => { + const settings = new ServerSettings({ disableUserRegistration: false }); + expect(settings.disableUserRegistration).toBe(false); + }); + }); +}); diff --git a/libs/common/src/platform/models/domain/server-settings.ts b/libs/common/src/platform/models/domain/server-settings.ts new file mode 100644 index 0000000000..b18f07466d --- /dev/null +++ b/libs/common/src/platform/models/domain/server-settings.ts @@ -0,0 +1,7 @@ +export class ServerSettings { + disableUserRegistration: boolean; + + constructor(data?: ServerSettings) { + this.disableUserRegistration = data?.disableUserRegistration ?? false; + } +} diff --git a/libs/common/src/platform/models/response/server-config.response.ts b/libs/common/src/platform/models/response/server-config.response.ts index a546d2d3de..d295634830 100644 --- a/libs/common/src/platform/models/response/server-config.response.ts +++ b/libs/common/src/platform/models/response/server-config.response.ts @@ -1,6 +1,7 @@ import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum"; import { BaseResponse } from "../../../models/response/base.response"; import { Region } from "../../abstractions/environment.service"; +import { ServerSettings } from "../domain/server-settings"; export class ServerConfigResponse extends BaseResponse { version: string; @@ -8,6 +9,7 @@ export class ServerConfigResponse extends BaseResponse { server: ThirdPartyServerConfigResponse; environment: EnvironmentServerConfigResponse; featureStates: { [key: string]: AllowedFeatureFlagTypes } = {}; + settings: ServerSettings; constructor(response: any) { super(response); @@ -21,6 +23,7 @@ export class ServerConfigResponse extends BaseResponse { this.server = new ThirdPartyServerConfigResponse(this.getResponseProperty("Server")); this.environment = new EnvironmentServerConfigResponse(this.getResponseProperty("Environment")); this.featureStates = this.getResponseProperty("FeatureStates"); + this.settings = new ServerSettings(this.getResponseProperty("Settings")); } } diff --git a/libs/common/src/platform/services/config/default-config.service.ts b/libs/common/src/platform/services/config/default-config.service.ts index e0603ed509..fce1c12106 100644 --- a/libs/common/src/platform/services/config/default-config.service.ts +++ b/libs/common/src/platform/services/config/default-config.service.ts @@ -28,6 +28,7 @@ import { Environment, EnvironmentService, Region } from "../../abstractions/envi import { LogService } from "../../abstractions/log.service"; import { devFlagEnabled, devFlagValue } from "../../misc/flags"; import { ServerConfigData } from "../../models/data/server-config.data"; +import { ServerSettings } from "../../models/domain/server-settings"; import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state"; export const RETRIEVAL_INTERVAL = devFlagEnabled("configRetrievalIntervalMs") @@ -57,6 +58,8 @@ export class DefaultConfigService implements ConfigService { serverConfig$: Observable; + serverSettings$: Observable; + cloudRegion$: Observable; constructor( @@ -111,6 +114,10 @@ export class DefaultConfigService implements ConfigService { this.cloudRegion$ = this.serverConfig$.pipe( map((config) => config?.environment?.cloudRegion ?? Region.US), ); + + this.serverSettings$ = this.serverConfig$.pipe( + map((config) => config?.settings ?? new ServerSettings()), + ); } getFeatureFlag$(key: Flag) { diff --git a/libs/common/src/platform/services/default-server-settings.service.spec.ts b/libs/common/src/platform/services/default-server-settings.service.spec.ts new file mode 100644 index 0000000000..09bca2ff78 --- /dev/null +++ b/libs/common/src/platform/services/default-server-settings.service.spec.ts @@ -0,0 +1,47 @@ +import { of } from "rxjs"; + +import { ConfigService } from "../abstractions/config/config.service"; +import { ServerSettings } from "../models/domain/server-settings"; + +import { DefaultServerSettingsService } from "./default-server-settings.service"; + +describe("DefaultServerSettingsService", () => { + let service: DefaultServerSettingsService; + let configServiceMock: { serverSettings$: any }; + + beforeEach(() => { + configServiceMock = { serverSettings$: of() }; + service = new DefaultServerSettingsService(configServiceMock as ConfigService); + }); + + describe("getSettings$", () => { + it("returns server settings", () => { + const mockSettings = new ServerSettings({ disableUserRegistration: true }); + configServiceMock.serverSettings$ = of(mockSettings); + + service.getSettings$().subscribe((settings) => { + expect(settings).toEqual(mockSettings); + }); + }); + }); + + describe("isUserRegistrationDisabled$", () => { + it("returns true when user registration is disabled", () => { + const mockSettings = new ServerSettings({ disableUserRegistration: true }); + configServiceMock.serverSettings$ = of(mockSettings); + + service.isUserRegistrationDisabled$.subscribe((isDisabled: boolean) => { + expect(isDisabled).toBe(true); + }); + }); + + it("returns false when user registration is enabled", () => { + const mockSettings = new ServerSettings({ disableUserRegistration: false }); + configServiceMock.serverSettings$ = of(mockSettings); + + service.isUserRegistrationDisabled$.subscribe((isDisabled: boolean) => { + expect(isDisabled).toBe(false); + }); + }); + }); +}); diff --git a/libs/common/src/platform/services/default-server-settings.service.ts b/libs/common/src/platform/services/default-server-settings.service.ts new file mode 100644 index 0000000000..9d0dd4bfd9 --- /dev/null +++ b/libs/common/src/platform/services/default-server-settings.service.ts @@ -0,0 +1,19 @@ +import { Observable } from "rxjs"; +import { map } from "rxjs/operators"; + +import { ConfigService } from "../abstractions/config/config.service"; +import { ServerSettings } from "../models/domain/server-settings"; + +export class DefaultServerSettingsService { + constructor(private configService: ConfigService) {} + + getSettings$(): Observable { + return this.configService.serverSettings$; + } + + get isUserRegistrationDisabled$(): Observable { + return this.getSettings$().pipe( + map((settings: ServerSettings) => settings.disableUserRegistration), + ); + } +} diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 5f53131719..0c508bfeb8 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -126,6 +126,7 @@ import { AppIdService } from "../platform/abstractions/app-id.service"; import { EnvironmentService } from "../platform/abstractions/environment.service"; import { LogService } from "../platform/abstractions/log.service"; import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { flagEnabled } from "../platform/misc/flags"; import { Utils } from "../platform/misc/utils"; import { SyncResponse } from "../platform/sync"; import { UserId } from "../types/guid"; @@ -583,7 +584,7 @@ export class ApiService implements ApiServiceAbstraction { } putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise { - return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, false); + return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, true); } postPurgeCiphers( @@ -1843,44 +1844,20 @@ export class ApiService implements ApiServiceAbstraction { const requestUrl = apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : ""); - const headers = new Headers({ - "Device-Type": this.deviceType, - }); - if (this.customUserAgent != null) { - headers.set("User-Agent", this.customUserAgent); - } + const [requestHeaders, requestBody] = await this.buildHeadersAndBody( + authed, + hasResponse, + body, + alterHeaders, + ); const requestInit: RequestInit = { cache: "no-store", credentials: await this.getCredentials(), method: method, }; - - if (authed) { - const authHeader = await this.getActiveBearerToken(); - headers.set("Authorization", "Bearer " + authHeader); - } - if (body != null) { - if (typeof body === "string") { - requestInit.body = body; - headers.set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8"); - } else if (typeof body === "object") { - if (body instanceof FormData) { - requestInit.body = body; - } else { - headers.set("Content-Type", "application/json; charset=utf-8"); - requestInit.body = JSON.stringify(body); - } - } - } - if (hasResponse) { - headers.set("Accept", "application/json"); - } - if (alterHeaders != null) { - alterHeaders(headers); - } - - requestInit.headers = headers; + requestInit.headers = requestHeaders; + requestInit.body = requestBody; const response = await this.fetch(new Request(requestUrl, requestInit)); const responseType = response.headers.get("content-type"); @@ -1897,6 +1874,51 @@ export class ApiService implements ApiServiceAbstraction { } } + private async buildHeadersAndBody( + authed: boolean, + hasResponse: boolean, + body: any, + alterHeaders: (headers: Headers) => void, + ): Promise<[Headers, any]> { + let requestBody: any = null; + const headers = new Headers({ + "Device-Type": this.deviceType, + }); + + if (flagEnabled("prereleaseBuild")) { + headers.set("Is-Prerelease", "1"); + } + if (this.customUserAgent != null) { + headers.set("User-Agent", this.customUserAgent); + } + if (hasResponse) { + headers.set("Accept", "application/json"); + } + if (alterHeaders != null) { + alterHeaders(headers); + } + if (authed) { + const authHeader = await this.getActiveBearerToken(); + headers.set("Authorization", "Bearer " + authHeader); + } + + if (body != null) { + if (typeof body === "string") { + requestBody = body; + headers.set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8"); + } else if (typeof body === "object") { + if (body instanceof FormData) { + requestBody = body; + } else { + headers.set("Content-Type", "application/json; charset=utf-8"); + requestBody = JSON.stringify(body); + } + } + } + + return [headers, requestBody]; + } + private async handleError( response: Response, tokenError: boolean, diff --git a/libs/common/src/tools/state/object-key.ts b/libs/common/src/tools/state/object-key.ts index 88365d5cbd..0593186ec4 100644 --- a/libs/common/src/tools/state/object-key.ts +++ b/libs/common/src/tools/state/object-key.ts @@ -22,6 +22,7 @@ export type ObjectKey> classifier: Classifier; format: "plain" | "classified"; options: UserKeyDefinitionOptions; + initial?: State; }; export function isObjectKey(key: any): key is ObjectKey { diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 89f19ac3c7..845ab25c80 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -254,17 +254,18 @@ export class UserStateSubject< withConstraints, map(([loadedState, constraints]) => { // bypass nulls - if (!loadedState) { + if (!loadedState && !this.objectKey?.initial) { return { constraints: {} as Constraints, state: null, } satisfies Constrained; } + const unconstrained = loadedState ?? structuredClone(this.objectKey.initial); const calibration = isDynamic(constraints) - ? constraints.calibrate(loadedState) + ? constraints.calibrate(unconstrained) : constraints; - const adjusted = calibration.adjust(loadedState); + const adjusted = calibration.adjust(unconstrained); return { constraints: calibration.constraints, diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 444c922fe3..5221f4cf0a 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -119,7 +119,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider Promise; + saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise; /** * Bulk update collections for many ciphers with the server * @param orgId diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 154042601e..6b618e2550 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -880,9 +880,11 @@ export class CipherService implements CipherServiceAbstraction { return new Cipher(updated[cipher.id as CipherId], cipher.localData); } - async saveCollectionsWithServerAdmin(cipher: Cipher): Promise { + async saveCollectionsWithServerAdmin(cipher: Cipher): Promise { const request = new CipherCollectionsRequest(cipher.collectionIds); - await this.apiService.putCipherCollectionsAdmin(cipher.id, request); + const response = await this.apiService.putCipherCollectionsAdmin(cipher.id, request); + const data = new CipherData(response); + return new Cipher(data); } /** diff --git a/libs/components/src/disclosure/disclosure-trigger-for.directive.ts b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts new file mode 100644 index 0000000000..0547028172 --- /dev/null +++ b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts @@ -0,0 +1,27 @@ +import { Directive, HostBinding, HostListener, Input } from "@angular/core"; + +import { DisclosureComponent } from "./disclosure.component"; + +@Directive({ + selector: "[bitDisclosureTriggerFor]", + exportAs: "disclosureTriggerFor", + standalone: true, +}) +export class DisclosureTriggerForDirective { + /** + * Accepts template reference for a bit-disclosure component instance + */ + @Input("bitDisclosureTriggerFor") disclosure: DisclosureComponent; + + @HostBinding("attr.aria-expanded") get ariaExpanded() { + return this.disclosure.open; + } + + @HostBinding("attr.aria-controls") get ariaControls() { + return this.disclosure.id; + } + + @HostListener("click") click() { + this.disclosure.open = !this.disclosure.open; + } +} diff --git a/libs/components/src/disclosure/disclosure.component.ts b/libs/components/src/disclosure/disclosure.component.ts new file mode 100644 index 0000000000..58c67ad0f0 --- /dev/null +++ b/libs/components/src/disclosure/disclosure.component.ts @@ -0,0 +1,21 @@ +import { Component, HostBinding, Input, booleanAttribute } from "@angular/core"; + +let nextId = 0; + +@Component({ + selector: "bit-disclosure", + standalone: true, + template: ``, +}) +export class DisclosureComponent { + /** + * Optionally init the disclosure in its opened state + */ + @Input({ transform: booleanAttribute }) open?: boolean = false; + + @HostBinding("class") get classList() { + return this.open ? "" : "tw-hidden"; + } + + @HostBinding("id") id = `bit-disclosure-${nextId++}`; +} diff --git a/libs/components/src/disclosure/disclosure.mdx b/libs/components/src/disclosure/disclosure.mdx new file mode 100644 index 0000000000..8df8e7025b --- /dev/null +++ b/libs/components/src/disclosure/disclosure.mdx @@ -0,0 +1,55 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./disclosure.stories"; + + + +```ts +import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/components"; +``` + +# Disclosure + +The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to +create an accessible content area whose visibility is controlled by a trigger button. + +To compose a disclosure and trigger: + +1. Create a trigger component (see "Supported Trigger Components" section below) +2. Create a `bit-disclosure` +3. Set a template reference on the `bit-disclosure` +4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the + `bit-disclosure` template reference +5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently + expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to + being hidden. + +``` + +click button to hide this content +``` + + + +
+
+ +## Supported Trigger Components + +This is the list of currently supported trigger components: + +- Icon button `muted` variant + +## Accessibility + +The disclosure and trigger directive functionality follow the +[Disclosure (Show/Hide)](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) pattern for +accessibility, automatically handling the `aria-controls` and `aria-expanded` properties. A `button` +element must be used as the trigger for the disclosure. The `button` element must also have an +accessible label/title -- please follow the accessibility guidelines for whatever trigger component +you choose. diff --git a/libs/components/src/disclosure/disclosure.stories.ts b/libs/components/src/disclosure/disclosure.stories.ts new file mode 100644 index 0000000000..974589a667 --- /dev/null +++ b/libs/components/src/disclosure/disclosure.stories.ts @@ -0,0 +1,29 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { IconButtonModule } from "../icon-button"; + +import { DisclosureTriggerForDirective } from "./disclosure-trigger-for.directive"; +import { DisclosureComponent } from "./disclosure.component"; + +export default { + title: "Component Library/Disclosure", + component: DisclosureComponent, + decorators: [ + moduleMetadata({ + imports: [DisclosureTriggerForDirective, DisclosureComponent, IconButtonModule], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const DisclosureWithIconButton: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + click button to hide this content + `, + }), +}; diff --git a/libs/components/src/disclosure/index.ts b/libs/components/src/disclosure/index.ts new file mode 100644 index 0000000000..b5bdf68725 --- /dev/null +++ b/libs/components/src/disclosure/index.ts @@ -0,0 +1,2 @@ +export * from "./disclosure-trigger-for.directive"; +export * from "./disclosure.component"; diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 54f6dfda96..d036e1c77c 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -52,10 +52,14 @@ const styles: Record = { "tw-bg-transparent", "!tw-text-muted", "tw-border-transparent", + "aria-expanded:tw-bg-text-muted", + "aria-expanded:!tw-text-contrast", "hover:tw-bg-transparent-hover", "hover:tw-border-primary-700", "focus-visible:before:tw-ring-primary-700", "disabled:tw-opacity-60", + "aria-expanded:hover:tw-bg-secondary-700", + "aria-expanded:hover:tw-border-secondary-700", "disabled:hover:tw-border-transparent", "disabled:hover:tw-bg-transparent", ...focusRing, diff --git a/libs/components/src/icon-button/icon-button.mdx b/libs/components/src/icon-button/icon-button.mdx index 8361d4c399..a45160d788 100644 --- a/libs/components/src/icon-button/icon-button.mdx +++ b/libs/components/src/icon-button/icon-button.mdx @@ -29,8 +29,6 @@ Icon buttons can be found in other components such as: the [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 @@ -40,48 +38,48 @@ button component styles. 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. @@ -95,11 +93,11 @@ with less padding around the icon, such as in the navigation component. ### Small - + ### Default - + ## Accessibility diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts index 0f25d2de58..b5542f7860 100644 --- a/libs/components/src/icon-button/icon-button.stories.ts +++ b/libs/components/src/icon-button/icon-button.stories.ts @@ -23,7 +23,7 @@ type Story = StoryObj; export const Default: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -56,7 +56,7 @@ export const Small: Story = { export const Primary: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` `, }), @@ -96,7 +96,7 @@ export const Muted: Story = { export const Light: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -110,7 +110,7 @@ export const Light: Story = { export const Contrast: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ `
diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 6881d801e0..810f32bdd3 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -13,6 +13,7 @@ export * from "./chip-select"; export * from "./color-password"; export * from "./container"; export * from "./dialog"; +export * from "./disclosure"; export * from "./form-field"; export * from "./icon-button"; export * from "./icon"; diff --git a/libs/components/src/select/select.component.html b/libs/components/src/select/select.component.html index f334e67d69..848692526a 100644 --- a/libs/components/src/select/select.component.html +++ b/libs/components/src/select/select.component.html @@ -7,6 +7,7 @@ (blur)="onBlur()" [labelForId]="labelForId" [clearable]="false" + (close)="onClose()" appendTo="body" > diff --git a/libs/components/src/select/select.component.ts b/libs/components/src/select/select.component.ts index 2d900353a6..d189f1ab52 100644 --- a/libs/components/src/select/select.component.ts +++ b/libs/components/src/select/select.component.ts @@ -7,6 +7,8 @@ import { QueryList, Self, ViewChild, + Output, + EventEmitter, } from "@angular/core"; import { ControlValueAccessor, NgControl, Validators } from "@angular/forms"; import { NgSelectComponent } from "@ng-select/ng-select"; @@ -31,6 +33,7 @@ export class SelectComponent implements BitFormFieldControl, ControlValueAcce /** Optional: Options can be provided using an array input or using `bit-option` */ @Input() items: Option[] = []; @Input() placeholder = this.i18nService.t("selectPlaceholder"); + @Output() closed = new EventEmitter(); protected selectedValue: T; protected selectedOption: Option; @@ -156,4 +159,9 @@ export class SelectComponent implements BitFormFieldControl, ControlValueAcce private findSelectedOption(items: Option[], value: T): Option | undefined { return items.find((item) => item.value === value); } + + /**Emits the closed event. */ + protected onClose() { + this.closed.emit(); + } } diff --git a/libs/components/src/table/sortable.component.ts b/libs/components/src/table/sortable.component.ts index b2e456953b..c6d60f155b 100644 --- a/libs/components/src/table/sortable.component.ts +++ b/libs/components/src/table/sortable.component.ts @@ -1,7 +1,7 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { Component, HostBinding, Input, OnInit } from "@angular/core"; -import type { SortFn } from "./table-data-source"; +import type { SortDirection, SortFn } from "./table-data-source"; import { TableComponent } from "./table.component"; @Component({ @@ -19,12 +19,16 @@ export class SortableComponent implements OnInit { */ @Input() bitSortable: string; - private _default: boolean; + private _default: SortDirection | boolean = false; /** * Mark the column as the default sort column */ - @Input() set default(value: boolean | "") { - this._default = coerceBooleanProperty(value); + @Input() set default(value: SortDirection | boolean | "") { + if (value === "desc" || value === "asc") { + this._default = value; + } else { + this._default = coerceBooleanProperty(value) ? "asc" : false; + } } /** @@ -32,6 +36,11 @@ export class SortableComponent implements OnInit { * * @example * fn = (a, b) => a.name.localeCompare(b.name) + * + * fn = (a, b, direction) => { + * const result = a.name.localeCompare(b.name) + * return direction === 'asc' ? result : -result; + * } */ @Input() fn: SortFn; @@ -52,8 +61,18 @@ export class SortableComponent implements OnInit { protected setActive() { if (this.table.dataSource) { - const direction = this.isActive && this.direction === "asc" ? "desc" : "asc"; - this.table.dataSource.sort = { column: this.bitSortable, direction: direction, fn: this.fn }; + const defaultDirection = this._default === "desc" ? "desc" : "asc"; + const direction = this.isActive + ? this.direction === "asc" + ? "desc" + : "asc" + : defaultDirection; + + this.table.dataSource.sort = { + column: this.bitSortable, + direction: direction, + fn: this.fn, + }; } } diff --git a/libs/components/src/table/table-data-source.ts b/libs/components/src/table/table-data-source.ts index 6501c9bffb..8a5d994dc1 100644 --- a/libs/components/src/table/table-data-source.ts +++ b/libs/components/src/table/table-data-source.ts @@ -3,7 +3,7 @@ import { DataSource } from "@angular/cdk/collections"; import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs"; export type SortDirection = "asc" | "desc"; -export type SortFn = (a: any, b: any) => number; +export type SortFn = (a: any, b: any, direction?: SortDirection) => number; export type Sort = { column?: string; direction: SortDirection; @@ -166,7 +166,7 @@ export class TableDataSource extends DataSource { return data.sort((a, b) => { // If a custom sort function is provided, use it instead of the default. if (sort.fn) { - return sort.fn(a, b) * directionModifier; + return sort.fn(a, b, sort.direction) * directionModifier; } let valueA = this.sortingDataAccessor(a, column); diff --git a/libs/components/src/table/table.mdx b/libs/components/src/table/table.mdx index 3f28dd93b6..8d784190ed 100644 --- a/libs/components/src/table/table.mdx +++ b/libs/components/src/table/table.mdx @@ -105,7 +105,7 @@ within the `ng-template`which provides access to the rows using `let-rows$`. We provide a simple component for displaying sortable column headers. The `bitSortable` component wires up to the `TableDataSource` and will automatically sort the data when clicked and display an -indicator for which column is currently sorted. The dafault sorting can be specified by setting the +indicator for which column is currently sorted. The default sorting can be specified by setting the `default`. ```html @@ -113,10 +113,23 @@ indicator for which column is currently sorted. The dafault sorting can be speci Name ``` +For default sorting in descending order, set default="desc" + +```html +Name +``` + It's also possible to define a custom sorting function by setting the `fn` input. ```ts +// Basic sort function const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1); + +// Direction aware sort function +const sortByName = (a: T, b: T, direction?: SortDirection) => { + const result = a.name.localeCompare(b.name); + return direction === "asc" ? result : -result; +}; ``` ### Filtering diff --git a/libs/tools/generator/components/src/catchall-settings.component.html b/libs/tools/generator/components/src/catchall-settings.component.html index 0b2a9e69ef..61037c91a7 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.html +++ b/libs/tools/generator/components/src/catchall-settings.component.html @@ -1,6 +1,11 @@ {{ "domainName" | i18n }} - + diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts index 55ddc1f810..3a42d68297 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.ts +++ b/libs/tools/generator/components/src/catchall-settings.component.ts @@ -1,6 +1,6 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs"; +import { BehaviorSubject, map, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -60,7 +60,19 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy { // the first emission is the current value; subsequent emissions are updates settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); - this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + // now that outputs are set up, connect inputs + this.saveSettings + .pipe( + withLatestFrom(this.settings.valueChanges), + map(([, settings]) => settings), + takeUntil(this.destroyed$), + ) + .subscribe(settings); + } + + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); } private singleUserId$() { @@ -78,6 +90,7 @@ export class CatchallSettingsComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index f580b75f1b..ce86abe80a 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -22,6 +22,7 @@ buttonType="main" (click)="generate('user request')" [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -33,6 +34,7 @@ [appA11yTitle]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" + [disabled]="!(algorithm$ | async)" >
diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 579d196a7a..6af1533612 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -202,9 +202,8 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { }); }); - // normalize cascade selections; introduce subjects to allow changes - // from user selections and changes from preference updates to - // update the template + // these subjects normalize cascade selections to ensure the current + // cascade is always well-known. type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm }; const activeRoot$ = new Subject(); const activeIdentifier$ = new Subject(); @@ -385,7 +384,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { if (!a || a.onlyOnRequest) { this.value$.next("-"); } else { - this.generate("autogenerate"); + this.generate("autogenerate").catch((e: unknown) => this.logService.error(e)); } }); }); @@ -495,7 +494,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { * @param requestor a label used to trace generation request * origin in the debugger. */ - protected generate(requestor: string) { + protected async generate(requestor: string) { this.generate$.next(requestor); } @@ -510,6 +509,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { private readonly destroyed = new Subject(); ngOnDestroy() { + this.destroyed.next(); this.destroyed.complete(); // finalize subjects diff --git a/libs/tools/generator/components/src/forwarder-settings.component.html b/libs/tools/generator/components/src/forwarder-settings.component.html index 64566fa956..0e15c2e89a 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.html +++ b/libs/tools/generator/components/src/forwarder-settings.component.html @@ -1,16 +1,28 @@
{{ "forwarderDomainName" | i18n }} - + {{ "forwarderDomainNameHint" | i18n }} {{ "apiKey" | i18n }} - + {{ "selfHostBaseUrl" | i18n }} - +
diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts index 67e93c611e..f1caf91ade 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.ts +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -17,7 +17,6 @@ import { skip, Subject, switchAll, - switchMap, takeUntil, withLatestFrom, } from "rxjs"; @@ -33,7 +32,7 @@ import { toCredentialGeneratorConfiguration, } from "@bitwarden/generator-core"; -import { completeOnAccountSwitch, toValidators } from "./util"; +import { completeOnAccountSwitch } from "./util"; const Controls = Object.freeze({ domain: "domain", @@ -117,35 +116,17 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy this.settings.patchValue(settings as any, { emitEvent: false }); }); - // bind policy to the reactive form - forwarder$ - .pipe( - switchMap((forwarder) => { - const constraints$ = this.generatorService - .policy$(forwarder, { userId$: singleUserId$ }) - .pipe(map(({ constraints }) => [constraints, forwarder] as const)); - - return constraints$; - }), - takeUntil(this.destroyed$), - ) - .subscribe(([constraints, forwarder]) => { - for (const name in Controls) { - const control = this.settings.get(name); - if (forwarder.request.includes(name as any)) { - control.enable({ emitEvent: false }); - control.setValidators( - // the configuration's type erasure affects `toValidators` as well - toValidators(name, forwarder, constraints), - ); - } else { - control.disable({ emitEvent: false }); - control.clearValidators(); - } + // enable requested forwarder inputs + forwarder$.pipe(takeUntil(this.destroyed$)).subscribe((forwarder) => { + for (const name in Controls) { + const control = this.settings.get(name); + if (forwarder.request.includes(name as any)) { + control.enable({ emitEvent: false }); + } else { + control.disable({ emitEvent: false }); } - - this.settings.updateValueAndValidity({ emitEvent: false }); - }); + } + }); // the first emission is the current value; subsequent emissions are updates settings$$ @@ -157,13 +138,18 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy .subscribe(this.onUpdated); // now that outputs are set up, connect inputs - this.settings.valueChanges - .pipe(withLatestFrom(settings$$), takeUntil(this.destroyed$)) - .subscribe(([value, settings]) => { + this.saveSettings + .pipe(withLatestFrom(this.settings.valueChanges, settings$$), takeUntil(this.destroyed$)) + .subscribe(([, value, settings]) => { settings.next(value); }); } + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); + } + ngOnChanges(changes: SimpleChanges): void { this.refresh$.complete(); if ("forwarder" in changes) { @@ -192,6 +178,7 @@ export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } diff --git a/libs/tools/generator/components/src/generator.module.ts b/libs/tools/generator/components/src/generator.module.ts index 2d1cedca40..e73d687d7d 100644 --- a/libs/tools/generator/components/src/generator.module.ts +++ b/libs/tools/generator/components/src/generator.module.ts @@ -7,6 +7,7 @@ import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -79,6 +80,7 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); I18nService, EncryptService, KeyService, + AccountService, ], }), ], diff --git a/libs/tools/generator/components/src/passphrase-settings.component.html b/libs/tools/generator/components/src/passphrase-settings.component.html index d089de7a07..4e073f3424 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.html +++ b/libs/tools/generator/components/src/passphrase-settings.component.html @@ -7,7 +7,13 @@ {{ "numWords" | i18n }} - + {{ numWordsBoundariesHint$ | async }} @@ -16,14 +22,33 @@ {{ "wordSeparator" | i18n }} - + - + {{ "capitalize" | i18n }} - + {{ "includeNumber" | i18n }}

{{ "generatorPolicyInEffect" | i18n }}

diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index d65e897f4e..f2f1749cb6 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -1,7 +1,15 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { BehaviorSubject, skip, takeUntil, Subject, ReplaySubject } from "rxjs"; +import { + BehaviorSubject, + skip, + takeUntil, + Subject, + map, + withLatestFrom, + ReplaySubject, +} from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -12,7 +20,7 @@ import { PassphraseGenerationOptions, } from "@bitwarden/generator-core"; -import { completeOnAccountSwitch, toValidators } from "./util"; +import { completeOnAccountSwitch } from "./util"; const Controls = Object.freeze({ numWords: "numWords", @@ -81,21 +89,12 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { // the first emission is the current value; subsequent emissions are updates settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); - // dynamic policy enforcement + // explain policy & disable policy-overridden fields this.generatorService .policy$(Generators.passphrase, { userId$: singleUserId$ }) .pipe(takeUntil(this.destroyed$)) .subscribe(({ constraints }) => { - this.settings - .get(Controls.numWords) - .setValidators(toValidators(Controls.numWords, Generators.passphrase, constraints)); - - this.settings - .get(Controls.wordSeparator) - .setValidators(toValidators(Controls.wordSeparator, Generators.passphrase, constraints)); - - this.settings.updateValueAndValidity({ emitEvent: false }); - + this.wordSeparatorMaxLength = constraints.wordSeparator.maxLength; this.policyInEffect = constraints.policyInEffect; this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly); @@ -110,7 +109,21 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { }); // now that outputs are set up, connect inputs - this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + this.saveSettings + .pipe( + withLatestFrom(this.settings.valueChanges), + map(([, settings]) => settings), + takeUntil(this.destroyed$), + ) + .subscribe(settings); + } + + /** attribute binding for wordSeparator[maxlength] */ + protected wordSeparatorMaxLength: number; + + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); } /** display binding for enterprise policy notice */ @@ -144,6 +157,7 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index 6726df3085..a6aa5ebdd0 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -20,6 +20,7 @@ buttonType="main" (click)="generate('user request')" [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -31,6 +32,7 @@ [appA11yTitle]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" + [disabled]="!(algorithm$ | async)" >
diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 8566edf466..715904dc98 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -22,11 +22,11 @@ import { Option } from "@bitwarden/components/src/select/option"; import { CredentialGeneratorService, Generators, - PasswordAlgorithm, GeneratedCredential, CredentialAlgorithm, isPasswordAlgorithm, AlgorithmInfo, + isSameAlgorithm, } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; @@ -57,7 +57,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { @Input({ transform: coerceBooleanProperty }) disableMargin = false; /** tracks the currently selected credential type */ - protected credentialType$ = new BehaviorSubject(null); + protected credentialType$ = new BehaviorSubject(null); /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); @@ -72,14 +72,14 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { * @param requestor a label used to trace generation request * origin in the debugger. */ - protected generate(requestor: string) { + protected async generate(requestor: string) { this.generate$.next(requestor); } /** Tracks changes to the selected credential type * @param type the new credential type */ - protected onCredentialTypeChanged(type: PasswordAlgorithm) { + protected onCredentialTypeChanged(type: CredentialAlgorithm) { // break subscription cycle if (this.credentialType$.value !== type) { this.zone.run(() => { @@ -169,29 +169,34 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { preferences.next(preference); }); - // populate the form with the user's preferences to kick off interactivity - preferences.pipe(takeUntil(this.destroyed)).subscribe(({ password }) => { - // update navigation - this.onCredentialTypeChanged(password.algorithm); - - // load algorithm metadata - const algorithm = this.generatorService.algorithm(password.algorithm); - - // update subjects within the angular zone so that the - // template bindings refresh immediately - this.zone.run(() => { - this.algorithm$.next(algorithm); - }); - }); - - // generate on load unless the generator prohibits it - this.algorithm$ + // update active algorithm + preferences .pipe( - distinctUntilChanged((prev, next) => prev.id === next.id), - filter((a) => !a.onlyOnRequest), + map(({ password }) => this.generatorService.algorithm(password.algorithm)), + distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), takeUntil(this.destroyed), ) - .subscribe(() => this.generate("autogenerate")); + .subscribe((algorithm) => { + // update navigation + this.onCredentialTypeChanged(algorithm.id); + + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + + // generate on load unless the generator prohibits it + this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.zone.run(() => { + if (!a || a.onlyOnRequest) { + this.value$.next("-"); + } else { + this.generate("autogenerate").catch((e: unknown) => this.logService.error(e)); + } + }); + }); } private typeToGenerator$(type: CredentialAlgorithm) { diff --git a/libs/tools/generator/components/src/password-settings.component.html b/libs/tools/generator/components/src/password-settings.component.html index aa12a3247c..9f8e00921f 100644 --- a/libs/tools/generator/components/src/password-settings.component.html +++ b/libs/tools/generator/components/src/password-settings.component.html @@ -7,7 +7,7 @@ {{ "length" | i18n }} - + {{ lengthBoundariesHint$ | async }} @@ -21,7 +21,12 @@ attr.aria-description="{{ 'uppercaseDescription' | i18n }}" title="{{ 'uppercaseDescription' | i18n }}" > - + {{ "uppercaseLabel" | i18n }} - + {{ "lowercaseLabel" | i18n }} - + {{ "numbersLabel" | i18n }} - + {{ "specialCharactersLabel" | i18n }}
{{ "minNumbers" | i18n }} - + {{ "minSpecial" | i18n }} - +
- + {{ "avoidAmbiguous" | i18n }}

{{ "generatorPolicyInEffect" | i18n }}

diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index 6e9d106b71..677a3417b9 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -1,7 +1,17 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { BehaviorSubject, takeUntil, Subject, map, filter, tap, skip, ReplaySubject } from "rxjs"; +import { + BehaviorSubject, + takeUntil, + Subject, + map, + filter, + tap, + skip, + ReplaySubject, + withLatestFrom, +} from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -12,7 +22,7 @@ import { PasswordGenerationOptions, } from "@bitwarden/generator-core"; -import { completeOnAccountSwitch, toValidators } from "./util"; +import { completeOnAccountSwitch } from "./util"; const Controls = Object.freeze({ length: "length", @@ -118,23 +128,11 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { this.settings.patchValue(s, { emitEvent: false }); }); - // bind policy to the template + // explain policy & disable policy-overridden fields this.generatorService .policy$(Generators.password, { userId$: singleUserId$ }) .pipe(takeUntil(this.destroyed$)) .subscribe(({ constraints }) => { - this.settings - .get(Controls.length) - .setValidators(toValidators(Controls.length, Generators.password, constraints)); - - this.minNumber.setValidators( - toValidators(Controls.minNumber, Generators.password, constraints), - ); - - this.minSpecial.setValidators( - toValidators(Controls.minSpecial, Generators.password, constraints), - ); - this.policyInEffect = constraints.policyInEffect; const toggles = [ @@ -153,8 +151,8 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { const boundariesHint = this.i18nService.t( "generatorBoundariesHint", - constraints.length.min, - constraints.length.max, + constraints.length.min?.toString(), + constraints.length.max?.toString(), ); this.lengthBoundariesHint.next(boundariesHint); }); @@ -201,9 +199,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); // now that outputs are set up, connect inputs - this.settings.valueChanges + this.saveSettings .pipe( - map((settings) => { + withLatestFrom(this.settings.valueChanges), + map(([, settings]) => { // interface is "avoid" while storage is "include" const s: any = { ...settings }; s.ambiguous = s.avoidAmbiguous; @@ -215,6 +214,11 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { .subscribe(settings); } + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); + } + /** display binding for enterprise policy notice */ protected policyInEffect: boolean; @@ -246,6 +250,7 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } diff --git a/libs/tools/generator/components/src/subaddress-settings.component.html b/libs/tools/generator/components/src/subaddress-settings.component.html index 16f3aea28b..1dfb5e3460 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.html +++ b/libs/tools/generator/components/src/subaddress-settings.component.html @@ -1,6 +1,11 @@
{{ "email" | i18n }} - +
diff --git a/libs/tools/generator/components/src/subaddress-settings.component.ts b/libs/tools/generator/components/src/subaddress-settings.component.ts index bd6ca899db..5a310c8def 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.ts +++ b/libs/tools/generator/components/src/subaddress-settings.component.ts @@ -53,28 +53,25 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy { const singleUserId$ = this.singleUserId$(); const settings = await this.generatorService.settings(Generators.subaddress, { singleUserId$ }); - settings - .pipe( - withLatestFrom(this.accountService.activeAccount$), - map(([settings, activeAccount]) => { - // if the subaddress isn't specified, copy it from - // the user's settings - if ((settings.subaddressEmail ?? "").length < 1) { - settings.subaddressEmail = activeAccount.email; - } - - return settings; - }), - takeUntil(this.destroyed$), - ) - .subscribe((s) => { - this.settings.patchValue(s, { emitEvent: false }); - }); + settings.pipe(takeUntil(this.destroyed$)).subscribe((s) => { + this.settings.patchValue(s, { emitEvent: false }); + }); // the first emission is the current value; subsequent emissions are updates settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); - this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + this.saveSettings + .pipe( + withLatestFrom(this.settings.valueChanges), + map(([, settings]) => settings), + takeUntil(this.destroyed$), + ) + .subscribe(settings); + } + + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); } private singleUserId$() { @@ -92,6 +89,7 @@ export class SubaddressSettingsComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 36aaae57ce..31e224713e 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -9,6 +9,7 @@ buttonType="main" (click)="generate('user request')" [appA11yTitle]="credentialTypeGenerateLabel$ | async" + [disabled]="!(algorithm$ | async)" > {{ credentialTypeGenerateLabel$ | async }} @@ -20,6 +21,7 @@ [appA11yTitle]="credentialTypeCopyLabel$ | async" [appCopyClick]="value$ | async" [valueLabel]="credentialTypeLabel$ | async" + [disabled]="!(algorithm$ | async)" > {{ credentialTypeCopyLabel$ | async }} diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index 6518ee51ed..3ac24ccd20 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -322,7 +322,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { if (!a || a.onlyOnRequest) { this.value$.next("-"); } else { - this.generate("autogenerate"); + this.generate("autogenerate").catch((e: unknown) => this.logService.error(e)); } }); }); @@ -414,7 +414,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { * @param requestor a label used to trace generation request * origin in the debugger. */ - protected generate(requestor: string) { + protected async generate(requestor: string) { this.generate$.next(requestor); } @@ -429,6 +429,7 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { private readonly destroyed = new Subject(); ngOnDestroy() { + this.destroyed.next(); this.destroyed.complete(); // finalize subjects diff --git a/libs/tools/generator/components/src/username-settings.component.html b/libs/tools/generator/components/src/username-settings.component.html index 4a4f8cd9fe..649cd052e7 100644 --- a/libs/tools/generator/components/src/username-settings.component.html +++ b/libs/tools/generator/components/src/username-settings.component.html @@ -1,10 +1,20 @@
- + {{ "capitalize" | i18n }} - + {{ "includeNumber" | i18n }}
diff --git a/libs/tools/generator/components/src/username-settings.component.ts b/libs/tools/generator/components/src/username-settings.component.ts index 8237b8674c..05a46feaaa 100644 --- a/libs/tools/generator/components/src/username-settings.component.ts +++ b/libs/tools/generator/components/src/username-settings.component.ts @@ -1,6 +1,6 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder } from "@angular/forms"; -import { BehaviorSubject, skip, Subject, takeUntil } from "rxjs"; +import { BehaviorSubject, map, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserId } from "@bitwarden/common/types/guid"; @@ -61,7 +61,18 @@ export class UsernameSettingsComponent implements OnInit, OnDestroy { // the first emission is the current value; subsequent emissions are updates settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated); - this.settings.valueChanges.pipe(takeUntil(this.destroyed$)).subscribe(settings); + this.saveSettings + .pipe( + withLatestFrom(this.settings.valueChanges), + map(([, settings]) => settings), + takeUntil(this.destroyed$), + ) + .subscribe(settings); + } + + private saveSettings = new Subject(); + save(site: string = "component api call") { + this.saveSettings.next(site); } private singleUserId$() { @@ -79,6 +90,7 @@ export class UsernameSettingsComponent implements OnInit, OnDestroy { private readonly destroyed$ = new Subject(); ngOnDestroy(): void { + this.destroyed$.next(); this.destroyed$.complete(); } } diff --git a/libs/tools/generator/components/src/util.ts b/libs/tools/generator/components/src/util.ts index d6cd4e6fba..7977f77459 100644 --- a/libs/tools/generator/components/src/util.ts +++ b/libs/tools/generator/components/src/util.ts @@ -49,7 +49,7 @@ export function toValidators( } const max = getConstraint("max", config, runtime); - if (max === undefined) { + if (max !== undefined) { validators.push(Validators.max(max)); } diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index 6090fe789c..6ddea595ec 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -1,7 +1,10 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { EmailRandomizer, @@ -19,12 +22,12 @@ import { PasswordGeneratorOptionsEvaluator, passwordLeastPrivilege, } from "../policies"; +import { CatchallConstraints } from "../policies/catchall-constraints"; +import { SubaddressConstraints } from "../policies/subaddress-constraints"; import { - CATCHALL_SETTINGS, EFF_USERNAME_SETTINGS, PASSPHRASE_SETTINGS, PASSWORD_SETTINGS, - SUBADDRESS_SETTINGS, } from "../strategies/storage"; import { CatchallGenerationOptions, @@ -178,79 +181,115 @@ const USERNAME = Object.freeze({ }, } satisfies CredentialGeneratorConfiguration); -const CATCHALL = Object.freeze({ - id: "catchall", - category: "email", - nameKey: "catchallEmail", - descriptionKey: "catchallEmailDesc", - generateKey: "generateEmail", - generatedValueKey: "email", - copyKey: "copyEmail", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new EmailRandomizer(dependencies.randomizer); +const CATCHALL: CredentialGeneratorConfiguration = + Object.freeze({ + id: "catchall", + category: "email", + nameKey: "catchallEmail", + descriptionKey: "catchallEmailDesc", + generateKey: "generateEmail", + generatedValueKey: "email", + copyKey: "copyEmail", + onlyOnRequest: false, + request: [], + engine: { + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); + }, }, - }, - settings: { - initial: DefaultCatchallOptions, - constraints: { catchallDomain: { minLength: 1 } }, - account: CATCHALL_SETTINGS, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; + settings: { + initial: DefaultCatchallOptions, + constraints: { catchallDomain: { minLength: 1 } }, + account: { + key: "catchallGeneratorSettings", + target: "object", + format: "plain", + classifier: new PublicClassifier([ + "catchallType", + "catchallDomain", + ]), + state: GENERATOR_DISK, + initial: { + catchallType: "random", + catchallDomain: "", + }, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: {}, + combine(_acc: NoPolicy, _policy: Policy) { + return {}; + }, + createEvaluator(_policy: NoPolicy) { + return new DefaultPolicyEvaluator(); + }, + toConstraints(_policy: NoPolicy, email: string) { + return new CatchallConstraints(email); + }, }, - toConstraints(_policy: NoPolicy) { - return new IdentityConstraint(); - }, - }, -} satisfies CredentialGeneratorConfiguration); + }); -const SUBADDRESS = Object.freeze({ - id: "subaddress", - category: "email", - nameKey: "plusAddressedEmail", - descriptionKey: "plusAddressedEmailDesc", - generateKey: "generateEmail", - generatedValueKey: "email", - copyKey: "copyEmail", - onlyOnRequest: false, - request: [], - engine: { - create( - dependencies: GeneratorDependencyProvider, - ): CredentialGenerator { - return new EmailRandomizer(dependencies.randomizer); +const SUBADDRESS: CredentialGeneratorConfiguration = + Object.freeze({ + id: "subaddress", + category: "email", + nameKey: "plusAddressedEmail", + descriptionKey: "plusAddressedEmailDesc", + generateKey: "generateEmail", + generatedValueKey: "email", + copyKey: "copyEmail", + onlyOnRequest: false, + request: [], + engine: { + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); + }, }, - }, - settings: { - initial: DefaultSubaddressOptions, - constraints: {}, - account: SUBADDRESS_SETTINGS, - }, - policy: { - type: PolicyType.PasswordGenerator, - disabledValue: {}, - combine(_acc: NoPolicy, _policy: Policy) { - return {}; + settings: { + initial: DefaultSubaddressOptions, + constraints: {}, + account: { + key: "subaddressGeneratorSettings", + target: "object", + format: "plain", + classifier: new PublicClassifier([ + "subaddressType", + "subaddressEmail", + ]), + state: GENERATOR_DISK, + initial: { + subaddressType: "random", + subaddressEmail: "", + }, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, }, - createEvaluator(_policy: NoPolicy) { - return new DefaultPolicyEvaluator(); + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: {}, + combine(_acc: NoPolicy, _policy: Policy) { + return {}; + }, + createEvaluator(_policy: NoPolicy) { + return new DefaultPolicyEvaluator(); + }, + toConstraints(_policy: NoPolicy, email: string) { + return new SubaddressConstraints(email); + }, }, - toConstraints(_policy: NoPolicy) { - return new IdentityConstraint(); - }, - }, -} satisfies CredentialGeneratorConfiguration); + }); export function toCredentialGeneratorConfiguration( configuration: ForwarderConfiguration, diff --git a/libs/tools/generator/core/src/policies/catchall-constraints.ts b/libs/tools/generator/core/src/policies/catchall-constraints.ts new file mode 100644 index 0000000000..37f62f874c --- /dev/null +++ b/libs/tools/generator/core/src/policies/catchall-constraints.ts @@ -0,0 +1,45 @@ +import { Constraints, StateConstraints } from "@bitwarden/common/tools/types"; + +import { CatchallGenerationOptions } from "../types"; + +/** Parses the domain part of an email address + */ +const DOMAIN_PARSER = new RegExp("[^@]+@(?.+)"); + +/** A constraint that sets the catchall domain using a fixed email address */ +export class CatchallConstraints implements StateConstraints { + /** Creates a catchall constraints + * @param email - the email address containing the domain. + */ + constructor(email: string) { + if (!email) { + this.domain = ""; + return; + } + + const parsed = DOMAIN_PARSER.exec(email); + if (parsed && parsed.groups?.domain) { + this.domain = parsed.groups.domain; + } + } + private domain: string; + + constraints: Readonly> = {}; + + adjust(state: CatchallGenerationOptions) { + const currentDomain = (state.catchallDomain ?? "").trim(); + + if (currentDomain !== "") { + return state; + } + + const options = { ...state }; + options.catchallDomain = this.domain; + + return options; + } + + fix(state: CatchallGenerationOptions) { + return state; + } +} diff --git a/libs/tools/generator/core/src/policies/constraints.ts b/libs/tools/generator/core/src/policies/constraints.ts index 6071b57048..d320329938 100644 --- a/libs/tools/generator/core/src/policies/constraints.ts +++ b/libs/tools/generator/core/src/policies/constraints.ts @@ -2,6 +2,7 @@ import { Constraint } from "@bitwarden/common/tools/types"; import { sum } from "../util"; +const Zero: Constraint = { min: 0, max: 0 }; const AtLeastOne: Constraint = { min: 1 }; const RequiresTrue: Constraint = { requiredValue: true }; @@ -159,6 +160,7 @@ export { enforceConstant, readonlyTrueWhen, fitLength, + Zero, AtLeastOne, RequiresTrue, }; diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts index 96f590f8ed..d05d75ffb7 100644 --- a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts +++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.spec.ts @@ -1,6 +1,6 @@ import { DefaultPasswordBoundaries, DefaultPasswordGenerationOptions, Policies } from "../data"; -import { AtLeastOne } from "./constraints"; +import { AtLeastOne, Zero } from "./constraints"; import { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints"; describe("DynamicPasswordPolicyConstraints", () => { @@ -207,7 +207,7 @@ describe("DynamicPasswordPolicyConstraints", () => { expect(calibrated.constraints.minNumber).toEqual(dynamic.constraints.minNumber); }); - it("disables the minNumber constraint when the state's number flag is false", () => { + it("outputs the zero constraint when the state's number flag is false", () => { const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); const state = { ...DefaultPasswordGenerationOptions, @@ -216,7 +216,7 @@ describe("DynamicPasswordPolicyConstraints", () => { const calibrated = dynamic.calibrate(state); - expect(calibrated.constraints.minNumber).toBeUndefined(); + expect(calibrated.constraints.minNumber).toEqual(Zero); }); it("outputs the minSpecial constraint when the state's special flag is true", () => { @@ -231,7 +231,7 @@ describe("DynamicPasswordPolicyConstraints", () => { expect(calibrated.constraints.minSpecial).toEqual(dynamic.constraints.minSpecial); }); - it("disables the minSpecial constraint when the state's special flag is false", () => { + it("outputs the zero constraint when the state's special flag is false", () => { const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); const state = { ...DefaultPasswordGenerationOptions, @@ -240,23 +240,7 @@ describe("DynamicPasswordPolicyConstraints", () => { const calibrated = dynamic.calibrate(state); - expect(calibrated.constraints.minSpecial).toBeUndefined(); - }); - - it("copies the minimum length constraint", () => { - const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); - - const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions); - - expect(calibrated.constraints.minSpecial).toBeUndefined(); - }); - - it("overrides the minimum length constraint when it is less than the sum of the state's minimums", () => { - const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue); - - const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions); - - expect(calibrated.constraints.minSpecial).toBeUndefined(); + expect(calibrated.constraints.minSpecial).toEqual(Zero); }); }); }); diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts index daff988254..7fe7606188 100644 --- a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts +++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts @@ -7,7 +7,7 @@ import { import { DefaultPasswordBoundaries } from "../data"; import { PasswordGeneratorPolicy, PasswordGeneratorSettings } from "../types"; -import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne } from "./constraints"; +import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne, Zero } from "./constraints"; import { PasswordPolicyConstraints } from "./password-policy-constraints"; /** Creates state constraints by blending policy and password settings. */ @@ -68,8 +68,8 @@ export class DynamicPasswordPolicyConstraints ...this.constraints, minLowercase: maybe(lowercase, this.constraints.minLowercase ?? AtLeastOne), minUppercase: maybe(uppercase, this.constraints.minUppercase ?? AtLeastOne), - minNumber: maybe(number, this.constraints.minNumber), - minSpecial: maybe(special, this.constraints.minSpecial), + minNumber: maybe(number, this.constraints.minNumber) ?? Zero, + minSpecial: maybe(special, this.constraints.minSpecial) ?? Zero, }; // lower bound of length must always at least fit its sub-lengths diff --git a/libs/tools/generator/core/src/policies/subaddress-constraints.ts b/libs/tools/generator/core/src/policies/subaddress-constraints.ts new file mode 100644 index 0000000000..db05f712cf --- /dev/null +++ b/libs/tools/generator/core/src/policies/subaddress-constraints.ts @@ -0,0 +1,34 @@ +import { Constraints, StateConstraints } from "@bitwarden/common/tools/types"; + +import { SubaddressGenerationOptions } from "../types"; + +/** A constraint that sets the subaddress email using a fixed email address */ +export class SubaddressConstraints implements StateConstraints { + /** Creates a catchall constraints + * @param email - the email address containing the domain. + */ + constructor(readonly email: string) { + if (!email) { + this.email = ""; + } + } + + constraints: Readonly> = {}; + + adjust(state: SubaddressGenerationOptions) { + const currentDomain = (state.subaddressEmail ?? "").trim(); + + if (currentDomain !== "") { + return state; + } + + const options = { ...state }; + options.subaddressEmail = this.email; + + return options; + } + + fix(state: SubaddressGenerationOptions) { + return state; + } +} diff --git a/libs/tools/generator/core/src/rx.ts b/libs/tools/generator/core/src/rx.ts index 070d34d37d..44d23ef1c5 100644 --- a/libs/tools/generator/core/src/rx.ts +++ b/libs/tools/generator/core/src/rx.ts @@ -23,11 +23,12 @@ export function mapPolicyToEvaluator( */ export function mapPolicyToConstraints( configuration: PolicyConfiguration, + email: string, ) { return pipe( reduceCollection(configuration.combine, configuration.disabledValue), distinctIfShallowMatch(), - map(configuration.toConstraints), + map((policy) => configuration.toConstraints(policy, email)), ); } diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts index 225745e5f9..bd26642157 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -202,6 +202,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); @@ -223,6 +224,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); @@ -248,6 +250,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); @@ -276,6 +279,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const website$ = new BehaviorSubject("some website"); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ })); @@ -297,6 +301,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const website$ = new BehaviorSubject("some website"); let error = null; @@ -322,6 +327,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const website$ = new BehaviorSubject("some website"); let completed = false; @@ -348,6 +354,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); @@ -368,6 +375,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.pipe(filter((u) => !!u)); @@ -392,6 +400,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(SomeUser); let error = null; @@ -417,6 +426,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(SomeUser); let completed = false; @@ -443,6 +453,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const on$ = new Subject(); const results: any[] = []; @@ -485,6 +496,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const on$ = new Subject(); let error: any = null; @@ -511,6 +523,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const on$ = new Subject(); let complete = false; @@ -542,6 +555,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = generator.algorithms("password"); @@ -563,6 +577,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = generator.algorithms("username"); @@ -583,6 +598,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = generator.algorithms("email"); @@ -604,6 +620,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = generator.algorithms(["username", "email"]); @@ -629,6 +646,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.algorithms$("password")); @@ -646,6 +664,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.algorithms$("username")); @@ -662,6 +681,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.algorithms$("email")); @@ -679,6 +699,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.algorithms$(["username", "email"])); @@ -701,6 +722,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.algorithms$(["password"])); @@ -726,6 +748,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const results: any = []; const sub = generator.algorithms$("password").subscribe((r) => results.push(r)); @@ -763,6 +786,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); @@ -784,6 +808,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -814,6 +839,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -840,6 +866,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -866,6 +893,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -898,6 +926,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -916,6 +945,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -936,6 +966,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -961,6 +992,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const results: any = []; const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r)); @@ -986,6 +1018,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); @@ -1007,6 +1040,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1034,6 +1068,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1060,6 +1095,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1086,6 +1122,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1118,6 +1155,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const subject = await generator.settings(SomeConfiguration, { singleUserId$ }); @@ -1139,6 +1177,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); let completed = false; @@ -1165,6 +1204,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(SomeUser).asObservable(); @@ -1182,6 +1222,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const policy$ = new BehaviorSubject([somePolicy]); @@ -1201,6 +1242,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1230,6 +1272,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1260,6 +1303,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); @@ -1286,6 +1330,7 @@ describe("CredentialGeneratorService", () => { i18nService, encryptService, keyService, + accountService, ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts index 04413ba2c0..8c971b0d61 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -23,6 +23,7 @@ import { Simplify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -98,6 +99,7 @@ export class CredentialGeneratorService { private readonly i18nService: I18nService, private readonly encryptService: EncryptService, private readonly keyService: KeyService, + private readonly accountService: AccountService, ) {} private getDependencyProvider(): GeneratorDependencyProvider { @@ -380,17 +382,30 @@ export class CredentialGeneratorService { configuration: Configuration, dependencies: Policy$Dependencies, ): Observable> { - const completion$ = dependencies.userId$.pipe(ignoreElements(), endWith(true)); + const email$ = dependencies.userId$.pipe( + distinctUntilChanged(), + withLatestFrom(this.accountService.accounts$), + filter((accounts) => !!accounts), + map(([userId, accounts]) => { + if (userId in accounts) { + return { userId, email: accounts[userId].email }; + } - const constraints$ = dependencies.userId$.pipe( - switchMap((userId) => { - // complete policy emissions otherwise `mergeMap` holds `policies$` open indefinitely + return { userId, email: null }; + }), + ); + + const constraints$ = email$.pipe( + switchMap(({ userId, email }) => { + // complete policy emissions otherwise `switchMap` holds `policies$` open indefinitely const policies$ = this.policyService .getAll$(configuration.policy.type, userId) - .pipe(takeUntil(completion$)); + .pipe( + mapPolicyToConstraints(configuration.policy, email), + takeUntil(anyComplete(email$)), + ); return policies$; }), - mapPolicyToConstraints(configuration.policy), ); return constraints$; diff --git a/libs/tools/generator/core/src/types/policy-configuration.ts b/libs/tools/generator/core/src/types/policy-configuration.ts index 2b01a04b92..07ded88660 100644 --- a/libs/tools/generator/core/src/types/policy-configuration.ts +++ b/libs/tools/generator/core/src/types/policy-configuration.ts @@ -24,9 +24,13 @@ export type PolicyConfiguration = { createEvaluator: (policy: Policy) => PolicyEvaluator; /** Converts policy service data into actionable policy constraints. + * + * @param policy - the policy to map into policy constraints. + * @param email - the default email to extend. + * * @remarks this version includes constraints needed for the reactive forms; * it was introduced so that the constraints can be incrementally introduced * as the new UI is built. */ - toConstraints: (policy: Policy) => GeneratorConstraints; + toConstraints: (policy: Policy, email: string) => GeneratorConstraints; }; diff --git a/libs/tools/send/send-ui/src/send-form/send-form.module.ts b/libs/tools/send/send-ui/src/send-form/send-form.module.ts index 67f1f910cc..ec51c2c0e3 100644 --- a/libs/tools/send/send-ui/src/send-form/send-form.module.ts +++ b/libs/tools/send/send-ui/src/send-form/send-form.module.ts @@ -4,6 +4,7 @@ import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -43,6 +44,7 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); I18nService, EncryptService, KeyService, + AccountService, ], }), ], diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index b62557a432..93229bda6c 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -87,7 +87,12 @@ describe("ItemDetailsSectionComponent", () => { component.config.allowPersonalOwnership = true; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; component.originalCipherView = { name: "cipher1", @@ -116,8 +121,18 @@ describe("ItemDetailsSectionComponent", () => { component.config.allowPersonalOwnership = true; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, - { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; component.originalCipherView = { name: "cipher1", @@ -367,9 +382,24 @@ describe("ItemDetailsSectionComponent", () => { } as CipherView; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, - { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, - { id: "col3", name: "Collection 3", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, + { + id: "col3", + name: "Collection 3", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; fixture.detectChanges(); @@ -387,7 +417,12 @@ describe("ItemDetailsSectionComponent", () => { component.config.allowPersonalOwnership = true; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, ]; fixture.detectChanges(); @@ -414,13 +449,24 @@ describe("ItemDetailsSectionComponent", () => { } as CipherView; component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ - { id: "col1", name: "Collection 1", organizationId: "org1" } as CollectionView, - { id: "col2", name: "Collection 2", organizationId: "org1" } as CollectionView, + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => true, + } as CollectionView, { id: "col3", name: "Collection 3", organizationId: "org1", readOnly: true, + canEditItems: (_org) => true, } as CollectionView, ]; @@ -433,5 +479,94 @@ describe("ItemDetailsSectionComponent", () => { expect(collectionHint).not.toBeNull(); }); + + it("should allow all collections to be altered when `config.admin` is true", async () => { + component.config.admin = true; + component.config.allowPersonalOwnership = true; + component.config.organizations = [{ id: "org1" } as Organization]; + component.config.collections = [ + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col3", + name: "Collection 3", + organizationId: "org1", + readOnly: false, + canEditItems: (_org) => false, + } as CollectionView, + ]; + + fixture.detectChanges(); + await fixture.whenStable(); + + component.itemDetailsForm.controls.organizationId.setValue("org1"); + + expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]); + }); + }); + + describe("readonlyCollections", () => { + beforeEach(() => { + component.config.mode = "edit"; + component.config.admin = true; + component.config.collections = [ + { + id: "col1", + name: "Collection 1", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col2", + name: "Collection 2", + organizationId: "org1", + canEditItems: (_org) => false, + } as CollectionView, + { + id: "col3", + name: "Collection 3", + organizationId: "org1", + readOnly: true, + canEditItems: (_org) => false, + } as CollectionView, + ]; + component.originalCipherView = { + name: "cipher1", + organizationId: "org1", + folderId: "folder1", + collectionIds: ["col1", "col2", "col3"], + favorite: true, + } as CipherView; + component.config.organizations = [{ id: "org1" } as Organization]; + }); + + it("should not show collections as readonly when `config.admin` is true", async () => { + await component.ngOnInit(); + fixture.detectChanges(); + + // Filters out all collections + expect(component["readOnlyCollections"]).toEqual([]); + + // Non-admin, keep readonly collections + component.config.admin = false; + + await component.ngOnInit(); + fixture.detectChanges(); + + expect(component["readOnlyCollections"]).toEqual(["Collection 1", "Collection 3"]); + }); }); }); diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index 86a8818bbe..ea82aa0cae 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -240,7 +240,11 @@ export class ItemDetailsSectionComponent implements OnInit { } else if (this.config.mode === "edit") { this.readOnlyCollections = this.collections .filter( - (c) => c.readOnly && this.originalCipherView.collectionIds.includes(c.id as CollectionId), + // When the configuration is set up for admins, they can alter read only collections + (c) => + c.readOnly && + !this.config.admin && + this.originalCipherView.collectionIds.includes(c.id as CollectionId), ) .map((c) => c.name); } @@ -262,12 +266,24 @@ export class ItemDetailsSectionComponent implements OnInit { collectionsControl.disable(); this.showCollectionsControl = false; return; + } else { + collectionsControl.enable(); + this.showCollectionsControl = true; } + const organization = this.organizations.find((o) => o.id === orgId); + this.collectionOptions = this.collections .filter((c) => { - // If partial edit mode, show all org collections because the control is disabled. - return c.organizationId === orgId && (this.partialEdit || !c.readOnly); + // Filter criteria: + // - The collection belongs to the organization + // - When in partial edit mode, show all org collections because the control is disabled. + // - The user can edit items within the collection + // - When viewing as an admin, all collections should be shown, even readonly. When non-admin, filter out readonly collections + return ( + c.organizationId === orgId && + (this.partialEdit || c.canEditItems(organization) || this.config.admin) + ); }) .map((c) => ({ id: c.id, diff --git a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts index 8e73d9edd4..1b7e86f82a 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form.service.ts @@ -1,6 +1,7 @@ import { inject, Injectable } from "@angular/core"; import { firstValueFrom, map } from "rxjs"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -17,6 +18,7 @@ function isSetEqual(a: Set, b: Set) { export class DefaultCipherFormService implements CipherFormService { private cipherService: CipherService = inject(CipherService); private accountService: AccountService = inject(AccountService); + private apiService: ApiService = inject(ApiService); async decryptCipher(cipher: Cipher): Promise { const activeUserId = await firstValueFrom( @@ -66,11 +68,21 @@ export class DefaultCipherFormService implements CipherFormService { // Updating a cipher with collection changes is not supported with a single request currently // First update the cipher with the original collectionIds encryptedCipher.collectionIds = config.originalCipher.collectionIds; - await this.cipherService.updateWithServer(encryptedCipher, config.admin); + await this.cipherService.updateWithServer( + encryptedCipher, + config.admin || originalCollectionIds.size === 0, + config.mode !== "clone", + ); // Then save the new collection changes separately encryptedCipher.collectionIds = cipher.collectionIds; - savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher); + + if (config.admin || originalCollectionIds.size === 0) { + // When using an admin config or the cipher was unassigned, update collections as an admin + savedCipher = await this.cipherService.saveCollectionsWithServerAdmin(encryptedCipher); + } else { + savedCipher = await this.cipherService.saveCollectionsWithServer(encryptedCipher); + } } // Its possible the cipher was made no longer available due to collection assignment changes diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index 2dd98092cb..ad5191b0e2 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -18,6 +18,7 @@ [organization]="organization$ | async" [collections]="collections" [folder]="folder$ | async" + [hideOwner]="isAdminConsole" > diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 324b2358a8..0871fd8e78 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -51,6 +51,10 @@ export class CipherViewComponent implements OnChanges, OnDestroy { * `CipherService` and the `collectionIds` property of the cipher. */ @Input() collections: CollectionView[]; + + /** Should be set to true when the component is used within the Admin Console */ + @Input() isAdminConsole?: boolean = false; + organization$: Observable; folder$: Observable; private destroyed$: Subject = new Subject(); @@ -94,6 +98,7 @@ export class CipherViewComponent implements OnChanges, OnDestroy { async loadCipherData() { // Load collections if not provided and the cipher has collectionIds if ( + this.cipher.collectionIds && this.cipher.collectionIds.length > 0 && (!this.collections || this.collections.length === 0) ) { diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.html b/libs/vault/src/cipher-view/item-details/item-details-v2.component.html index 2d15230f9a..b6b4256440 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.html +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.html @@ -4,10 +4,8 @@ {{ "itemName" | i18n }} @@ -24,11 +22,11 @@
  • { + let component: ItemDetailsV2Component; + let fixture: ComponentFixture; + + const cipher = { + id: "cipher1", + collectionIds: ["col1", "col2"], + organizationId: "org1", + folderId: "folder1", + name: "cipher name", + } as CipherView; + + const organization = { + id: "org1", + name: "Organization 1", + } as Organization; + + const collection = { + id: "col1", + name: "Collection 1", + } as CollectionView; + + const collection2 = { + id: "col2", + name: "Collection 2", + } as CollectionView; + + const folder = { + id: "folder1", + name: "Folder 1", + } as FolderView; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ItemDetailsV2Component], + providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemDetailsV2Component); + component = fixture.componentInstance; + component.cipher = cipher; + component.organization = organization; + component.collections = [collection, collection2]; + component.folder = folder; + fixture.detectChanges(); + }); + + it("displays all available fields", () => { + const itemName = fixture.debugElement.query(By.css('[data-testid="item-name"]')); + const owner = fixture.debugElement.query(By.css('[data-testid="owner"]')); + const collections = fixture.debugElement.queryAll(By.css('[data-testid="collections"] li')); + const folderElement = fixture.debugElement.query(By.css('[data-testid="folder"]')); + + expect(itemName.nativeElement.value).toBe(cipher.name); + expect(owner.nativeElement.textContent.trim()).toBe(organization.name); + expect(collections.map((c) => c.nativeElement.textContent.trim())).toEqual([ + collection.name, + collection2.name, + ]); + expect(folderElement.nativeElement.textContent.trim()).toBe(folder.name); + }); + + it("does not render owner when `hideOwner` is true", () => { + component.hideOwner = true; + fixture.detectChanges(); + + const owner = fixture.debugElement.query(By.css('[data-testid="owner"]')); + expect(owner).toBeNull(); + }); +}); diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts index 48c129bd3b..daa8092f4b 100644 --- a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts @@ -36,4 +36,9 @@ export class ItemDetailsV2Component { @Input() organization?: Organization; @Input() collections?: CollectionView[]; @Input() folder?: FolderView; + @Input() hideOwner?: boolean = false; + + get showOwnership() { + return this.cipher.organizationId && this.organization && !this.hideOwner; + } } diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html index 0bee6d30eb..afc38a58d6 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html @@ -111,6 +111,7 @@ [value]="totpCodeCopyObj?.totpCodeFormatted || '*** ***'" aria-readonly="true" data-testid="login-totp" + class="tw-font-mono" />
    - - + [appCopyClick]="h.password" + [valueLabel]="'password' | i18n" + showToast + > diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts index 8772a24582..3900681f23 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts +++ b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts @@ -3,14 +3,13 @@ import { By } from "@angular/platform-browser"; import { BehaviorSubject } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.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 { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; -import { ColorPasswordModule, ItemModule, ToastService } from "@bitwarden/components"; +import { ColorPasswordModule, ItemModule } from "@bitwarden/components"; import { ColorPasswordComponent } from "@bitwarden/components/src/color-password/color-password.component"; import { PasswordHistoryViewComponent } from "./password-history-view.component"; @@ -25,8 +24,6 @@ describe("PasswordHistoryViewComponent", () => { organizationId: "222-444-555", } as CipherView; - const copyToClipboard = jest.fn(); - const showToast = jest.fn(); const activeAccount$ = new BehaviorSubject<{ id: string }>({ id: "666-444-444" }); const mockCipherService = { get: jest.fn().mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }), @@ -36,17 +33,13 @@ describe("PasswordHistoryViewComponent", () => { beforeEach(async () => { mockCipherService.get.mockClear(); mockCipherService.getKeyForCipherKeyDecryption.mockClear(); - copyToClipboard.mockClear(); - showToast.mockClear(); await TestBed.configureTestingModule({ imports: [ItemModule, ColorPasswordModule, JslibModule], providers: [ - { provide: WINDOW, useValue: window }, { provide: CipherService, useValue: mockCipherService }, - { provide: PlatformUtilsService, useValue: { copyToClipboard } }, + { provide: PlatformUtilsService }, { provide: AccountService, useValue: { activeAccount$ } }, - { provide: ToastService, useValue: { showToast } }, { provide: I18nService, useValue: { t: (key: string) => key } }, ], }).compileComponents(); @@ -80,18 +73,5 @@ describe("PasswordHistoryViewComponent", () => { "bad-password-2", ]); }); - - it("copies a password", () => { - const copyButton = fixture.debugElement.query(By.css("button")); - - copyButton.nativeElement.click(); - - expect(copyToClipboard).toHaveBeenCalledWith("bad-password-1", { window: window }); - expect(showToast).toHaveBeenCalledWith({ - message: "passwordCopied", - title: "", - variant: "info", - }); - }); }); }); diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.ts b/libs/vault/src/components/password-history-view/password-history-view.component.ts index 5e858af727..a0f0aa6b35 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.ts +++ b/libs/vault/src/components/password-history-view/password-history-view.component.ts @@ -1,21 +1,14 @@ import { CommonModule } from "@angular/common"; -import { OnInit, Inject, Component, Input } from "@angular/core"; +import { OnInit, Component, Input } from "@angular/core"; import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view"; -import { - ToastService, - ItemModule, - ColorPasswordModule, - IconButtonModule, -} from "@bitwarden/components"; +import { ItemModule, ColorPasswordModule, IconButtonModule } from "@bitwarden/components"; @Component({ selector: "vault-password-history-view", @@ -33,29 +26,15 @@ export class PasswordHistoryViewComponent implements OnInit { history: PasswordHistoryView[] = []; constructor( - @Inject(WINDOW) private win: Window, protected cipherService: CipherService, - protected platformUtilsService: PlatformUtilsService, protected i18nService: I18nService, protected accountService: AccountService, - protected toastService: ToastService, ) {} async ngOnInit() { await this.init(); } - /** Copies a password to the clipboard. */ - copy(password: string) { - const copyOptions = this.win != null ? { window: this.win } : undefined; - this.platformUtilsService.copyToClipboard(password, copyOptions); - this.toastService.showToast({ - variant: "info", - title: "", - message: this.i18nService.t("passwordCopied"), - }); - } - /** Retrieve the password history for the given cipher */ protected async init() { const cipher = await this.cipherService.get(this.cipherId);