diff --git a/.eslintrc.json b/.eslintrc.json index 8485a9f30a0..14b9a888444 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -73,6 +73,10 @@ { "message": "Calling `svgIcon` directly is not allowed", "selector": "CallExpression[callee.name='svgIcon']" + }, + { + "message": "Accessing FormGroup using `get` is not allowed, use `.value` instead", + "selector": "ChainExpression[expression.object.callee.property.name='get'][expression.property.name='value']" } ], "curly": ["error", "all"], @@ -97,7 +101,10 @@ ] } ], - "no-restricted-imports": ["error", { "patterns": ["src/**/*"] }] + "no-restricted-imports": [ + "error", + { "patterns": ["src/**/*"], "paths": ["@fluffy-spoon/substitute"] } + ] }, "overrides": [ { diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 675f5bbc55a..85b6bdf158c 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -370,8 +370,8 @@ jobs: - cloc - setup - build-artifacts - - build-containers - build-commercial-selfhost-image + - build-containers - crowdin-push steps: - name: Check if any job failed @@ -381,7 +381,7 @@ jobs: SETUP_STATUS: ${{ needs.setup.result }} ARTIFACT_STATUS: ${{ needs.build-artifacts.result }} BUILD_SELFHOST_STATUS: ${{ needs.build-commercial-selfhost-image.result }} - BUILD_QA_STATUS: ${{ needs.build-qa.result }} + BUILD_CONTAINERS_STATUS: ${{ needs.build-containers.result }} CROWDIN_PUSH_STATUS: ${{ needs.crowdin-push.result }} run: | if [ "$CLOC_STATUS" = "failure" ]; then @@ -392,7 +392,7 @@ jobs: exit 1 elif [ "$BUILD_SELFHOST_STATUS" = "failure" ]; then exit 1 - elif [ "$BUILD_QA_STATUS" = "failure" ]; then + elif [ "$BUILD_CONTAINERS_STATUS" = "failure" ]; then exit 1 elif [ "$CROWDIN_PUSH_STATUS" = "failure" ]; then exit 1 diff --git a/.github/workflows/release-browser.yml b/.github/workflows/release-browser.yml index 7279d3b08b4..514dafa65f8 100644 --- a/.github/workflows/release-browser.yml +++ b/.github/workflows/release-browser.yml @@ -102,7 +102,7 @@ jobs: - name: Download latest Release build artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-browser.yml workflow_conclusion: success @@ -115,7 +115,7 @@ jobs: - name: Download latest master build artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-browser.yml workflow_conclusion: success diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index c3ddbb98144..cad548f2b0d 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -76,7 +76,7 @@ jobs: - name: Download all Release artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli @@ -85,7 +85,7 @@ jobs: - name: Download all artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli @@ -167,7 +167,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli @@ -177,7 +177,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli @@ -232,7 +232,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli/dist @@ -242,7 +242,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli/dist @@ -289,7 +289,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli/build @@ -299,7 +299,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-cli.yml path: apps/cli/build diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index 2d34ab257e9..79a4eaa24cb 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -944,7 +944,11 @@ jobs: SECRETS: | aws-electron-access-id, aws-electron-access-key, - aws-electron-bucket-name + aws-electron-bucket-name, + r2-electron-access-id, + r2-electron-access-key, + r2-electron-bucket-name, + cf-prod-account run: | for i in ${SECRETS//,/ } do @@ -977,6 +981,20 @@ jobs: --recursive \ --quiet + - name: Publish artifacts to R2 + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.r2-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.r2-electron-access-key }} + AWS_DEFAULT_REGION: 'us-east-1' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.r2-electron-bucket-name }} + CF_ACCOUNT: ${{ steps.retrieve-secrets.outputs.cf-prod-account }} + working-directory: apps/desktop/artifacts + run: | + aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ + --recursive \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com + - name: Update deployment status to Success if: ${{ success() }} uses: chrnorm/deployment-status@07b3930847f65e71c9c6802ff5a402f6dfb46b86 diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 9a51a3b0bea..247b59ea43d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -13,13 +13,18 @@ on: - Initial Release - Redeploy - Dry Run + rollout_percentage: + description: 'Staged Rollout Percentage' + required: true + default: '10' + type: string snap_publish: - description: 'Publish to snap store' + description: 'Publish to Snap store' required: true default: true type: boolean choco_publish: - description: 'Publish to chocolatey store' + description: 'Publish to Chocolatey store' required: true default: true type: boolean @@ -93,23 +98,20 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - env: - KEYVAULT: bitwarden-prod-kv - SECRETS: | - aws-electron-access-id, + uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af + with: + keyvault: "bitwarden-prod-kv" + secrets: "aws-electron-access-id, aws-electron-access-key, - aws-electron-bucket-name - run: | - for i in ${SECRETS//,/ } - do - VALUE=$(az keyvault secret show --vault-name $KEYVAULT --name $i --query value --output tsv) - echo "::add-mask::$VALUE" - echo "::set-output name=$i::$VALUE" - done + aws-electron-bucket-name, + r2-electron-access-id, + r2-electron-access-key, + r2-electron-bucket-name, + cf-prod-account" - name: Download all artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success @@ -118,7 +120,7 @@ jobs: - name: Download all artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success @@ -131,6 +133,15 @@ jobs: working-directory: apps/desktop/artifacts run: mv Bitwarden-${{ env.PKG_VERSION }}-universal.pkg Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive + - name: Set staged rollout percentage + env: + RELEASE_CHANNEL: ${{ steps.release-channel.outputs.channel }} + ROLLOUT_PCT: ${{ github.event.inputs.rollout_percentage }} + run: | + echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}.yml + echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}-linux.yml + echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}-mac.yml + - name: Publish artifacts to S3 if: ${{ github.event.inputs.release_type != 'Dry Run' }} env: @@ -145,8 +156,23 @@ jobs: --recursive \ --quiet - - name: Create release - uses: ncipollo/release-action@95215a3cb6e6a1908b3c44e00b4fdb15548b1e09 # v2.8.5 + - name: Publish artifacts to R2 + if: ${{ github.event.inputs.release_type != 'Dry Run' }} + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.r2-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.r2-electron-access-key }} + AWS_DEFAULT_REGION: 'us-east-1' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.r2-electron-bucket-name }} + CF_ACCOUNT: ${{ steps.retrieve-secrets.outputs.cf-prod-account }} + working-directory: apps/desktop/artifacts + run: | + aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ + --recursive \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com + + - name: Create Release + uses: ncipollo/release-action@95215a3cb6e6a1908b3c44e00b4fdb15548b1e09 if: ${{ steps.release-channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' }} env: PKG_VERSION: ${{ steps.version.outputs.version }} @@ -217,17 +243,10 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - env: - KEYVAULT: bitwarden-prod-kv - SECRETS: | - snapcraft-store-token - run: | - for i in ${SECRETS//,/ } - do - VALUE=$(az keyvault secret show --vault-name $KEYVAULT --name $i --query value --output tsv) - echo "::add-mask::$VALUE" - echo "::set-output name=$i::$VALUE" - done + uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af + with: + keyvault: "bitwarden-prod-kv" + secrets: "snapcraft-store-token" - name: Install Snap uses: samuelmeuli/action-snapcraft@10d7d0a84d9d86098b19f872257df314b0bd8e2d # v1.2.0 @@ -240,7 +259,7 @@ jobs: - name: Download Snap artifact if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success @@ -250,7 +269,7 @@ jobs: - name: Download Snap artifact if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success @@ -274,7 +293,7 @@ jobs: _PKG_VERSION: ${{ needs.setup.outputs.release-version }} steps: - name: Checkout Repo - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f # v2.3.4 + uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f - name: Print Environment run: | @@ -288,17 +307,10 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - env: - KEYVAULT: bitwarden-prod-kv - SECRETS: | - cli-choco-api-key - run: | - for i in ${SECRETS//,/ } - do - VALUE=$(az keyvault secret show --vault-name $KEYVAULT --name $i --query value --output tsv) - echo "::add-mask::$VALUE" - echo "::set-output name=$i::$VALUE" - done + uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af + with: + keyvault: "bitwarden-prod-kv" + secrets: "cli-choco-api-key" - name: Setup Chocolatey shell: pwsh @@ -313,7 +325,7 @@ jobs: - name: Download choco artifact if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success @@ -323,7 +335,7 @@ jobs: - name: Download choco artifact if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-desktop.yml workflow_conclusion: success diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index 23f99c8f9b3..e52f6d5aa3a 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -154,7 +154,7 @@ jobs: - name: Download latest cloud asset if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-web.yml path: apps/web @@ -164,7 +164,7 @@ jobs: - name: Download latest cloud asset if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-web.yml path: apps/web @@ -240,7 +240,7 @@ jobs: - name: Download latest build artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-web.yml path: apps/web/artifacts @@ -251,7 +251,7 @@ jobs: - name: Download latest build artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8 + uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a with: workflow: build-web.yml path: apps/web/artifacts diff --git a/.github/workflows/staged-rollout-desktop.yml b/.github/workflows/staged-rollout-desktop.yml new file mode 100644 index 00000000000..bf6a21f286f --- /dev/null +++ b/.github/workflows/staged-rollout-desktop.yml @@ -0,0 +1,123 @@ +--- +name: Staged Rollout Desktop + +on: + workflow_dispatch: + inputs: + rollout_percentage: + description: 'Staged Rollout Percentage' + required: true + default: '10' + type: string + +defaults: + run: + shell: bash + +jobs: + rollout: + name: Update Rollout Percentage + runs-on: ubuntu-22.04 + outputs: + release-version: ${{ steps.version.outputs.version }} + release-channel: ${{ steps.release-channel.outputs.channel }} + steps: + - name: Login to Azure + uses: Azure/login@ec3c14589bd3e9312b3cc8c41e6860e258df9010 + with: + creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} + + - name: Retrieve secrets + id: retrieve-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af + with: + keyvault: "bitwarden-prod-kv" + secrets: "aws-electron-access-id, + aws-electron-access-key, + aws-electron-bucket-name, + r2-electron-access-id, + r2-electron-access-key, + r2-electron-bucket-name, + cf-prod-account" + + - name: Download channel update info files from S3 + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.aws-electron-access-key }} + AWS_DEFAULT_REGION: 'us-west-2' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.aws-electron-bucket-name }} + run: | + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest.yml . \ + --quiet + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest-linux.yml . \ + --quiet + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest-mac.yml . \ + --quiet + + - name: Download channel update info files from R2 + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.r2-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.r2-electron-access-key }} + AWS_DEFAULT_REGION: 'us-east-1' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.r2-electron-bucket-name }} + CF_ACCOUNT: ${{ steps.retrieve-secrets.outputs.cf-prod-account }} + run: | + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest.yml . \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest-linux.yml . \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com + aws s3 cp $AWS_S3_BUCKET_NAME/desktop/latest-mac.yml . \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com + + - name: Check new rollout percentage + env: + NEW_PCT: ${{ github.event.inputs.rollout_percentage }} + run: | + CURRENT_PCT=$(sed -r -n "s/stagingPercentage:\s([0-9]+)/\1/p" latest.yml) + echo "Current percentage: ${CURRENT_PCT}" + echo "New percentage: ${NEW_PCT}" + echo + if [ "$NEW_PCT" -le "$CURRENT_PCT" ]; then + echo "New percentage (${NEW_PCT}) must be higher than current percentage (${CURRENT_PCT})!" + echo + echo "If you want to pull a staged release because it hasn’t gone well, you must increment the version \ + number higher than your broken release. Because some of your users will be on the broken 1.0.1, \ + releasing a new 1.0.1 would result in them staying on a broken version.” + exit 1 + fi + + - name: Set staged rollout percentage + env: + ROLLOUT_PCT: ${{ github.event.inputs.rollout_percentage }} + run: | + sed -i -r "/stagingPercentage/s/[0-9]+/${ROLLOUT_PCT}/" latest.yml + sed -i -r "/stagingPercentage/s/[0-9]+/${ROLLOUT_PCT}/" latest-linux.yml + sed -i -r "/stagingPercentage/s/[0-9]+/${ROLLOUT_PCT}/" latest-mac.yml + + - name: Publish channel update info files to S3 + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.aws-electron-access-key }} + AWS_DEFAULT_REGION: 'us-west-2' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.aws-electron-bucket-name }} + run: | + aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ + --include "latest*.yml" \ + --acl "public-read" \ + --quiet + + - name: Publish channel update info files to R2 + env: + AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.r2-electron-access-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.r2-electron-access-key }} + AWS_DEFAULT_REGION: 'us-east-1' + AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.r2-electron-bucket-name }} + CF_ACCOUNT: ${{ steps.retrieve-secrets.outputs.cf-prod-account }} + run: | + aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ + --include "latest*.yml" \ + --quiet \ + --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index 4c1b0f6e8f1..29118a0a9fc 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -12,7 +12,7 @@ defaults: jobs: setup: name: "Setup" - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: version_number: ${{ steps.version.outputs.new-version }} if: contains(github.event.release.tag, 'desktop') @@ -20,22 +20,23 @@ jobs: - name: Checkout Branch uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 - - name: Get version to bump + - name: Calculate bumped version id: version env: - RELEASE_TAG: ${{ github.event.release.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | + CURR_MAJOR=$(echo $RELEASE_TAG | sed -r 's/v([0-9]{4}\.[0-9]{1,2})\.([0-9]{1,2})/\1/') + CURR_PATCH=$(echo $RELEASE_TAG | sed -r 's/v([0-9]{4}\.[0-9]{1,2})\.([0-9]{1,2})/\2/') + echo "Current Patch: $CURR_PATCH" - CURR_MAJOR=$(echo $RELEASE_TAG | sed -r 's/desktop-v([0-9]{4}\.[0-9]\.)([0-9])/\1/') - CURR_VER=$(echo $RELEASE_TAG | sed -r 's/desktop-v([0-9]{4}\.[0-9]\.)([0-9])/\2/') - echo $CURR_VER - ((CURR_VER++)) - NEW_VER=$CURR_MAJOR$CURR_VER + NEW_PATCH=$((CURR_PATCH++)) + NEW_VER=$CURR_MAJOR.$NEW_PATCH + echo "New Version: $NEW_VER" echo "::set-output name=new-version::$NEW_VER" trigger_version_bump: name: "Trigger desktop version bump workflow" - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - setup steps: @@ -46,13 +47,10 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - env: - KEYVAULT: bitwarden-prod-kv - SECRET: "github-pat-bitwarden-devops-bot-repo-scope" - run: | - VALUE=$(az keyvault secret show --vault-name $KEYVAULT --name $SECRET --query value --output tsv) - echo "::add-mask::$VALUE" - echo "::set-output name=$SECRET::$VALUE" + uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af + with: + keyvault: "bitwarden-prod-kv" + secrets: "github-pat-bitwarden-devops-bot-repo-scope" - name: Call GitHub API to trigger workflow bump env: diff --git a/apps/browser/src/background/commands.background.ts b/apps/browser/src/background/commands.background.ts index 7303d790ec9..2ad3eafe49c 100644 --- a/apps/browser/src/background/commands.background.ts +++ b/apps/browser/src/background/commands.background.ts @@ -64,7 +64,7 @@ export default class CommandsBackground { } private async generatePasswordToClipboard() { - const options = (await this.passwordGenerationService.getOptions())[0]; + const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {}; const password = await this.passwordGenerationService.generatePassword(options); this.platformUtilsService.copyToClipboard(password, { window: window }); this.passwordGenerationService.addHistory(password); diff --git a/apps/browser/src/background/contextMenus.background.ts b/apps/browser/src/background/contextMenus.background.ts index 22d9d56bbff..8af66db1f0c 100644 --- a/apps/browser/src/background/contextMenus.background.ts +++ b/apps/browser/src/background/contextMenus.background.ts @@ -66,7 +66,7 @@ export default class ContextMenusBackground { } private async generatePasswordToClipboard() { - const options = (await this.passwordGenerationService.getOptions())[0]; + const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {}; const password = await this.passwordGenerationService.generatePassword(options); this.platformUtilsService.copyToClipboard(password, { window: window }); this.passwordGenerationService.addHistory(password); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index cf83b6e78d1..56c81ec5add 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -247,7 +247,8 @@ export default class MainBackground { return promise.then((result) => result.response === "unlocked"); } - } + }, + window ); this.i18nService = new I18nService(BrowserApi.getUILanguage(window)); this.encryptService = new EncryptService(this.cryptoFunctionService, this.logService, true); diff --git a/apps/browser/src/background/notification.background.ts b/apps/browser/src/background/notification.background.ts index e011f3d6976..5eaec27325f 100644 --- a/apps/browser/src/background/notification.background.ts +++ b/apps/browser/src/background/notification.background.ts @@ -446,6 +446,8 @@ export default class NotificationBackground { } private async allowPersonalOwnership(): Promise { - return !(await this.policyService.policyAppliesToUser(PolicyType.PersonalOwnership)); + return !(await firstValueFrom( + this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership) + )); } } diff --git a/apps/browser/src/background/service_factories/autofill-service.factory.ts b/apps/browser/src/background/service_factories/autofill-service.factory.ts new file mode 100644 index 00000000000..a14cd1dd8c3 --- /dev/null +++ b/apps/browser/src/background/service_factories/autofill-service.factory.ts @@ -0,0 +1,37 @@ +import { AutofillService as AbstractAutoFillService } from "../../services/abstractions/autofill.service"; +import AutofillService from "../../services/autofill.service"; + +import { cipherServiceFactory, CipherServiceInitOptions } from "./cipher-service.factory"; +import { EventServiceInitOptions, eventServiceFactory } from "./event-service.factory"; +import { CachedServices, factory, FactoryOptions } from "./factory-options"; +import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; +import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; +import { totpServiceFactory, TotpServiceInitOptions } from "./totp-service.factory"; + +type AutoFillServiceOptions = FactoryOptions; + +export type AutoFillServiceInitOptions = AutoFillServiceOptions & + CipherServiceInitOptions & + StateServiceInitOptions & + TotpServiceInitOptions & + EventServiceInitOptions & + LogServiceInitOptions; + +export function autofillServiceFactory( + cache: { autofillService?: AbstractAutoFillService } & CachedServices, + opts: AutoFillServiceInitOptions +): Promise { + return factory( + cache, + "autofillService", + opts, + async () => + new AutofillService( + await cipherServiceFactory(cache, opts), + await stateServiceFactory(cache, opts), + await totpServiceFactory(cache, opts), + await eventServiceFactory(cache, opts), + await logServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/background/service_factories/cipher-service.factory.ts b/apps/browser/src/background/service_factories/cipher-service.factory.ts index 149ac54fc82..03141f2c84f 100644 --- a/apps/browser/src/background/service_factories/cipher-service.factory.ts +++ b/apps/browser/src/background/service_factories/cipher-service.factory.ts @@ -44,7 +44,7 @@ export function cipherServiceFactory( await apiServiceFactory(cache, opts), await fileUploadServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), - opts.cipherServiceOptions.searchServiceFactory === undefined + opts.cipherServiceOptions?.searchServiceFactory === undefined ? () => cache.searchService : opts.cipherServiceOptions.searchServiceFactory, await logServiceFactory(cache, opts), diff --git a/apps/browser/src/background/service_factories/event-service.factory.ts b/apps/browser/src/background/service_factories/event-service.factory.ts new file mode 100644 index 00000000000..61a82ebeb19 --- /dev/null +++ b/apps/browser/src/background/service_factories/event-service.factory.ts @@ -0,0 +1,40 @@ +import { EventService as AbstractEventService } from "@bitwarden/common/abstractions/event.service"; +import { EventService } from "@bitwarden/common/services/event.service"; + +import { apiServiceFactory, ApiServiceInitOptions } from "./api-service.factory"; +import { cipherServiceFactory, CipherServiceInitOptions } from "./cipher-service.factory"; +import { FactoryOptions, CachedServices, factory } from "./factory-options"; +import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; +import { + organizationServiceFactory, + OrganizationServiceInitOptions, +} from "./organization-service.factory"; +import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; + +type EventServiceOptions = FactoryOptions; + +export type EventServiceInitOptions = EventServiceOptions & + ApiServiceInitOptions & + CipherServiceInitOptions & + StateServiceInitOptions & + LogServiceInitOptions & + OrganizationServiceInitOptions; + +export function eventServiceFactory( + cache: { eventService?: AbstractEventService } & CachedServices, + opts: EventServiceInitOptions +): Promise { + return factory( + cache, + "eventService", + opts, + async () => + new EventService( + await apiServiceFactory(cache, opts), + await cipherServiceFactory(cache, opts), + await stateServiceFactory(cache, opts), + await logServiceFactory(cache, opts), + await organizationServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/background/service_factories/platform-utils-service.factory.ts b/apps/browser/src/background/service_factories/platform-utils-service.factory.ts index 6d85f126361..da25e51ce0c 100644 --- a/apps/browser/src/background/service_factories/platform-utils-service.factory.ts +++ b/apps/browser/src/background/service_factories/platform-utils-service.factory.ts @@ -28,7 +28,8 @@ export function platformUtilsServiceFactory( new BrowserPlatformUtilsService( await messagingServiceFactory(cache, opts), opts.platformUtilsServiceOptions.clipboardWriteCallback, - opts.platformUtilsServiceOptions.biometricCallback + opts.platformUtilsServiceOptions.biometricCallback, + opts.platformUtilsServiceOptions.win ) ); } diff --git a/apps/browser/src/background/service_factories/totp-service.factory.ts b/apps/browser/src/background/service_factories/totp-service.factory.ts new file mode 100644 index 00000000000..07556489de5 --- /dev/null +++ b/apps/browser/src/background/service_factories/totp-service.factory.ts @@ -0,0 +1,31 @@ +import { TotpService as AbstractTotpService } from "@bitwarden/common/abstractions/totp.service"; +import { TotpService } from "@bitwarden/common/services/totp.service"; + +import { + cryptoFunctionServiceFactory, + CryptoFunctionServiceInitOptions, +} from "./crypto-function-service.factory"; +import { CachedServices, factory, FactoryOptions } from "./factory-options"; +import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; + +type TotpServiceOptions = FactoryOptions; + +export type TotpServiceInitOptions = TotpServiceOptions & + CryptoFunctionServiceInitOptions & + LogServiceInitOptions; + +export function totpServiceFactory( + cache: { totpService?: AbstractTotpService } & CachedServices, + opts: TotpServiceInitOptions +): Promise { + return factory( + cache, + "totpService", + opts, + async () => + new TotpService( + await cryptoFunctionServiceFactory(cache, opts), + await logServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/listeners/onCommandListener.ts b/apps/browser/src/listeners/onCommandListener.ts index 2a33e91e578..294ea51a963 100644 --- a/apps/browser/src/listeners/onCommandListener.ts +++ b/apps/browser/src/listeners/onCommandListener.ts @@ -1,27 +1,15 @@ +import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus"; import { StateFactory } from "@bitwarden/common/factories/stateFactory"; import { GlobalState } from "@bitwarden/common/models/domain/globalState"; -import { AuthService } from "@bitwarden/common/services/auth.service"; -import { CipherService } from "@bitwarden/common/services/cipher.service"; -import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service"; -import { EncryptService } from "@bitwarden/common/services/encrypt.service"; -import { NoopEventService } from "@bitwarden/common/services/noopEvent.service"; -import { SearchService } from "@bitwarden/common/services/search.service"; -import { SettingsService } from "@bitwarden/common/services/settings.service"; -import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service"; -import { WebCryptoFunctionService } from "@bitwarden/common/services/webCryptoFunction.service"; +import { authServiceFactory } from "../background/service_factories/auth-service.factory"; +import { autofillServiceFactory } from "../background/service_factories/autofill-service.factory"; +import { CachedServices } from "../background/service_factories/factory-options"; +import { logServiceFactory } from "../background/service_factories/log-service.factory"; +import { BrowserApi } from "../browser/browserApi"; import { AutoFillActiveTabCommand } from "../commands/autoFillActiveTabCommand"; import { Account } from "../models/account"; -import { StateService as AbstractStateService } from "../services/abstractions/state.service"; -import AutofillService from "../services/autofill.service"; -import { BrowserCryptoService } from "../services/browserCrypto.service"; -import BrowserLocalStorageService from "../services/browserLocalStorage.service"; -import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service"; -import I18nService from "../services/i18n.service"; -import { KeyGenerationService } from "../services/keyGeneration.service"; -import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service"; -import { StateService } from "../services/state.service"; export const onCommandListener = async (command: string, tab: chrome.tabs.Tab) => { switch (command) { @@ -32,100 +20,44 @@ export const onCommandListener = async (command: string, tab: chrome.tabs.Tab) = }; const doAutoFillLogin = async (tab: chrome.tabs.Tab): Promise => { - const logService = new ConsoleLogService(false); - - const cryptoFunctionService = new WebCryptoFunctionService(self); - - const storageService = new BrowserLocalStorageService(); - - const secureStorageService = new BrowserLocalStorageService(); - - const memoryStorageService = new LocalBackedSessionStorageService( - new EncryptService(cryptoFunctionService, logService, false), - new KeyGenerationService(cryptoFunctionService) - ); - - const stateFactory = new StateFactory(GlobalState, Account); - - const stateMigrationService = new StateMigrationService( - storageService, - secureStorageService, - stateFactory - ); - - const stateService: AbstractStateService = new StateService( - storageService, - secureStorageService, - memoryStorageService, // AbstractStorageService - logService, - stateMigrationService, - stateFactory - ); - - await stateService.init(); - - const platformUtils = new BrowserPlatformUtilsService( - null, // MessagingService - null, // clipboardWriteCallback - null // biometricCallback - ); - - const cryptoService = new BrowserCryptoService( - cryptoFunctionService, - null, // AbstractEncryptService - platformUtils, - logService, - stateService - ); - - const settingsService = new SettingsService(stateService); - - const i18nService = new I18nService(chrome.i18n.getUILanguage()); - - await i18nService.init(); - - // Don't love this pt.1 - let searchService: SearchService = null; - - const cipherService = new CipherService( - cryptoService, - settingsService, - null, // ApiService - null, // FileUploadService, - i18nService, - () => searchService, // Don't love this pt.2 - logService, - stateService - ); - - // Don't love this pt.3 - searchService = new SearchService(cipherService, logService, i18nService); - - // TODO: Remove this before we encourage anyone to start using this - const eventService = new NoopEventService(); - - const autofillService = new AutofillService( - cipherService, - stateService, - null, // TotpService - eventService, - logService - ); - - const authService = new AuthService( - cryptoService, // CryptoService - null, // ApiService - null, // TokenService - null, // AppIdService - platformUtils, - null, // MessagingService - logService, - null, // KeyConnectorService - null, // EnvironmentService - stateService, - null, // TwoFactorService - i18nService - ); + const cachedServices: CachedServices = {}; + const opts = { + cryptoFunctionServiceOptions: { + win: self, + }, + encryptServiceOptions: { + logMacFailures: true, + }, + logServiceOptions: { + isDev: false, + }, + platformUtilsServiceOptions: { + clipboardWriteCallback: () => Promise.resolve(), + biometricCallback: () => Promise.resolve(false), + win: self, + }, + stateServiceOptions: { + stateFactory: new StateFactory(GlobalState, Account), + }, + stateMigrationServiceOptions: { + stateFactory: new StateFactory(GlobalState, Account), + }, + apiServiceOptions: { + logoutCallback: () => Promise.resolve(), + }, + keyConnectorServiceOptions: { + logoutCallback: () => Promise.resolve(), + }, + i18nServiceOptions: { + systemLanguage: BrowserApi.getUILanguage(self), + }, + cipherServiceOptions: { + searchServiceFactory: null as () => SearchService, // No dependence on search service + }, + }; + const logService = await logServiceFactory(cachedServices, opts); + const authService = await authServiceFactory(cachedServices, opts); + const autofillService = await autofillServiceFactory(cachedServices, opts); const authStatus = await authService.getAuthStatus(); if (authStatus < AuthenticationStatus.Unlocked) { diff --git a/apps/browser/src/popup/accounts/lock.component.ts b/apps/browser/src/popup/accounts/lock.component.ts index c288a54a65b..775ecaa3ca0 100644 --- a/apps/browser/src/popup/accounts/lock.component.ts +++ b/apps/browser/src/popup/accounts/lock.component.ts @@ -12,7 +12,6 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service"; import { AuthenticationStatus } from "@bitwarden/common/enums/authenticationStatus"; @@ -28,8 +27,6 @@ export class LockComponent extends BaseLockComponent { biometricError: string; pendingBiometric = false; - authenicatedUrl = "/tabs/current"; - unAuthenicatedUrl = "/update-temp-password"; constructor( router: Router, @@ -45,8 +42,7 @@ export class LockComponent extends BaseLockComponent { logService: LogService, keyConnectorService: KeyConnectorService, ngZone: NgZone, - private authService: AuthService, - private syncService: SyncService + private authService: AuthService ) { super( router, @@ -63,17 +59,12 @@ export class LockComponent extends BaseLockComponent { keyConnectorService, ngZone ); - + this.successRoute = "/tabs/current"; this.isInitialLockScreen = (window as any).previousPopupUrl == null; } async ngOnInit() { await super.ngOnInit(); - await this.syncService.fullSync(true); - - const forcePasswordReset = await this.stateService.getForcePasswordReset(); - this.successRoute = forcePasswordReset === true ? this.unAuthenicatedUrl : this.authenicatedUrl; - const disableAutoBiometricsPrompt = (await this.stateService.getDisableAutoBiometricsPrompt()) ?? true; diff --git a/apps/browser/src/services/autofill.service.ts b/apps/browser/src/services/autofill.service.ts index ff8eca93475..b403a2a679c 100644 --- a/apps/browser/src/services/autofill.service.ts +++ b/apps/browser/src/services/autofill.service.ts @@ -172,14 +172,10 @@ export default class AutofillService implements AutofillServiceInterface { } else { cipher = await this.cipherService.getLastUsedForUrl(tab.url, true); } - - if (cipher == null) { - return null; - } } - if (cipher.reprompt !== CipherRepromptType.None) { - return; + if (cipher == null || cipher.reprompt !== CipherRepromptType.None) { + return null; } const totpCode = await this.doAutoFill({ diff --git a/apps/browser/src/services/browserPlatformUtils.service.spec.ts b/apps/browser/src/services/browserPlatformUtils.service.spec.ts index 21034bcfa47..1f557dc7426 100644 --- a/apps/browser/src/services/browserPlatformUtils.service.spec.ts +++ b/apps/browser/src/services/browserPlatformUtils.service.spec.ts @@ -16,7 +16,7 @@ describe("Browser Utils Service", () => { let browserPlatformUtilsService: BrowserPlatformUtilsService; beforeEach(() => { (window as any).matchMedia = jest.fn().mockReturnValueOnce({}); - browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null, null); + browserPlatformUtilsService = new BrowserPlatformUtilsService(null, null, null, self); }); afterEach(() => { diff --git a/apps/browser/src/services/browserPlatformUtils.service.ts b/apps/browser/src/services/browserPlatformUtils.service.ts index a9f1c35567d..48c305a91e7 100644 --- a/apps/browser/src/services/browserPlatformUtils.service.ts +++ b/apps/browser/src/services/browserPlatformUtils.service.ts @@ -19,7 +19,8 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService constructor( private messagingService: MessagingService, private clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void, - private biometricCallback: () => Promise + private biometricCallback: () => Promise, + private win: Window & typeof globalThis ) {} getDevice(): DeviceType { @@ -33,8 +34,8 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService ) { this.deviceCache = DeviceType.FirefoxExtension; } else if ( - (self.opr && self.opr.addons) || - self.opera || + (!!this.win.opr && !!opr.addons) || + !!this.win.opera || navigator.userAgent.indexOf(" OPR/") >= 0 ) { this.deviceCache = DeviceType.OperaExtension; @@ -42,7 +43,7 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService this.deviceCache = DeviceType.EdgeExtension; } else if (navigator.userAgent.indexOf(" Vivaldi/") !== -1) { this.deviceCache = DeviceType.VivaldiExtension; - } else if (window.chrome && navigator.userAgent.indexOf(" Chrome/") !== -1) { + } else if (this.win.chrome && navigator.userAgent.indexOf(" Chrome/") !== -1) { this.deviceCache = DeviceType.ChromeExtension; } else if (navigator.userAgent.indexOf(" Safari/") !== -1) { this.deviceCache = DeviceType.SafariExtension; @@ -178,8 +179,8 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService } copyToClipboard(text: string, options?: any): void { - let win = window; - let doc = window.document; + let win = this.win; + let doc = this.win.document; if (options && (options.window || options.win)) { win = options.window || options.win; doc = win.document; @@ -238,8 +239,8 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService } async readFromClipboard(options?: any): Promise { - let win = window; - let doc = window.document; + let win = this.win; + let doc = this.win.document; if (options && (options.window || options.win)) { win = options.window || options.win; doc = win.document; @@ -335,7 +336,7 @@ export default class BrowserPlatformUtilsService implements PlatformUtilsService } sidebarViewName(): string { - if (window.chrome.sidebarAction && this.isFirefox()) { + if (this.win.chrome.sidebarAction && this.isFirefox()) { return "sidebar"; } else if (this.isOpera() && typeof opr !== "undefined" && opr.sidebarAction) { return "sidebar_panel"; diff --git a/apps/browser/src/services/localBackedSessionStorage.service.spec.ts b/apps/browser/src/services/localBackedSessionStorage.service.spec.ts index f7101ddae2e..1d105ce3e4c 100644 --- a/apps/browser/src/services/localBackedSessionStorage.service.spec.ts +++ b/apps/browser/src/services/localBackedSessionStorage.service.spec.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { Utils } from "@bitwarden/common/misc/utils"; diff --git a/apps/browser/src/services/state.service.spec.ts b/apps/browser/src/services/state.service.spec.ts index f3b6c74a5e3..f9aa6fc6433 100644 --- a/apps/browser/src/services/state.service.spec.ts +++ b/apps/browser/src/services/state.service.spec.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { LogService } from "@bitwarden/common/abstractions/log.service"; diff --git a/apps/cli/src/commands/export.command.ts b/apps/cli/src/commands/export.command.ts index 50446d7e027..4b367463096 100644 --- a/apps/cli/src/commands/export.command.ts +++ b/apps/cli/src/commands/export.command.ts @@ -1,5 +1,6 @@ import * as program from "commander"; import * as inquirer from "inquirer"; +import { firstValueFrom } from "rxjs"; import { ExportFormat, ExportService } from "@bitwarden/common/abstractions/export.service"; import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; @@ -15,7 +16,9 @@ export class ExportCommand { async run(options: program.OptionValues): Promise { if ( options.organizationid == null && - (await this.policyService.policyAppliesToUser(PolicyType.DisablePersonalVaultExport)) + (await firstValueFrom( + this.policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport) + )) ) { return Response.badRequest( "One or more organization policies prevents you from exporting your personal vault." diff --git a/apps/desktop/src/app/accounts/lock.component.ts b/apps/desktop/src/app/accounts/lock.component.ts index ff136aa982e..5cb28802796 100644 --- a/apps/desktop/src/app/accounts/lock.component.ts +++ b/apps/desktop/src/app/accounts/lock.component.ts @@ -13,7 +13,6 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; -import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeoutSettings.service"; @@ -25,8 +24,6 @@ const BroadcasterSubscriptionId = "LockComponent"; }) export class LockComponent extends BaseLockComponent { private deferFocus: boolean = null; - authenicatedUrl = "vault"; - unAuthenicatedUrl = "update-temp-password"; constructor( router: Router, @@ -43,8 +40,7 @@ export class LockComponent extends BaseLockComponent { private broadcasterService: BroadcasterService, ngZone: NgZone, logService: LogService, - keyConnectorService: KeyConnectorService, - private syncService: SyncService + keyConnectorService: KeyConnectorService ) { super( router, @@ -67,11 +63,6 @@ export class LockComponent extends BaseLockComponent { await super.ngOnInit(); const autoPromptBiometric = !(await this.stateService.getNoAutoPromptBiometrics()); - await this.syncService.fullSync(true); - - const forcePasswordReset = await this.stateService.getForcePasswordReset(); - this.successRoute = forcePasswordReset === true ? this.unAuthenicatedUrl : this.authenicatedUrl; - // eslint-disable-next-line rxjs-angular/prefer-takeuntil this.route.queryParams.subscribe((params) => { if (this.supportsBiometric && params.promptBiometric && autoPromptBiometric) { diff --git a/apps/desktop/src/app/vault/generator.component.spec.ts b/apps/desktop/src/app/vault/generator.component.spec.ts index 6c906e9b367..59638bb7584 100644 --- a/apps/desktop/src/app/vault/generator.component.spec.ts +++ b/apps/desktop/src/app/vault/generator.component.spec.ts @@ -1,6 +1,7 @@ import { NO_ERRORS_SCHEMA } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ActivatedRoute } from "@angular/router"; +// eslint-disable-next-line no-restricted-imports import { Substitute } from "@fluffy-spoon/substitute"; import { mock, MockProxy } from "jest-mock-extended"; diff --git a/apps/desktop/src/services/nativeMessageHandler.service.ts b/apps/desktop/src/services/nativeMessageHandler.service.ts index 916bd86bc89..5787e407343 100644 --- a/apps/desktop/src/services/nativeMessageHandler.service.ts +++ b/apps/desktop/src/services/nativeMessageHandler.service.ts @@ -182,12 +182,25 @@ export class NativeMessageHandlerService { this.ddgSharedSecret = SymmetricCryptoKey.fromJSON({ keyB64: storedKey }); } - return JSON.parse( - await this.cryptoService.decryptToUtf8( + try { + let decryptedResult = await this.cryptoService.decryptToUtf8( message.encryptedCommand as EncString, this.ddgSharedSecret - ) - ); + ); + + decryptedResult = this.trimNullCharsFromMessage(decryptedResult); + + return JSON.parse(decryptedResult); + } catch { + this.sendResponse({ + messageId: message.messageId, + version: NativeMessagingVersion.Latest, + payload: { + error: "cannot-decrypt", + }, + }); + return; + } } private async sendEncryptedResponse( @@ -218,4 +231,23 @@ export class NativeMessageHandlerService { private sendResponse(response: EncryptedMessageResponse | UnencryptedMessageResponse) { ipcRenderer.send("nativeMessagingReply", response); } + + // Trim all null bytes padded at the end of messages. This happens with C encryption libraries. + private trimNullCharsFromMessage(message: string): string { + const charNull = 0; + const charRightCurlyBrace = 125; + const charRightBracket = 93; + + for (let i = message.length - 1; i >= 0; i--) { + if (message.charCodeAt(i) === charNull) { + message = message.substring(0, message.length - 1); + } else if ( + message.charCodeAt(i) === charRightCurlyBrace || + message.charCodeAt(i) === charRightBracket + ) { + break; + } + } + return message; + } } diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json index 14f3154c0fa..69dae5e732f 100644 --- a/apps/web/.eslintrc.json +++ b/apps/web/.eslintrc.json @@ -14,7 +14,8 @@ "**/organizations/policies/*", "@bitwarden/web-vault/*", "src/**/*" - ] + ], + "paths": ["@fluffy-spoon/substitute"] } ] } diff --git a/apps/web/package.json b/apps/web/package.json index cbf65094dd7..4ddd9cb05b5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2022.9.2", + "version": "2022.10.0", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/apps/web/src/app/accounts/login/login-with-device.component.ts b/apps/web/src/app/accounts/login/login-with-device.component.ts index 4c6f6268dfd..32d350a1e05 100644 --- a/apps/web/src/app/accounts/login/login-with-device.component.ts +++ b/apps/web/src/app/accounts/login/login-with-device.component.ts @@ -37,9 +37,11 @@ export class LoginWithDeviceComponent onSuccessfulLoginTwoFactorNavigate: () => Promise; onSuccessfulLogin: () => Promise; onSuccessfulLoginNavigate: () => Promise; + onSuccessfulLoginForceResetNavigate: () => Promise; protected twoFactorRoute = "2fa"; protected successRoute = "vault"; + protected forcePasswordResetRoute = "update-temp-password"; private authRequestKeyPair: [publicKey: ArrayBuffer, privateKey: ArrayBuffer]; constructor( @@ -119,14 +121,29 @@ export class LoginWithDeviceComponent } const credentials = await this.buildLoginCredntials(requestId, response); - await this.authService.logIn(credentials); - if (this.onSuccessfulLogin != null) { - this.onSuccessfulLogin(); - } - if (this.onSuccessfulLoginNavigate != null) { - this.onSuccessfulLoginNavigate(); + const loginResponse = await this.authService.logIn(credentials); + + if (loginResponse.requiresTwoFactor) { + if (this.onSuccessfulLoginTwoFactorNavigate != null) { + this.onSuccessfulLoginTwoFactorNavigate(); + } else { + this.router.navigate([this.twoFactorRoute]); + } + } else if (loginResponse.forcePasswordReset) { + if (this.onSuccessfulLoginForceResetNavigate != null) { + this.onSuccessfulLoginForceResetNavigate(); + } else { + this.router.navigate([this.forcePasswordResetRoute]); + } } else { - this.router.navigate([this.successRoute]); + if (this.onSuccessfulLogin != null) { + this.onSuccessfulLogin(); + } + if (this.onSuccessfulLoginNavigate != null) { + this.onSuccessfulLoginNavigate(); + } else { + this.router.navigate([this.successRoute]); + } } } catch (error) { this.logService.error(error); diff --git a/apps/web/src/app/accounts/login/login.component.ts b/apps/web/src/app/accounts/login/login.component.ts index 2568d5a3e74..02bc781ee17 100644 --- a/apps/web/src/app/accounts/login/login.component.ts +++ b/apps/web/src/app/accounts/login/login.component.ts @@ -1,6 +1,7 @@ -import { Component, NgZone } from "@angular/core"; +import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; import { first } from "rxjs/operators"; import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/components/login.component"; @@ -29,13 +30,14 @@ import { RouterService, StateService } from "../../core"; selector: "app-login", templateUrl: "login.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class LoginComponent extends BaseLoginComponent { +export class LoginComponent extends BaseLoginComponent implements OnInit, OnDestroy { showResetPasswordAutoEnrollWarning = false; enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions; policies: ListResponse; showPasswordless = false; + private destroy$ = new Subject(); + constructor( authService: AuthService, router: Router, @@ -128,14 +130,23 @@ export class LoginComponent extends BaseLoginComponent { this.showResetPasswordAutoEnrollWarning = resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled; - this.enforcedPasswordPolicyOptions = - await this.policyService.getMasterPasswordPolicyOptions(policyList); + this.policyService + .masterPasswordPolicyOptions$(policyList) + .pipe(takeUntil(this.destroy$)) + .subscribe((enforcedPasswordPolicyOptions) => { + this.enforcedPasswordPolicyOptions = enforcedPasswordPolicyOptions; + }); } } } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + async goAfterLogIn() { - const masterPassword = this.formGroup.get("masterPassword")?.value; + const masterPassword = this.formGroup.value.masterPassword; // Check master password against policy if (this.enforcedPasswordPolicyOptions != null) { @@ -170,7 +181,7 @@ export class LoginComponent extends BaseLoginComponent { } async submit() { - const rememberEmail = this.formGroup.get("rememberEmail")?.value; + const rememberEmail = this.formGroup.value.rememberEmail; await this.stateService.setRememberEmail(rememberEmail); if (!rememberEmail) { @@ -192,7 +203,7 @@ export class LoginComponent extends BaseLoginComponent { } private getPasswordStrengthUserInput() { - const email = this.formGroup.get("email")?.value; + const email = this.formGroup.value.email; let userInput: string[] = []; const atPosition = email.indexOf("@"); if (atPosition > -1) { diff --git a/apps/web/src/app/accounts/register-form/register-form.component.ts b/apps/web/src/app/accounts/register-form/register-form.component.ts index 8045ef18f1e..ad3341fb6c0 100644 --- a/apps/web/src/app/accounts/register-form/register-form.component.ts +++ b/apps/web/src/app/accounts/register-form/register-form.component.ts @@ -73,7 +73,7 @@ export class RegisterFormComponent extends BaseRegisterComponent { this.enforcedPolicyOptions != null && !this.policyService.evaluateMasterPassword( this.passwordStrengthResult.score, - this.formGroup.get("masterPassword")?.value, + this.formGroup.value.masterPassword, this.enforcedPolicyOptions ) ) { diff --git a/apps/web/src/app/accounts/register.component.ts b/apps/web/src/app/accounts/register.component.ts index 4e700ca4d40..2d1eb6d5e53 100644 --- a/apps/web/src/app/accounts/register.component.ts +++ b/apps/web/src/app/accounts/register.component.ts @@ -1,6 +1,7 @@ -import { Component } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { UntypedFormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; import { first } from "rxjs/operators"; import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/components/register.component"; @@ -27,14 +28,14 @@ import { RouterService } from "../core"; selector: "app-register", templateUrl: "register.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class RegisterComponent extends BaseRegisterComponent { +export class RegisterComponent extends BaseRegisterComponent implements OnInit, OnDestroy { email = ""; showCreateOrgMessage = false; layout = ""; enforcedPolicyOptions: MasterPasswordPolicyOptions; private policies: Policy[]; + private destroy$ = new Subject(); constructor( formValidationErrorService: FormValidationErrorsService, @@ -130,11 +131,19 @@ export class RegisterComponent extends BaseRegisterComponent { } if (this.policies != null) { - this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions( - this.policies - ); + this.policyService + .masterPasswordPolicyOptions$(this.policies) + .pipe(takeUntil(this.destroy$)) + .subscribe((enforcedPasswordPolicyOptions) => { + this.enforcedPolicyOptions = enforcedPasswordPolicyOptions; + }); } await super.ngOnInit(); } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/apps/web/src/app/accounts/trial-initiation/billing.component.ts b/apps/web/src/app/accounts/trial-initiation/billing.component.ts index aff798b5b86..0817c19c26b 100644 --- a/apps/web/src/app/accounts/trial-initiation/billing.component.ts +++ b/apps/web/src/app/accounts/trial-initiation/billing.component.ts @@ -57,8 +57,8 @@ export class BillingComponent extends OrganizationPlansComponent { async ngOnInit() { const additionalSeats = this.product == ProductType.Families ? 0 : 1; this.formGroup.patchValue({ - name: this.orgInfoForm.get("name")?.value, - billingEmail: this.orgInfoForm.get("email")?.value, + name: this.orgInfoForm.value.name, + billingEmail: this.orgInfoForm.value.email, additionalSeats: additionalSeats, plan: this.plan, product: this.product, diff --git a/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts b/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts index f20b407e6bf..c69a1bf1f0a 100644 --- a/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts +++ b/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.spec.ts @@ -5,8 +5,9 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testin import { FormBuilder, UntypedFormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; +// eslint-disable-next-line no-restricted-imports import { Substitute } from "@fluffy-spoon/substitute"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, of } from "rxjs"; import { I18nPipe } from "@bitwarden/angular/pipes/i18n.pipe"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -46,7 +47,7 @@ describe("TrialInitiationComponent", () => { }; policyServiceMock = { - getMasterPasswordPolicyOptions: jest.fn(), + masterPasswordPolicyOptions$: jest.fn(), }; TestBed.configureTestingModule({ @@ -144,14 +145,16 @@ describe("TrialInitiationComponent", () => { }, ], }); - policyServiceMock.getMasterPasswordPolicyOptions.mockReturnValueOnce({ - minComplexity: 4, - minLength: 10, - requireLower: null, - requireNumbers: null, - requireSpecial: null, - requireUpper: null, - } as MasterPasswordPolicyOptions); + policyServiceMock.masterPasswordPolicyOptions$.mockReturnValue( + of({ + minComplexity: 4, + minLength: 10, + requireLower: null, + requireNumbers: null, + requireSpecial: null, + requireUpper: null, + } as MasterPasswordPolicyOptions) + ); // Need to recreate component with new service mocks fixture = TestBed.createComponent(TrialInitiationComponent); diff --git a/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.ts b/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.ts index 00bbc6e236f..d8b2fc0873d 100644 --- a/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.ts +++ b/apps/web/src/app/accounts/trial-initiation/trial-initiation.component.ts @@ -1,9 +1,9 @@ import { StepperSelectionEvent } from "@angular/cdk/stepper"; import { TitleCasePipe } from "@angular/common"; -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { UntypedFormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { first } from "rxjs"; +import { first, Subject, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; @@ -24,8 +24,7 @@ import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.co selector: "app-trial", templateUrl: "trial-initiation.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class TrialInitiationComponent implements OnInit { +export class TrialInitiationComponent implements OnInit, OnDestroy { email = ""; org = ""; orgInfoSubLabel = ""; @@ -63,6 +62,8 @@ export class TrialInitiationComponent implements OnInit { } } + private destroy$ = new Subject(); + constructor( private route: ActivatedRoute, protected router: Router, @@ -140,12 +141,20 @@ export class TrialInitiationComponent implements OnInit { } if (this.policies != null) { - this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions( - this.policies - ); + this.policyService + .masterPasswordPolicyOptions$(this.policies) + .pipe(takeUntil(this.destroy$)) + .subscribe((enforcedPasswordPolicyOptions) => { + this.enforcedPolicyOptions = enforcedPasswordPolicyOptions; + }); } } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + stepSelectionChange(event: StepperSelectionEvent) { // Set org info sub label if (event.selectedIndex === 1 && this.orgInfoFormGroup.controls.name.value === "") { diff --git a/apps/web/src/app/common/base.people.component.ts b/apps/web/src/app/common/base.people.component.ts index 8d9a3324244..e7c2c8f4ea9 100644 --- a/apps/web/src/app/common/base.people.component.ts +++ b/apps/web/src/app/common/base.people.component.ts @@ -3,7 +3,6 @@ import { Directive, ViewChild, ViewContainerRef } from "@angular/core"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -11,6 +10,7 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType"; import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; import { ProviderUserStatusType } from "@bitwarden/common/enums/providerUserStatusType"; diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts index ab12faa82d6..842e031307b 100644 --- a/apps/web/src/app/core/event.service.ts +++ b/apps/web/src/app/core/event.service.ts @@ -1,16 +1,32 @@ -import { Injectable } from "@angular/core"; +import { Injectable, OnDestroy, OnInit } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; import { DeviceType } from "@bitwarden/common/enums/deviceType"; import { EventType } from "@bitwarden/common/enums/eventType"; import { PolicyType } from "@bitwarden/common/enums/policyType"; +import { Policy } from "@bitwarden/common/models/domain/policy"; import { EventResponse } from "@bitwarden/common/models/response/eventResponse"; @Injectable() -export class EventService { +export class EventService implements OnInit, OnDestroy { + private destroy$ = new Subject(); + private policies: Policy[]; + constructor(private i18nService: I18nService, private policyService: PolicyService) {} + ngOnInit(): void { + this.policyService.policies$.pipe(takeUntil(this.destroy$)).subscribe((policies) => { + this.policies = policies; + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + getDefaultDateFilters() { const d = new Date(); const end = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59); @@ -326,8 +342,7 @@ export class EventService { case EventType.Policy_Updated: { msg = this.i18nService.t("modifiedPolicyId", this.formatPolicyId(ev)); - const policies = await this.policyService.getAll(); - const policy = policies.filter((p) => p.id === ev.policyId)[0]; + const policy = this.policies.filter((p) => p.id === ev.policyId)[0]; let p1 = this.getShortId(ev.policyId); if (policy != null) { p1 = PolicyType[policy.type]; diff --git a/apps/web/src/app/organizations/manage/people.component.ts b/apps/web/src/app/organizations/manage/people.component.ts index c34505fbb41..cf426ef7aa5 100644 --- a/apps/web/src/app/organizations/manage/people.component.ts +++ b/apps/web/src/app/organizations/manage/people.component.ts @@ -1,11 +1,10 @@ -import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; -import { first } from "rxjs/operators"; +import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { combineLatest, concatMap, Subject, takeUntil } from "rxjs"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; @@ -13,11 +12,11 @@ import { LogService } from "@bitwarden/common/abstractions/log.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; -import { PolicyApiServiceAbstraction } from "@bitwarden/common/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { StateService } from "@bitwarden/common/abstractions/state.service"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType"; import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType"; import { PolicyType } from "@bitwarden/common/enums/policyType"; @@ -43,10 +42,9 @@ import { UserGroupsComponent } from "./user-groups.component"; selector: "app-org-people", templateUrl: "people.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil export class PeopleComponent extends BasePeopleComponent - implements OnInit + implements OnInit, OnDestroy { @ViewChild("addEdit", { read: ViewContainerRef, static: true }) addEditModalRef: ViewContainerRef; @ViewChild("groupsTemplate", { read: ViewContainerRef, static: true }) @@ -77,6 +75,8 @@ export class PeopleComponent orgResetPasswordPolicyEnabled = false; callingUserType: OrganizationUserType = null; + private destroy$ = new Subject(); + constructor( apiService: ApiService, private route: ActivatedRoute, @@ -84,10 +84,8 @@ export class PeopleComponent modalService: ModalService, platformUtilsService: PlatformUtilsService, cryptoService: CryptoService, - private router: Router, searchService: SearchService, validationService: ValidationService, - private policyApiService: PolicyApiServiceAbstraction, private policyService: PolicyService, logService: LogService, searchPipe: SearchPipe, @@ -113,53 +111,63 @@ export class PeopleComponent } async ngOnInit() { - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.params.subscribe(async (params) => { - this.organizationId = params.organizationId; - const organization = await this.organizationService.get(this.organizationId); - this.accessEvents = organization.useEvents; - this.accessGroups = organization.useGroups; - this.canResetPassword = organization.canManageUsersPassword; - this.orgUseResetPassword = organization.useResetPassword; - this.callingUserType = organization.type; - this.orgHasKeys = organization.hasPublicAndPrivateKeys; + combineLatest([this.route.params, this.route.queryParams, this.policyService.policies$]) + .pipe( + concatMap(async ([params, qParams, policies]) => { + this.organizationId = params.organizationId; + const organization = await this.organizationService.get(this.organizationId); + this.accessEvents = organization.useEvents; + this.accessGroups = organization.useGroups; + this.canResetPassword = organization.canManageUsersPassword; + this.orgUseResetPassword = organization.useResetPassword; + this.callingUserType = organization.type; + this.orgHasKeys = organization.hasPublicAndPrivateKeys; - // Backfill pub/priv key if necessary - if (this.canResetPassword && !this.orgHasKeys) { - const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId); - const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey); - const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); - const response = await this.organizationApiService.updateKeys(this.organizationId, request); - if (response != null) { - this.orgHasKeys = response.publicKey != null && response.privateKey != null; - await this.syncService.fullSync(true); // Replace oganizations with new data - } else { - throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); - } - } - - await this.load(); - - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - this.searchText = qParams.search; - if (qParams.viewEvents != null) { - const user = this.users.filter((u) => u.id === qParams.viewEvents); - if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) { - this.events(user[0]); + // Backfill pub/priv key if necessary + if (this.canResetPassword && !this.orgHasKeys) { + const orgShareKey = await this.cryptoService.getOrgKey(this.organizationId); + const orgKeys = await this.cryptoService.makeKeyPair(orgShareKey); + const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString); + const response = await this.organizationApiService.updateKeys( + this.organizationId, + request + ); + if (response != null) { + this.orgHasKeys = response.publicKey != null && response.privateKey != null; + await this.syncService.fullSync(true); // Replace oganizations with new data + } else { + throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); + } } - } - }); - }); + + const resetPasswordPolicy = policies + .filter((policy) => policy.type === PolicyType.ResetPassword) + .find((p) => p.organizationId === this.organizationId); + this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled; + + await this.load(); + + this.searchText = qParams.search; + if (qParams.viewEvents != null) { + const user = this.users.filter((u) => u.id === qParams.viewEvents); + if (user.length > 0 && user[0].status === OrganizationUserStatusType.Confirmed) { + this.events(user[0]); + } + } + }), + takeUntil(this.destroy$) + ) + .subscribe(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } async load() { - const resetPasswordPolicy = await this.policyApiService.getPolicyForOrganization( - PolicyType.ResetPassword, - this.organizationId - ); - this.orgResetPasswordPolicyEnabled = resetPasswordPolicy?.enabled; super.load(); + await super.load(); } getUsers(): Promise> { diff --git a/apps/web/src/app/organizations/manage/reset-password.component.ts b/apps/web/src/app/organizations/manage/reset-password.component.ts index d37148a25eb..97e9466e80e 100644 --- a/apps/web/src/app/organizations/manage/reset-password.component.ts +++ b/apps/web/src/app/organizations/manage/reset-password.component.ts @@ -1,4 +1,13 @@ -import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core"; +import { + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + ViewChild, +} from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; import zxcvbn from "zxcvbn"; import { PasswordStrengthComponent } from "@bitwarden/angular/shared/components/password-strength/password-strength.component"; @@ -18,7 +27,7 @@ import { OrganizationUserResetPasswordRequest } from "@bitwarden/common/models/r selector: "app-reset-password", templateUrl: "reset-password.component.html", }) -export class ResetPasswordComponent implements OnInit { +export class ResetPasswordComponent implements OnInit, OnDestroy { @Input() name: string; @Input() email: string; @Input() id: string; @@ -32,6 +41,8 @@ export class ResetPasswordComponent implements OnInit { passwordStrengthResult: zxcvbn.ZXCVBNResult; formPromise: Promise; + private destroy$ = new Subject(); + constructor( private apiService: ApiService, private i18nService: I18nService, @@ -43,8 +54,18 @@ export class ResetPasswordComponent implements OnInit { ) {} async ngOnInit() { - // Get Enforced Policy Options - this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions(); + this.policyService + .masterPasswordPolicyOptions$() + .pipe(takeUntil(this.destroy$)) + .subscribe( + (enforcedPasswordPolicyOptions) => + (this.enforcedPolicyOptions = enforcedPasswordPolicyOptions) + ); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } get loggedOutWarningName() { @@ -52,7 +73,7 @@ export class ResetPasswordComponent implements OnInit { } async generatePassword() { - const options = (await this.passwordGenerationService.getOptions())[0]; + const options = (await this.passwordGenerationService.getOptions())?.[0] ?? {}; this.newPassword = await this.passwordGenerationService.generatePassword(options); this.passwordStrengthComponent.updatePasswordStrength(this.newPassword); } diff --git a/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts b/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts index 853907452d2..db857bf1c15 100644 --- a/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts +++ b/apps/web/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts @@ -4,12 +4,12 @@ import { Observable, Subject } from "rxjs"; import { first, map, takeUntil } from "rxjs/operators"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { ValidationService } from "@bitwarden/angular/services/validation.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.abstraction"; +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; import { PlanSponsorshipType } from "@bitwarden/common/enums/planSponsorshipType"; import { PlanType } from "@bitwarden/common/enums/planType"; import { ProductType } from "@bitwarden/common/enums/productType"; diff --git a/apps/web/src/app/organizations/tools/import-export/org-import.component.ts b/apps/web/src/app/organizations/tools/import-export/org-import.component.ts index 69506b226a3..81a16a29680 100644 --- a/apps/web/src/app/organizations/tools/import-export/org-import.component.ts +++ b/apps/web/src/app/organizations/tools/import-export/org-import.component.ts @@ -47,7 +47,6 @@ export class OrganizationImportComponent extends ImportComponent { this.organizationId = params.organizationId; this.successNavigate = ["organizations", this.organizationId, "vault"]; await super.ngOnInit(); - this.importBlockedByPolicy = false; }); const organization = await this.organizationService.get(this.organizationId); this.organizationName = organization.name; diff --git a/apps/web/src/app/settings/emergency-access-takeover.component.ts b/apps/web/src/app/settings/emergency-access-takeover.component.ts index 5731dc8b891..27a9484f4de 100644 --- a/apps/web/src/app/settings/emergency-access-takeover.component.ts +++ b/apps/web/src/app/settings/emergency-access-takeover.component.ts @@ -1,4 +1,5 @@ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { takeUntil } from "rxjs"; import { ChangePasswordComponent } from "@bitwarden/angular/components/change-password.component"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -21,7 +22,11 @@ import { PolicyResponse } from "@bitwarden/common/models/response/policyResponse selector: "emergency-access-takeover", templateUrl: "emergency-access-takeover.component.html", }) -export class EmergencyAccessTakeoverComponent extends ChangePasswordComponent implements OnInit { +// eslint-disable-next-line rxjs-angular/prefer-takeuntil +export class EmergencyAccessTakeoverComponent + extends ChangePasswordComponent + implements OnInit, OnDestroy +{ @Output() onDone = new EventEmitter(); @Input() emergencyAccessId: string; @Input() name: string; @@ -59,12 +64,19 @@ export class EmergencyAccessTakeoverComponent extends ChangePasswordComponent im const policies = response.data.map( (policyResponse: PolicyResponse) => new Policy(new PolicyData(policyResponse)) ); - this.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions( - policies - ); + + this.policyService + .masterPasswordPolicyOptions$(policies) + .pipe(takeUntil(this.destroy$)) + .subscribe((enforcedPolicyOptions) => (this.enforcedPolicyOptions = enforcedPolicyOptions)); } } + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + ngOnDestroy(): void { + super.ngOnDestroy(); + } + async submit() { if (!(await this.strongPassword())) { return; diff --git a/apps/web/src/app/settings/organization-plans.component.ts b/apps/web/src/app/settings/organization-plans.component.ts index 485afd12b15..934803b3fb1 100644 --- a/apps/web/src/app/settings/organization-plans.component.ts +++ b/apps/web/src/app/settings/organization-plans.component.ts @@ -1,6 +1,15 @@ -import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core"; +import { + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + ViewChild, +} from "@angular/core"; import { UntypedFormBuilder, Validators } from "@angular/forms"; import { Router } from "@angular/router"; +import { Subject, takeUntil } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; @@ -35,7 +44,7 @@ interface OnSuccessArgs { selector: "app-organization-plans", templateUrl: "organization-plans.component.html", }) -export class OrganizationPlansComponent implements OnInit { +export class OrganizationPlansComponent implements OnInit, OnDestroy { @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; @ViewChild(TaxInfoComponent) taxComponent: TaxInfoComponent; @@ -73,6 +82,8 @@ export class OrganizationPlansComponent implements OnInit { plans: PlanResponse[]; + private destroy$ = new Subject(); + constructor( private apiService: ApiService, private i18nService: I18nService, @@ -114,9 +125,21 @@ export class OrganizationPlansComponent implements OnInit { this.formGroup.controls.billingEmail.addValidators(Validators.required); } + this.policyService + .policyAppliesToActiveUser$(PolicyType.SingleOrg) + .pipe(takeUntil(this.destroy$)) + .subscribe((policyAppliesToActiveUser) => { + this.singleOrgPolicyBlock = policyAppliesToActiveUser; + }); + this.loading = false; } + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + get createOrganization() { return this.organizationId == null; } @@ -288,8 +311,6 @@ export class OrganizationPlansComponent implements OnInit { } async submit() { - this.singleOrgPolicyBlock = await this.userHasBlockingSingleOrgPolicy(); - if (this.singleOrgPolicyBlock) { return; } @@ -353,10 +374,6 @@ export class OrganizationPlansComponent implements OnInit { } } - private async userHasBlockingSingleOrgPolicy() { - return this.policyService.policyAppliesToUser(PolicyType.SingleOrg); - } - private async updateOrganization(orgId: string) { const request = new OrganizationUpgradeRequest(); request.businessName = this.formGroup.controls.businessOwned.value diff --git a/apps/web/src/app/settings/two-factor-setup.component.ts b/apps/web/src/app/settings/two-factor-setup.component.ts index 13e3cbecea8..994262548bc 100644 --- a/apps/web/src/app/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/settings/two-factor-setup.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit, Type, ViewChild, ViewContainerRef } from "@angular/core"; +import { Component, OnDestroy, OnInit, Type, ViewChild, ViewContainerRef } from "@angular/core"; +import { Subject, takeUntil } from "rxjs"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalService } from "@bitwarden/angular/services/modal.service"; @@ -25,8 +26,7 @@ import { TwoFactorYubiKeyComponent } from "./two-factor-yubikey.component"; selector: "app-two-factor-setup", templateUrl: "two-factor-setup.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class TwoFactorSetupComponent implements OnInit { +export class TwoFactorSetupComponent implements OnInit, OnDestroy { @ViewChild("recoveryTemplate", { read: ViewContainerRef, static: true }) recoveryModalRef: ViewContainerRef; @ViewChild("authenticatorTemplate", { read: ViewContainerRef, static: true }) @@ -49,6 +49,9 @@ export class TwoFactorSetupComponent implements OnInit { modal: ModalRef; formPromise: Promise; + private destroy$ = new Subject(); + private twoFactorAuthPolicyAppliesToActiveUser: boolean; + constructor( protected apiService: ApiService, protected modalService: ModalService, @@ -93,9 +96,22 @@ export class TwoFactorSetupComponent implements OnInit { } this.providers.sort((a: any, b: any) => a.sort - b.sort); + + this.policyService + .policyAppliesToActiveUser$(PolicyType.TwoFactorAuthentication) + .pipe(takeUntil(this.destroy$)) + .subscribe((policyAppliesToActiveUser) => { + this.twoFactorAuthPolicyAppliesToActiveUser = policyAppliesToActiveUser; + }); + await this.load(); } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + async load() { this.loading = true; const providerList = await this.getTwoFactorProviders(); @@ -203,9 +219,7 @@ export class TwoFactorSetupComponent implements OnInit { private async evaluatePolicies() { if (this.organizationId == null && this.providers.filter((p) => p.enabled).length === 1) { - this.showPolicyWarning = await this.policyService.policyAppliesToUser( - PolicyType.TwoFactorAuthentication - ); + this.showPolicyWarning = this.twoFactorAuthPolicyAppliesToActiveUser; } else { this.showPolicyWarning = false; } diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts index 203f81c5548..380cbcb6667 100644 --- a/apps/web/src/app/shared/shared.module.ts +++ b/apps/web/src/app/shared/shared.module.ts @@ -13,6 +13,7 @@ import { CalloutModule, FormFieldModule, IconModule, + AsyncActionsModule, MenuModule, TableModule, TabsModule, @@ -51,6 +52,7 @@ import "./locales"; ], exports: [ CommonModule, + AsyncActionsModule, DragDropModule, FormsModule, InfiniteScrollModule, diff --git a/apps/web/src/app/tools/import-export/import.component.html b/apps/web/src/app/tools/import-export/import.component.html index 26d820b5189..ba2eb69925c 100644 --- a/apps/web/src/app/tools/import-export/import.component.html +++ b/apps/web/src/app/tools/import-export/import.component.html @@ -1,7 +1,7 @@ - + {{ "personalOwnershipPolicyInEffectImports" | i18n }}
@@ -14,7 +14,7 @@ name="Format" [(ngModel)]="format" class="form-control" - [disabled]="importBlockedByPolicy" + [disabled]="importBlockedByPolicy$ | async" required > @@ -296,7 +296,7 @@ id="file" class="form-control-file" name="file" - [disabled]="importBlockedByPolicy" + [disabled]="importBlockedByPolicy$ | async" /> @@ -308,14 +308,14 @@ class="form-control" name="FileContents" [(ngModel)]="fileContents" - [disabled]="importBlockedByPolicy" + [disabled]="importBlockedByPolicy$ | async" >
+``` + +## Usage: Standalone form buttons + +Adding async actions to standalone form buttons requires the following 3 steps. + +### 1. Add a handler to your `Component` + +A handler is a function that returns a promise or an observable. Functions that return `void` are also supported which is +useful for aborting an action. + +**NOTE:** Defining the handlers as arrow-functions assigned to variables is mandatory if the handler needs access to the parent +component using the variable `this`. + +```ts +@Component({...}) +class Component { + formGroup = this.formBuilder.group({...}); + + submit = async () => { + // not relevant for this example + } + + // action can also return Observable instead of Promise + handler = async () => { + if (/* perform guard check */) { + return; + } + + await this.apiService.post(/* ... */); + }; +} +``` + +### 2. Add directive to the `form` element + +The `bitSubmit` directive is required beacuse of its coordinating role. + +```html +
...
+``` + +### 3. Add directives to the `button` element + +Add `bitButton`, `bitFormButton`, `bitAction` directives to the button. Make sure to supply a handler. + +```html + + +``` diff --git a/libs/components/src/async-actions/in-forms.stories.ts b/libs/components/src/async-actions/in-forms.stories.ts new file mode 100644 index 00000000000..b492032df12 --- /dev/null +++ b/libs/components/src/async-actions/in-forms.stories.ts @@ -0,0 +1,149 @@ +import { Component } from "@angular/core"; +import { FormsModule, ReactiveFormsModule, Validators, FormBuilder } from "@angular/forms"; +import { action } from "@storybook/addon-actions"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; +import { delay, of } from "rxjs"; + +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; +import { I18nService } from "@bitwarden/common/src/abstractions/i18n.service"; + +import { ButtonModule } from "../button"; +import { FormFieldModule } from "../form-field"; +import { IconButtonModule } from "../icon-button"; +import { InputModule } from "../input/input.module"; +import { I18nMockService } from "../utils/i18n-mock.service"; + +import { BitActionDirective } from "./bit-action.directive"; +import { BitSubmitDirective } from "./bit-submit.directive"; +import { BitFormButtonDirective } from "./form-button.directive"; + +const template = ` +
+ + Name + + + + + Email + + + + + + + +
`; + +@Component({ + selector: "app-promise-example", + template, +}) +class PromiseExampleComponent { + formObj = this.formBuilder.group({ + name: ["", [Validators.required]], + email: ["", [Validators.required, Validators.email]], + }); + + constructor(private formBuilder: FormBuilder) {} + + submit = async () => { + this.formObj.markAllAsTouched(); + + if (!this.formObj.valid) { + return; + } + + await new Promise((resolve, reject) => { + setTimeout(resolve, 2000); + }); + }; + + delete = async () => { + await new Promise((resolve, reject) => { + setTimeout(resolve, 2000); + }); + }; +} + +@Component({ + selector: "app-observable-example", + template, +}) +class ObservableExampleComponent { + formObj = this.formBuilder.group({ + name: ["", [Validators.required]], + email: ["", [Validators.required, Validators.email]], + }); + + constructor(private formBuilder: FormBuilder) {} + + submit = () => { + this.formObj.markAllAsTouched(); + + if (!this.formObj.valid) { + return undefined; + } + + return of("fake observable").pipe(delay(2000)); + }; + + delete = () => { + return of("fake observable").pipe(delay(2000)); + }; +} + +export default { + title: "Component Library/Async Actions/In Forms", + decorators: [ + moduleMetadata({ + declarations: [ + BitSubmitDirective, + BitFormButtonDirective, + PromiseExampleComponent, + ObservableExampleComponent, + BitActionDirective, + ], + imports: [ + FormsModule, + ReactiveFormsModule, + FormFieldModule, + InputModule, + ButtonModule, + IconButtonModule, + ], + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + required: "required", + inputRequired: "Input is required.", + inputEmail: "Input is not an email-address.", + }); + }, + }, + { + provide: ValidationService, + useValue: { + showError: action("ValidationService.showError"), + } as Partial, + }, + ], + }), + ], +} as Meta; + +const PromiseTemplate: Story = (args: PromiseExampleComponent) => ({ + props: args, + template: ``, +}); + +export const UsingPromise = PromiseTemplate.bind({}); + +const ObservableTemplate: Story = (args: PromiseExampleComponent) => ({ + props: args, + template: ``, +}); + +export const UsingObservable = ObservableTemplate.bind({}); diff --git a/libs/components/src/async-actions/index.ts b/libs/components/src/async-actions/index.ts new file mode 100644 index 00000000000..6515ffc47ca --- /dev/null +++ b/libs/components/src/async-actions/index.ts @@ -0,0 +1,3 @@ +export * from "./async-actions.module"; +export * from "./bit-action.directive"; +export * from "./form-button.directive"; diff --git a/libs/components/src/async-actions/overview.stories.mdx b/libs/components/src/async-actions/overview.stories.mdx new file mode 100644 index 00000000000..9ec792aefdd --- /dev/null +++ b/libs/components/src/async-actions/overview.stories.mdx @@ -0,0 +1,26 @@ +import { Meta } from "@storybook/addon-docs"; + + + +# Async Actions + +The directives in this module makes it easier for developers to reflect the progress of async actions in the UI when using +buttons, while also providing robust and standardized error handling. + +These buttons can either be standalone (such as Refresh buttons), submit buttons for forms or as standalone buttons +that are part of a form (such as Delete buttons). + +These directives are meant to replace the older `appApiAction` directive, providing the option to use `observables` and reduce +clutter inside our view `components`. + +## When to use? + +When building a button that triggers a long running task in the background eg. server API calls. + +## Why? + +To better visualize that the application is processing their request. + +## What does it do? + +It disables buttons and show a spinning animation. diff --git a/libs/components/src/async-actions/standalone.stories.mdx b/libs/components/src/async-actions/standalone.stories.mdx new file mode 100644 index 00000000000..7ed5c46ffde --- /dev/null +++ b/libs/components/src/async-actions/standalone.stories.mdx @@ -0,0 +1,63 @@ +import { Meta } from "@storybook/addon-docs"; + + + +# Standalone Async Actions + +These directives should be used when building a standalone button that triggers a long running task in the background, +eg. Refresh buttons. For non-submit buttons that are associated with forms see [Async Actions In Forms](?path=/story/component-library-async-actions-in-forms-documentation--page). + +## Usage + +Adding async actions to standalone buttons requires the following 2 steps + +### 1. Add a handler to your `Component` + +A handler is a function that returns a promise or an observable. Functions that return `void` are also supported which is +useful for aborting an action. + +**NOTE:** Defining the handlers as arrow-functions assigned to variables is mandatory if the handler needs access to the parent +component using the variable `this`. + +#### Example using promises + +```ts +@Component({...}) +class PromiseExampleComponent { + handler = async () => { + if (/* perform guard check */) { + return; + } + + await this.apiService.post(/* ... */); + }; +} +``` + +#### Example using observables + +```ts +@Component({...}) +class Component { + handler = () => { + if (/* perform guard check */) { + return; + } + + return this.apiService.post$(/* ... */); + }; +} +``` + +### 2. Add directive to the DOM element + +Add the `bitAction` directive and supply the handler defined in step 1. + +**NOTE:** The `directive` is defined using the input syntax: `[input]="handler"`. +This is different from how click handlers are usually defined with the output syntax `(click)="handler()"`. + +```html + + +`; +``` diff --git a/libs/components/src/async-actions/standalone.stories.ts b/libs/components/src/async-actions/standalone.stories.ts new file mode 100644 index 00000000000..cd0c6239b06 --- /dev/null +++ b/libs/components/src/async-actions/standalone.stories.ts @@ -0,0 +1,97 @@ +import { Component } from "@angular/core"; +import { action } from "@storybook/addon-actions"; +import { Meta, moduleMetadata, Story } from "@storybook/angular"; +import { delay, of } from "rxjs"; + +import { ValidationService } from "@bitwarden/common/abstractions/validation.service"; + +import { ButtonModule } from "../button"; +import { IconButtonModule } from "../icon-button"; + +import { BitActionDirective } from "./bit-action.directive"; + +const template = ` + + `; + +@Component({ + template, + selector: "app-promise-example", +}) +class PromiseExampleComponent { + action = async () => { + await new Promise((resolve, reject) => { + setTimeout(resolve, 2000); + }); + }; +} + +@Component({ + template, + selector: "app-observable-example", +}) +class ObservableExampleComponent { + action = () => { + return of("fake observable").pipe(delay(2000)); + }; +} + +@Component({ + template, + selector: "app-rejected-promise-example", +}) +class RejectedPromiseExampleComponent { + action = async () => { + await new Promise((resolve, reject) => { + setTimeout(() => reject(new Error("Simulated error")), 2000); + }); + }; +} + +export default { + title: "Component Library/Async Actions/Standalone", + decorators: [ + moduleMetadata({ + declarations: [ + BitActionDirective, + PromiseExampleComponent, + ObservableExampleComponent, + RejectedPromiseExampleComponent, + ], + imports: [ButtonModule, IconButtonModule], + providers: [ + { + provide: ValidationService, + useValue: { + showError: action("ValidationService.showError"), + } as Partial, + }, + ], + }), + ], +} as Meta; + +const PromiseTemplate: Story = (args: PromiseExampleComponent) => ({ + props: args, + template: ``, +}); + +export const UsingPromise = PromiseTemplate.bind({}); + +const ObservableTemplate: Story = ( + args: ObservableExampleComponent +) => ({ + template: ``, +}); + +export const UsingObservable = ObservableTemplate.bind({}); + +const RejectedPromiseTemplate: Story = ( + args: ObservableExampleComponent +) => ({ + template: ``, +}); + +export const RejectedPromise = RejectedPromiseTemplate.bind({}); diff --git a/libs/components/src/button/button.component.html b/libs/components/src/button/button.component.html index 4875c159e92..ee4d150dfcc 100644 --- a/libs/components/src/button/button.component.html +++ b/libs/components/src/button/button.component.html @@ -2,7 +2,10 @@ - - + + diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index eeba83b8156..9a27bfdd9ed 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -1,5 +1,7 @@ import { Input, HostBinding, Component } from "@angular/core"; +import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; + export type ButtonTypes = "primary" | "secondary" | "danger"; const buttonStyles: Record = { @@ -41,8 +43,9 @@ const buttonStyles: Record = { @Component({ selector: "button[bitButton], a[bitButton]", templateUrl: "button.component.html", + providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }], }) -export class ButtonComponent { +export class ButtonComponent implements ButtonLikeAbstraction { @HostBinding("class") get classList() { return [ "tw-font-semibold", diff --git a/libs/components/src/form-field/form-field-control.ts b/libs/components/src/form-field/form-field-control.ts new file mode 100644 index 00000000000..ee63407a8bd --- /dev/null +++ b/libs/components/src/form-field/form-field-control.ts @@ -0,0 +1,8 @@ +export abstract class BitFormFieldControl { + ariaDescribedBy: string; + id: string; + labelForId: string; + required: boolean; + hasError: boolean; + error: [string, any]; +} diff --git a/libs/components/src/form-field/form-field.component.html b/libs/components/src/form-field/form-field.component.html index cf638ce1c77..5c46fb557b4 100644 --- a/libs/components/src/form-field/form-field.component.html +++ b/libs/components/src/form-field/form-field.component.html @@ -1,4 +1,4 @@ -