1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 13:10:17 +00:00

Merge branch 'main' into billing/pm-22968-ui-when-msp/bup-is-suspended

This commit is contained in:
cyprain-okeke
2025-07-23 18:07:02 +01:00
committed by GitHub
255 changed files with 6280 additions and 1938 deletions

4
.github/CODEOWNERS vendored
View File

@@ -94,6 +94,8 @@ libs/platform @bitwarden/team-platform-dev
libs/storage-core @bitwarden/team-platform-dev
libs/logging @bitwarden/team-platform-dev
libs/storage-test-utils @bitwarden/team-platform-dev
libs/messaging @bitwarden/team-platform-dev
libs/messaging-internal @bitwarden/team-platform-dev
# Web utils used across app and connectors
apps/web/src/utils/ @bitwarden/team-platform-dev
# Web core and shared files
@@ -138,6 +140,8 @@ apps/desktop/desktop_native/autotype @bitwarden/team-autofill-dev
# DuckDuckGo integration
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev
apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-dev
apps/desktop/src/services/encrypted-message-handler.service.ts @bitwarden/team-autofill-dev
.github/workflows/alert-ddg-files-modified.yml @bitwarden/team-autofill-dev
# SSH Agent
apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-autofill-dev @bitwarden/wg-ssh-keys

View File

@@ -0,0 +1,50 @@
name: DDG File Change Monitor
on:
pull_request:
branches: [ main ]
types: [ opened, synchronize ]
jobs:
check-files:
name: Check files
runs-on: ubuntu-22.04
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Get changed files
id: changed-files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
list-files: shell
filters: |
monitored:
- 'apps/desktop/native-messaging-test-runner/**'
- 'apps/desktop/src/services/duckduckgo-message-handler.service.ts'
- 'apps/desktop/src/services/encrypted-message-handler.service.ts'
- name: Comment on PR if monitored files changed
if: steps.changed-files.outputs.monitored == 'true'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const changedFiles = `${{ steps.changed-files.outputs.monitored_files }}`.split(' ').filter(file => file.trim() !== '');
const message = `⚠️🦆 **DuckDuckGo Integration files have been modified in this PR:**
${changedFiles.map(file => `- \`${file}\``).join('\n')}
Please run the DuckDuckGo native messaging test runner from this branch using [these instructions](https://contributing.bitwarden.com/getting-started/clients/desktop/native-messaging-test-runner) and ensure it functions properly.`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: message
});

View File

@@ -28,6 +28,8 @@ jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
permissions:
contents: read
run-workflow:
name: Build Browser
@@ -35,4 +37,8 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
uses: ./.github/workflows/build-browser.yml
secrets: inherit
permissions:
contents: write
pull-requests: write
id-token: write

View File

@@ -41,7 +41,8 @@ defaults:
run:
shell: bash
permissions: {}
permissions:
contents: read
jobs:
setup:
@@ -77,10 +78,8 @@ jobs:
- name: Check secrets
id: check-secrets
env:
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
run: |
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }}
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
@@ -302,6 +301,9 @@ jobs:
build-safari:
name: Build Safari
runs-on: macos-13
permissions:
contents: read
id-token: write
needs:
- setup
- locales-test
@@ -327,10 +329,19 @@ jobs:
node --version
npm --version
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-clients
secrets: "KEYCHAIN-PASSWORD"
- name: Download Provisioning Profiles secrets
env:
@@ -366,9 +377,12 @@ jobs:
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert |
jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Set up keychain
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }}
run: |
security create-keychain -p $KEYCHAIN_PASSWORD build.keychain
security default-keychain -s build.keychain
@@ -440,6 +454,10 @@ jobs:
name: Crowdin Push
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
permissions:
contents: write
pull-requests: write
id-token: write
needs:
- build
- build-safari
@@ -449,10 +467,12 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -461,6 +481,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Upload Sources
uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1
env:
@@ -478,6 +501,9 @@ jobs:
name: Check for failures
if: always()
runs-on: ubuntu-22.04
permissions:
contents: read
id-token: write
needs:
- setup
- locales-test
@@ -493,11 +519,13 @@ jobs:
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Login to Azure - Prod Subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
if: failure()
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -507,6 +535,10 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "devops-alerts-slack-webhook-url"
- name: Log out from Azure
if: failure()
uses: bitwarden/gh-actions/azure-logout@main
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()

View File

@@ -28,6 +28,8 @@ jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
permissions:
contents: read
run-workflow:
name: Build CLI
@@ -35,4 +37,7 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
uses: ./.github/workflows/build-cli.yml
secrets: inherit
permissions:
contents: read
id-token: write

View File

@@ -78,10 +78,8 @@ jobs:
- name: Check secrets
id: check-secrets
env:
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
run: |
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }}
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
@@ -108,6 +106,10 @@ jobs:
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
_WIN_PKG_FETCH_VERSION: 20.11.1
_WIN_PKG_VERSION: 3.5
permissions:
contents: read
id-token: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -156,9 +158,11 @@ jobs:
- name: Login to Azure
if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }}
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get certificates
if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }}
@@ -168,10 +172,21 @@ jobs:
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert |
jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12
- name: Get Azure Key Vault secrets
id: get-kv-secrets
if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-clients
secrets: "KEYCHAIN-PASSWORD,APP-STORE-CONNECT-AUTH-KEY,APP-STORE-CONNECT-TEAM-ISSUER"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Set up keychain
if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }}
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }}
run: |
security create-keychain -p $KEYCHAIN_PASSWORD build.keychain
security default-keychain -s build.keychain
@@ -199,13 +214,13 @@ jobs:
run: |
mkdir ~/private_keys
cat << EOF > ~/private_keys/AuthKey_6TV9MKN3GP.p8
${{ secrets.APP_STORE_CONNECT_AUTH_KEY }}
${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }}
EOF
- name: Notarize app
if: ${{ matrix.os.base == 'mac' && needs.setup.outputs.has_secrets == 'true' }}
env:
APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}
APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }}
APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP
APP_STORE_CONNECT_AUTH_KEY_PATH: ~/private_keys/AuthKey_6TV9MKN3GP.p8
run: |
@@ -261,6 +276,9 @@ jobs:
{ build_prefix: "bit", artifact_prefix: "", readable: "commercial license" }
]
runs-on: windows-2022
permissions:
contents: read
id-token: write
needs: setup
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
@@ -344,11 +362,13 @@ jobs:
ResourceHacker -open version-info.rc -save version-info.res -action compile
ResourceHacker -open %WIN_PKG_BUILT% -save %WIN_PKG_BUILT% -action addoverwrite -resource version-info.res
- name: Login to Azure
- name: Log in to Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -362,6 +382,10 @@ jobs:
code-signing-client-secret,
code-signing-cert-name"
- name: Log out from Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/azure-logout@main
- name: Install
run: npm ci
working-directory: ./
@@ -520,6 +544,9 @@ jobs:
name: Check for failures
if: always()
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
needs:
- setup
- cli
@@ -534,11 +561,13 @@ jobs:
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Login to Azure - Prod Subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
if: failure()
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -548,6 +577,10 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "devops-alerts-slack-webhook-url"
- name: Log out from Azure
if: failure()
uses: bitwarden/gh-actions/azure-logout@main
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()

View File

@@ -28,6 +28,8 @@ jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
permissions:
contents: read
run-workflow:
name: Build Desktop
@@ -35,4 +37,8 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
uses: ./.github/workflows/build-desktop.yml
secrets: inherit
permissions:
contents: write
pull-requests: write
id-token: write

View File

@@ -147,10 +147,8 @@ jobs:
- name: Check secrets
id: check-secrets
env:
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
run: |
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }}
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
linux:
@@ -404,6 +402,9 @@ jobs:
runs-on: windows-2022
needs:
- setup
permissions:
contents: read
id-token: write
defaults:
run:
shell: pwsh
@@ -438,11 +439,13 @@ jobs:
choco --version
rustup show
- name: Login to Azure
- name: Log in to Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -456,6 +459,10 @@ jobs:
code-signing-client-secret,
code-signing-cert-name"
- name: Log out from Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/azure-logout@main
- name: Install Node dependencies
run: npm ci
working-directory: ./
@@ -655,6 +662,9 @@ jobs:
runs-on: macos-13
needs:
- setup
permissions:
contents: read
id-token: write
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -700,11 +710,21 @@ jobs:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
- name: Login to Azure
- name: Log in to Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-clients
secrets: "KEYCHAIN-PASSWORD"
- name: Download Provisioning Profiles secrets
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
@@ -747,10 +767,14 @@ jobs:
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert |
jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12
- name: Log out from Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/azure-logout@main
- name: Set up keychain
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }}
run: |
security create-keychain -p $KEYCHAIN_PASSWORD build.keychain
security default-keychain -s build.keychain
@@ -850,6 +874,10 @@ jobs:
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: ./.github/workflows/build-browser.yml
secrets: inherit
permissions:
contents: write
pull-requests: write
id-token: write
macos-package-github:
@@ -860,6 +888,9 @@ jobs:
- browser-build
- macos-build
- setup
permissions:
contents: read
id-token: write
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -905,10 +936,19 @@ jobs:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-clients
secrets: "KEYCHAIN-PASSWORD,APP-STORE-CONNECT-AUTH-KEY,APP-STORE-CONNECT-TEAM-ISSUER"
- name: Download Provisioning Profiles secrets
env:
@@ -949,9 +989,12 @@ jobs:
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert |
jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Set up keychain
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }}
run: |
security create-keychain -p $KEYCHAIN_PASSWORD build.keychain
security default-keychain -s build.keychain
@@ -1055,12 +1098,12 @@ jobs:
run: |
mkdir ~/private_keys
cat << EOF > ~/private_keys/AuthKey_6TV9MKN3GP.p8
${{ secrets.APP_STORE_CONNECT_AUTH_KEY }}
${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }}
EOF
- name: Build application (dist)
env:
APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}
APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }}
APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP
APP_STORE_CONNECT_AUTH_KEY_PATH: ~/private_keys/AuthKey_6TV9MKN3GP.p8
CSC_FOR_PULL_REQUEST: true
@@ -1103,6 +1146,9 @@ jobs:
- browser-build
- macos-build
- setup
permissions:
contents: read
id-token: write
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -1148,10 +1194,19 @@ jobs:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-clients
secrets: "KEYCHAIN-PASSWORD,APP-STORE-CONNECT-AUTH-KEY,APP-STORE-CONNECT-TEAM-ISSUER"
- name: Retrieve Slack secret
id: retrieve-slack-secret
@@ -1199,9 +1254,12 @@ jobs:
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert |
jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Set up keychain
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }}
run: |
security create-keychain -p $KEYCHAIN_PASSWORD build.keychain
security default-keychain -s build.keychain
@@ -1305,12 +1363,12 @@ jobs:
run: |
mkdir ~/private_keys
cat << EOF > ~/private_keys/AuthKey_6TV9MKN3GP.p8
${{ secrets.APP_STORE_CONNECT_AUTH_KEY }}
${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }}
EOF
- name: Build application for App Store
env:
APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}
APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }}
APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP
APP_STORE_CONNECT_AUTH_KEY_PATH: ~/private_keys/AuthKey_6TV9MKN3GP.p8
CSC_FOR_PULL_REQUEST: true
@@ -1334,7 +1392,7 @@ jobs:
cat << EOF > ~/secrets/appstoreconnect-fastlane.json
{
"issuer_id": "${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}",
"issuer_id": "${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }}",
"key_id": "6TV9MKN3GP",
"key": "$KEY_WITHOUT_NEWLINES"
}
@@ -1346,7 +1404,7 @@ jobs:
github.event_name != 'pull_request_target'
&& (inputs.testflight_distribute || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop')
env:
APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}
APP_STORE_CONNECT_TEAM_ISSUER: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-TEAM-ISSUER }}
APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP
BRANCH: ${{ github.ref }}
run: |
@@ -1396,6 +1454,10 @@ jobs:
- windows
- macos-package-github
- macos-package-mas
permissions:
contents: write
pull-requests: write
id-token: write
runs-on: ubuntu-22.04
steps:
- name: Check out repo
@@ -1403,10 +1465,12 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -1415,6 +1479,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Upload Sources
uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1
env:
@@ -1442,6 +1509,9 @@ jobs:
- macos-package-github
- macos-package-mas
- crowdin-push
permissions:
contents: read
id-token: write
steps:
- name: Check if any job failed
if: |
@@ -1450,11 +1520,13 @@ jobs:
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Login to Azure - Prod Subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
if: failure()
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -1464,6 +1536,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "devops-alerts-slack-webhook-url"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()
@@ -1471,3 +1546,4 @@ jobs:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with:
status: ${{ job.status }}

View File

@@ -27,6 +27,8 @@ jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
permissions:
contents: read
run-workflow:
name: Build Web
@@ -34,4 +36,9 @@ jobs:
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
uses: ./.github/workflows/build-web.yml
secrets: inherit
permissions:
contents: write
pull-requests: write
id-token: write
security-events: write

View File

@@ -51,7 +51,8 @@ env:
_AZ_REGISTRY: bitwardenprod.azurecr.io
_GITHUB_PR_REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }}
permissions: {}
permissions:
contents: read
jobs:
setup:
@@ -80,10 +81,8 @@ jobs:
- name: Check secrets
id: check-secrets
env:
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
run: |
has_secrets=${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL != '' }}
has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }}
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT
@@ -204,11 +203,13 @@ jobs:
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
########## ACRs ##########
- name: Login to Prod Azure
- name: Log in to Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log into Prod container registry
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
@@ -328,11 +329,19 @@ jobs:
- name: Log out of Docker
run: docker logout $_AZ_REGISTRY
- name: Log out from Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/azure-logout@main
crowdin-push:
name: Crowdin Push
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
needs: build-containers
permissions:
contents: write
pull-requests: write
id-token: write
runs-on: ubuntu-24.04
steps:
- name: Check out repo
@@ -340,10 +349,12 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -352,6 +363,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Upload Sources
uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1
env:
@@ -370,11 +384,15 @@ jobs:
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
needs: build-containers
permissions:
id-token: write
steps:
- name: Login to Azure - CI Subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve github PAT secrets
id: retrieve-secret-pat
@@ -383,6 +401,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger web vault deploy using GitHub Run ID
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
@@ -409,6 +430,8 @@ jobs:
- build-containers
- crowdin-push
- trigger-web-vault-deploy
permissions:
id-token: write
steps:
- name: Check if any job failed
if: |
@@ -417,11 +440,13 @@ jobs:
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Login to Azure - Prod Subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
if: failure()
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -431,6 +456,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "devops-alerts-slack-webhook-url"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()

View File

@@ -15,6 +15,8 @@ jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
permissions:
contents: read
chromatic:
name: Chromatic
@@ -23,6 +25,7 @@ jobs:
permissions:
contents: read
pull-requests: write
id-token: write
steps:
- name: Check out repo
@@ -30,13 +33,13 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Get changed files
id: get-changed-files-for-chromatic
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
storyFiles:
storyFiles:
- "apps/!(cli)/**"
- "bitwarden_license/bit-web/src/app/**"
- "libs/!(eslint)/**"
@@ -74,11 +77,28 @@ jobs:
if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true'
run: npm run build-storybook:ci
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-clients
secrets: "CHROMATIC-PROJECT-TOKEN"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Publish to Chromatic
uses: chromaui/action@e8cc4c31775280b175a3c440076c00d19a9014d7 # v11.28.2
with:
token: ${{ secrets.GITHUB_TOKEN }}
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
projectToken: ${{ steps.get-kv-secrets.outputs.CHROMATIC-PROJECT-TOKEN }}
storybookBuildDir: ./storybook-static
exitOnceUploaded: true
onlyChanged: true

View File

@@ -10,6 +10,9 @@ jobs:
crowdin-sync:
name: Autosync
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
@@ -21,22 +24,19 @@ jobs:
- app_name: web
crowdin_project_id: "308189"
steps:
- name: Generate GH App token
uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3
id: app-token
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
token: ${{ steps.app-token.outputs.token }}
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Retrieve secrets
id: retrieve-secrets
@@ -45,6 +45,21 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
token: ${{ steps.app-token.outputs.token }}
- name: Download translations
uses: bitwarden/gh-actions/crowdin@main
env:

View File

@@ -66,8 +66,9 @@ jobs:
environment_url: ${{ steps.config.outputs.environment_url }}
environment_name: ${{ steps.config.outputs.environment_name }}
environment_artifact: ${{ steps.config.outputs.environment_artifact }}
azure_login_creds: ${{ steps.config.outputs.azure_login_creds }}
retrive_secrets_keyvault: ${{ steps.config.outputs.retrive_secrets_keyvault }}
azure_login_client_key_name: ${{ steps.config.outputs.azure_login_client_key_name }}
azure_login_subscription_id_key_name: ${{ steps.config.outputs.azure_login_subscription_id_key_name }}
retrieve_secrets_keyvault: ${{ steps.config.outputs.retrieve_secrets_keyvault }}
sync_utility: ${{ steps.config.outputs.sync_utility }}
sync_delete_destination_files: ${{ steps.config.outputs.sync_delete_destination_files }}
slack_channel_name: ${{ steps.config.outputs.slack_channel_name }}
@@ -81,40 +82,45 @@ jobs:
case ${{ inputs.environment }} in
"USQA")
echo "azure_login_creds=AZURE_KV_US_QA_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
echo "retrive_secrets_keyvault=bw-webvault-rlktusqa-kv" >> $GITHUB_OUTPUT
echo "azure_login_client_key_name=AZURE_CLIENT_ID_USQA" >> $GITHUB_OUTPUT
echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USQA" >> $GITHUB_OUTPUT
echo "retrieve_secrets_keyvault=bw-webvault-rlktusqa-kv" >> $GITHUB_OUTPUT
echo "environment_artifact=web-*-cloud-QA.zip" >> $GITHUB_OUTPUT
echo "environment_name=Web Vault - US QA Cloud" >> $GITHUB_OUTPUT
echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT
echo "slack_channel_name=alerts-deploy-qa" >> $GITHUB_OUTPUT
;;
"EUQA")
echo "azure_login_creds=AZURE_KV_EU_QA_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
echo "retrive_secrets_keyvault=webvaulteu-westeurope-qa" >> $GITHUB_OUTPUT
echo "azure_login_client_key_name=AZURE_CLIENT_ID_EUQA" >> $GITHUB_OUTPUT
echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_EUQA" >> $GITHUB_OUTPUT
echo "retrieve_secrets_keyvault=webvaulteu-westeurope-qa" >> $GITHUB_OUTPUT
echo "environment_artifact=web-*-cloud-euqa.zip" >> $GITHUB_OUTPUT
echo "environment_name=Web Vault - EU QA Cloud" >> $GITHUB_OUTPUT
echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT
echo "slack_channel_name=alerts-deploy-qa" >> $GITHUB_OUTPUT
;;
"USPROD")
echo "azure_login_creds=AZURE_KV_US_PROD_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
echo "retrive_secrets_keyvault=bw-webvault-klrt-kv" >> $GITHUB_OUTPUT
echo "azure_login_client_key_name=AZURE_CLIENT_ID_USPROD" >> $GITHUB_OUTPUT
echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USPROD" >> $GITHUB_OUTPUT
echo "retrieve_secrets_keyvault=bw-webvault-klrt-kv" >> $GITHUB_OUTPUT
echo "environment_artifact=web-*-cloud-COMMERCIAL.zip" >> $GITHUB_OUTPUT
echo "environment_name=Web Vault - US Production Cloud" >> $GITHUB_OUTPUT
echo "environment_url=http://vault.bitwarden.com" >> $GITHUB_OUTPUT
echo "slack_channel_name=alerts-deploy-prd" >> $GITHUB_OUTPUT
;;
"EUPROD")
echo "azure_login_creds=AZURE_KV_EU_PRD_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
echo "retrive_secrets_keyvault=webvault-westeurope-prod" >> $GITHUB_OUTPUT
echo "azure_login_client_key_name=AZURE_CLIENT_ID_EUPROD" >> $GITHUB_OUTPUT
echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_EUPROD" >> $GITHUB_OUTPUT
echo "retrieve_secrets_keyvault=webvault-westeurope-prod" >> $GITHUB_OUTPUT
echo "environment_artifact=web-*-cloud-euprd.zip" >> $GITHUB_OUTPUT
echo "environment_name=Web Vault - EU Production Cloud" >> $GITHUB_OUTPUT
echo "environment_url=http://vault.bitwarden.eu" >> $GITHUB_OUTPUT
echo "slack_channel_name=alerts-deploy-prd" >> $GITHUB_OUTPUT
;;
"USDEV")
echo "azure_login_creds=AZURE_KV_US_DEV_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
echo "retrive_secrets_keyvault=webvault-eastus-dev" >> $GITHUB_OUTPUT
echo "azure_login_client_key_name=AZURE_CLIENT_ID_USDEV" >> $GITHUB_OUTPUT
echo "azure_login_subscription_id_key_name=AZURE_SUBSCRIPTION_ID_USDEV" >> $GITHUB_OUTPUT
echo "retrieve_secrets_keyvault=webvault-eastus-dev" >> $GITHUB_OUTPUT
echo "environment_artifact=web-*-cloud-usdev.zip" >> $GITHUB_OUTPUT
echo "environment_name=Web Vault - US Development Cloud" >> $GITHUB_OUTPUT
echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT
@@ -180,6 +186,9 @@ jobs:
name: Check if Web artifact is present
runs-on: ubuntu-22.04
needs: setup
permissions:
contents: read
id-token: write
env:
_ENVIRONMENT_ARTIFACT: ${{ needs.setup.outputs.environment_artifact }}
outputs:
@@ -209,11 +218,13 @@ jobs:
branch: ${{ inputs.branch-or-tag }}
artifacts: ${{ env._ENVIRONMENT_ARTIFACT }}
- name: Login to Azure
- name: Log in to Azure
if: ${{ steps.download-latest-artifacts.outcome == 'failure' }}
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets for Build trigger
if: ${{ steps.download-latest-artifacts.outcome == 'failure' }}
@@ -223,6 +234,10 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Log out from Azure
if: ${{ steps.download-latest-artifacts.outcome == 'failure' }}
uses: bitwarden/gh-actions/azure-logout@main
- name: 'Trigger build web for missing branch/tag ${{ inputs.branch-or-tag }}'
if: ${{ steps.download-latest-artifacts.outcome == 'failure' }}
uses: convictional/trigger-workflow-and-wait@f69fa9eedd3c62a599220f4d5745230e237904be # v1.6.5
@@ -262,6 +277,8 @@ jobs:
- artifact-check
runs-on: ubuntu-22.04
if: ${{ always() && ( contains( inputs.environment , 'QA' ) || contains( inputs.environment , 'DEV' ) ) }}
permissions:
id-token: write
outputs:
channel_id: ${{ steps.slack-message.outputs.channel_id }}
ts: ${{ steps.slack-message.outputs.ts }}
@@ -277,7 +294,9 @@ jobs:
event: 'start'
commit-sha: ${{ needs.artifact-check.outputs.artifact_build_commit }}
url: https://github.com/bitwarden/clients/actions/runs/${{ github.run_id }}
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
update-summary:
name: Display commit
@@ -302,6 +321,9 @@ jobs:
_ENVIRONMENT_URL: ${{ needs.setup.outputs.environment_url }}
_ENVIRONMENT_NAME: ${{ needs.setup.outputs.environment_name }}
_ENVIRONMENT_ARTIFACT: ${{ needs.setup.outputs.environment_artifact }}
permissions:
id-token: write
deployments: write
steps:
- name: Create GitHub deployment
uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7
@@ -309,23 +331,25 @@ jobs:
with:
token: '${{ secrets.GITHUB_TOKEN }}'
initial-status: 'in_progress'
environment_url: ${{ env._ENVIRONMENT_URL }}
environment-url: ${{ env._ENVIRONMENT_URL }}
environment: ${{ env._ENVIRONMENT_NAME }}
task: 'deploy'
description: 'Deployment from branch/tag: ${{ inputs.branch-or-tag }}'
ref: ${{ needs.artifact-check.outputs.artifact_build_commit }}
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets[needs.setup.outputs.azure_login_creds] }}
subscription_id: ${{ secrets[needs.setup.outputs.azure_login_subscription_id_key_name] }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets[needs.setup.outputs.azure_login_client_key_name] }}
- name: Retrieve Storage Account connection string for az sync
if: ${{ needs.setup.outputs.sync_utility == 'az-sync' }}
id: retrieve-secrets-az-sync
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: ${{ needs.setup.outputs.retrive_secrets_keyvault }}
keyvault: ${{ needs.setup.outputs.retrieve_secrets_keyvault }}
secrets: "sa-bitwarden-web-vault-dev-key-temp"
- name: Retrieve Storage Account name and SPN credentials for azcopy
@@ -333,9 +357,12 @@ jobs:
id: retrieve-secrets-azcopy
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: ${{ needs.setup.outputs.retrive_secrets_keyvault }}
keyvault: ${{ needs.setup.outputs.retrieve_secrets_keyvault }}
secrets: "sa-bitwarden-web-vault-name,sp-bitwarden-web-vault-password,sp-bitwarden-web-vault-appid,sp-bitwarden-web-vault-tenant"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: 'Download latest cloud asset using GitHub Run ID: ${{ inputs.build-web-run-id }}'
if: ${{ inputs.build-web-run-id }}
uses: bitwarden/gh-actions/download-artifacts@main
@@ -397,7 +424,7 @@ jobs:
uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3
with:
token: '${{ secrets.GITHUB_TOKEN }}'
environment_url: ${{ env._ENVIRONMENT_URL }}
environment-url: ${{ env._ENVIRONMENT_URL }}
state: 'success'
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
@@ -406,7 +433,7 @@ jobs:
uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3
with:
token: '${{ secrets.GITHUB_TOKEN }}'
environment_url: ${{ env._ENVIRONMENT_URL }}
environment-url: ${{ env._ENVIRONMENT_URL }}
state: 'failure'
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
@@ -419,6 +446,8 @@ jobs:
- notify-start
- azure-deploy
- artifact-check
permissions:
id-token: write
steps:
- name: Notify Slack with result
uses: bitwarden/gh-actions/report-deployment-status-to-slack@main
@@ -431,4 +460,6 @@ jobs:
url: https://github.com/bitwarden/clients/actions/runs/${{ github.run_id }}
commit-sha: ${{ needs.artifact-check.outputs.artifact_build_commit }}
update-ts: ${{ needs.notify-start.outputs.ts }}
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}

View File

@@ -5,12 +5,14 @@ on:
types: [opened, synchronize]
paths:
- '**/crowdin.yml'
permissions: {}
jobs:
lint-crowdin-config:
name: Lint Crowdin Config ${{ matrix.app.name }}
runs-on: ubuntu-24.04
permissions:
contents: read
id-token: write
strategy:
matrix:
app: [
@@ -22,17 +24,25 @@ jobs:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
fetch-depth: 1
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "crowdin-api-token"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Lint ${{ matrix.app.name }} config
uses: crowdin/github-action@f214c8723025f41fc55b2ad26e67b60b80b1885d # v2.7.1
env:
@@ -42,4 +52,4 @@ jobs:
with:
dryrun_action: true
command: 'config lint'
command_args: '--verbose -c ${{ matrix.app.config_path }}'
command_args: '--verbose -c ${{ matrix.app.config_path }}'

View File

@@ -48,6 +48,10 @@ jobs:
defaults:
run:
working-directory: .
permissions:
contents: read
deployments: write
steps:
- name: Branch check
if: ${{ inputs.publish_type != 'Dry Run' }}
@@ -86,6 +90,10 @@ jobs:
name: Deploy Snap
runs-on: ubuntu-22.04
needs: setup
permissions:
contents: read
packages: read
id-token: write
if: inputs.snap_publish
env:
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
@@ -93,10 +101,12 @@ jobs:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -105,6 +115,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "snapcraft-store-token"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Install Snap
uses: samuelmeuli/action-snapcraft@d33c176a9b784876d966f80fb1b461808edc0641 # v2.1.1
@@ -123,6 +136,10 @@ jobs:
name: Deploy Choco
runs-on: windows-2022
needs: setup
permissions:
contents: read
packages: read
id-token: write
if: inputs.choco_publish
env:
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
@@ -130,10 +147,12 @@ jobs:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -142,6 +161,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "cli-choco-api-key"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Setup Chocolatey
run: choco apikey --key $env:CHOCO_API_KEY --source https://push.chocolatey.org/
env:
@@ -163,6 +185,10 @@ jobs:
name: Publish NPM
runs-on: ubuntu-22.04
needs: setup
permissions:
contents: read
packages: read
id-token: write
if: inputs.npm_publish
env:
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
@@ -170,10 +196,12 @@ jobs:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -182,6 +210,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "npm-api-key"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Download and set up artifact
run: |
mkdir -p build
@@ -210,6 +241,10 @@ jobs:
- npm
- snap
- choco
permissions:
contents: read
deployments: write
if: ${{ always() && inputs.publish_type != 'Dry Run' }}
steps:
- name: Check if any job failed

View File

@@ -42,6 +42,9 @@ jobs:
release_channel: ${{ steps.release_channel.outputs.channel }}
tag_name: ${{ steps.version.outputs.tag_name }}
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
permissions:
contents: read
deployments: write
steps:
- name: Branch check
if: ${{ inputs.publish_type != 'Dry Run' }}
@@ -106,14 +109,21 @@ jobs:
name: Electron blob publish
runs-on: ubuntu-22.04
needs: setup
permissions:
contents: read
packages: read
id-token: write
deployments: write
env:
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
_RELEASE_TAG: ${{ needs.setup.outputs.tag_name }}
steps:
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -124,6 +134,9 @@ jobs:
aws-electron-access-key,
aws-electron-bucket-name"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Create artifacts directory
run: mkdir -p apps/desktop/artifacts
@@ -176,6 +189,9 @@ jobs:
name: Deploy Snap
runs-on: ubuntu-22.04
needs: setup
permissions:
contents: read
id-token: write
if: inputs.snap_publish
env:
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
@@ -184,10 +200,12 @@ jobs:
- name: Checkout Repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -196,6 +214,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "snapcraft-store-token"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Install Snap
uses: samuelmeuli/action-snapcraft@d33c176a9b784876d966f80fb1b461808edc0641 # v2.1.1
@@ -220,6 +241,9 @@ jobs:
name: Deploy Choco
runs-on: windows-2022
needs: setup
permissions:
contents: read
id-token: write
if: inputs.choco_publish
env:
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
@@ -233,10 +257,12 @@ jobs:
dotnet --version
dotnet nuget --version
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -245,6 +271,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "cli-choco-api-key"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Setup Chocolatey
run: choco apikey --key $env:CHOCO_API_KEY --source https://push.chocolatey.org/
env:
@@ -271,6 +300,9 @@ jobs:
- electron-blob
- snap
- choco
permissions:
contents: read
deployments: write
if: ${{ always() && inputs.publish_type != 'Dry Run' }}
steps:
- name: Check if any job failed

View File

@@ -24,6 +24,8 @@ jobs:
outputs:
release_version: ${{ steps.version.outputs.version }}
tag_version: ${{ steps.version.outputs.tag }}
permissions:
contents: read
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -52,6 +54,10 @@ jobs:
name: Release self-host docker
runs-on: ubuntu-22.04
needs: setup
permissions:
id-token: write
contents: read
deployments: write
env:
_BRANCH_NAME: ${{ github.ref_name }}
_RELEASE_VERSION: ${{ needs.setup.outputs.release_version }}
@@ -69,10 +75,12 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
########## ACR ##########
- name: Login to Azure - PROD Subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Login to Azure ACR
run: az acr login -n bitwardenprod
@@ -121,6 +129,9 @@ jobs:
docker push $_AZ_REGISTRY/web-sh:latest
fi
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Update deployment status to Success
if: ${{ inputs.publish_type != 'Dry Run' && success() }}
uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3
@@ -147,11 +158,15 @@ jobs:
runs-on: ubuntu-22.04
needs:
- setup
permissions:
id-token: write
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
@@ -160,6 +175,9 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger self-host build
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:

View File

@@ -15,6 +15,8 @@ jobs:
setup:
name: Setup
runs-on: ubuntu-22.04
permissions:
contents: write
outputs:
release_version: ${{ steps.version.outputs.version }}
release_channel: ${{ steps.release_channel.outputs.channel }}
@@ -115,6 +117,8 @@ jobs:
name: Linux Build
runs-on: ubuntu-22.04
needs: setup
permissions:
contents: read
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -204,6 +208,9 @@ jobs:
name: Windows Build
runs-on: windows-2022
needs: setup
permissions:
contents: read
id-token: write
defaults:
run:
shell: pwsh
@@ -237,10 +244,12 @@ jobs:
npm --version
choco --version
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -253,6 +262,9 @@ jobs:
code-signing-client-secret,
code-signing-cert-name"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Install Node dependencies
run: npm ci
working-directory: ./
@@ -394,6 +406,9 @@ jobs:
name: MacOS Build
runs-on: macos-13
needs: setup
permissions:
contents: read
id-token: write
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -438,6 +453,20 @@ jobs:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-clients
secrets: "KEYCHAIN-PASSWORD"
- name: Download Provisioning Profiles secrets
env:
ACCOUNT_NAME: bitwardenci
@@ -472,9 +501,12 @@ jobs:
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert |
jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Set up keychain
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }}
run: |
security create-keychain -p $KEYCHAIN_PASSWORD build.keychain
security default-keychain -s build.keychain
@@ -528,6 +560,10 @@ jobs:
needs:
- setup
- macos-build
permissions:
contents: read
packages: read
id-token: write
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -572,10 +608,19 @@ jobs:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-clients
secrets: "KEYCHAIN-PASSWORD,APPLE-ID-USERNAME,APPLE-ID-PASSWORD"
- name: Download Provisioning Profiles secrets
env:
@@ -611,9 +656,12 @@ jobs:
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert |
jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Set up keychain
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }}
run: |
security create-keychain -p $KEYCHAIN_PASSWORD build.keychain
security default-keychain -s build.keychain
@@ -702,8 +750,8 @@ jobs:
- name: Build application (dist)
env:
APPLE_ID_USERNAME: ${{ secrets.APPLE_ID_USERNAME }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_ID_USERNAME: ${{ steps.get-kv-secrets.outputs.APPLE-ID-USERNAME }}
APPLE_ID_PASSWORD: ${{ steps.get-kv-secrets.outputs.APPLE-ID-PASSWORD }}
run: npm run pack:mac
- name: Upload .zip artifact
@@ -741,6 +789,10 @@ jobs:
needs:
- setup
- macos-build
permissions:
contents: read
packages: read
id-token: write
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -785,6 +837,20 @@ jobs:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-clients
secrets: "KEYCHAIN-PASSWORD,APPLE-ID-USERNAME,APPLE-ID-PASSWORD"
- name: Download Provisioning Profiles secrets
env:
ACCOUNT_NAME: bitwardenci
@@ -819,9 +885,12 @@ jobs:
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert |
jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Set up keychain
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }}
run: |
security create-keychain -p $KEYCHAIN_PASSWORD build.keychain
security default-keychain -s build.keychain
@@ -911,8 +980,8 @@ jobs:
- name: Build application for App Store
run: npm run pack:mac:mas
env:
APPLE_ID_USERNAME: ${{ secrets.APPLE_ID_USERNAME }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_ID_USERNAME: ${{ steps.get-kv-secrets.outputs.APPLE-ID-USERNAME }}
APPLE_ID_PASSWORD: ${{ steps.get-kv-secrets.outputs.APPLE-ID-PASSWORD }}
- name: Upload .pkg artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
@@ -931,6 +1000,10 @@ jobs:
- macos-build
- macos-package-github
- macos-package-mas
permissions:
contents: read
id-token: write
deployments: write
steps:
- name: Create GitHub deployment
uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7
@@ -942,10 +1015,12 @@ jobs:
description: 'Deployment ${{ needs.setup.outputs.release_version }} to channel ${{ needs.setup.outputs.release_channel }} from branch ${{ needs.setup.outputs.branch_name }}'
task: release
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -956,6 +1031,9 @@ jobs:
aws-electron-access-key,
aws-electron-bucket-name"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Download all artifacts
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
@@ -1008,6 +1086,8 @@ jobs:
- macos-package-github
- macos-package-mas
- release
permissions:
contents: write
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

View File

@@ -36,7 +36,9 @@ on:
description: "New version override (leave blank for automatic calculation, example: '2024.1.0')"
required: false
type: string
permissions: {}
jobs:
setup:
name: Setup
@@ -56,6 +58,7 @@ jobs:
fi
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
bump_version:
name: Bump Version
if: ${{ always() }}
@@ -66,6 +69,9 @@ jobs:
version_cli: ${{ steps.set-final-version-output.outputs.version_cli }}
version_desktop: ${{ steps.set-final-version-output.outputs.version_desktop }}
version_web: ${{ steps.set-final-version-output.outputs.version_web }}
permissions:
id-token: write
steps:
- name: Validate version input format
if: ${{ inputs.version_number_override != '' }}
@@ -73,12 +79,29 @@ jobs:
with:
version: ${{ inputs.version_number_override }}
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -400,6 +423,7 @@ jobs:
- name: Push changes
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
run: git push
cut_branch:
name: Cut branch
if: ${{ needs.setup.outputs.branch == 'rc' }}
@@ -407,13 +431,33 @@ jobs:
- setup
- bump_version
runs-on: ubuntu-24.04
permissions:
id-token: write
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -435,4 +479,4 @@ jobs:
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: |
git switch --quiet --create $BRANCH_NAME
git push --quiet --set-upstream origin $BRANCH_NAME
git push --quiet --set-upstream origin $BRANCH_NAME

View File

@@ -11,11 +11,15 @@ jobs:
rollout:
name: Retrieve Rollout Percentage
runs-on: ubuntu-22.04
permissions:
id-token: write
steps:
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -26,6 +30,9 @@ jobs:
aws-electron-access-key,
aws-electron-bucket-name"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Download channel update info files from S3
env:
AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }}

View File

@@ -18,11 +18,15 @@ jobs:
rollout:
name: Update Rollout Percentage
runs-on: ubuntu-22.04
permissions:
id-token: write
steps:
- name: Login to Azure
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
@@ -33,6 +37,9 @@ jobs:
aws-electron-access-key,
aws-electron-bucket-name"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Download channel update info files from S3
env:
AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }}

View File

@@ -9,13 +9,33 @@ jobs:
bump-version:
name: Bump Desktop Version
runs-on: ubuntu-24.04
permissions:
id-token: write
contents: write
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
- name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

View File

@@ -547,6 +547,9 @@
"searchVault": {
"message": "Search vault"
},
"resetSearch": {
"message": "Reset search"
},
"edit": {
"message": "Edit"
},

View File

@@ -0,0 +1,50 @@
import { MockProxy, mock } from "jest-mock-extended";
import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import { ExtensionChangePasswordService } from "./extension-change-password.service";
describe("ExtensionChangePasswordService", () => {
let keyService: MockProxy<KeyService>;
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let window: MockProxy<Window>;
let changePasswordService: ChangePasswordService;
beforeEach(() => {
keyService = mock<KeyService>();
masterPasswordApiService = mock<MasterPasswordApiService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
window = mock<Window>();
changePasswordService = new ExtensionChangePasswordService(
keyService,
masterPasswordApiService,
masterPasswordService,
window,
);
});
it("should instantiate the service", () => {
expect(changePasswordService).toBeDefined();
});
it("should close the browser extension popout", () => {
const closePopupSpy = jest.spyOn(BrowserApi, "closePopup");
const browserPopupUtilsInPopupSpy = jest
.spyOn(BrowserPopupUtils, "inPopout")
.mockReturnValue(true);
changePasswordService.closeBrowserExtensionPopout?.();
expect(closePopupSpy).toHaveBeenCalledWith(window);
expect(browserPopupUtilsInPopupSpy).toHaveBeenCalledWith(window);
});
});

View File

@@ -0,0 +1,29 @@
import {
DefaultChangePasswordService,
ChangePasswordService,
} from "@bitwarden/angular/auth/password-management/change-password";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
export class ExtensionChangePasswordService
extends DefaultChangePasswordService
implements ChangePasswordService
{
constructor(
protected keyService: KeyService,
protected masterPasswordApiService: MasterPasswordApiService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
private win: Window,
) {
super(keyService, masterPasswordApiService, masterPasswordService);
}
closeBrowserExtensionPopout(): void {
if (BrowserPopupUtils.inPopout(this.win)) {
BrowserApi.closePopup(this.win);
}
}
}

View File

@@ -1,12 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, switchMap } from "rxjs";
import { filter, firstValueFrom, of, switchMap } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
@@ -51,9 +51,14 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
* Initializes the auto-submit login policy. If the policy is not enabled, it
* will trigger a removal of any established listeners.
*/
async init() {
this.accountService.activeAccount$
this.authService.activeAccountStatus$
.pipe(
switchMap((value) =>
value === AuthenticationStatus.Unlocked ? this.accountService.activeAccount$ : of(null),
),
filter((account): account is Account => account !== null),
getUserId,
switchMap((userId) =>
this.policyService.policiesByType$(PolicyType.AutomaticAppLogIn, userId),

View File

@@ -3,10 +3,8 @@ import { Subject, firstValueFrom } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { tagAsExternal } from "@bitwarden/common/platform/messaging/helpers";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { tagAsExternal } from "@bitwarden/messaging-internal";
import { FullSyncMessage } from "./foreground-sync.service";
import { FULL_SYNC_FINISHED, SyncServiceListener } from "./sync-service.listener";

View File

@@ -5,6 +5,7 @@ import { merge, of, Subject } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password";
import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service";
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
@@ -45,6 +46,7 @@ import {
AccountService as AccountServiceAbstraction,
} from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import {
@@ -143,6 +145,7 @@ import {
import { AccountSwitcherService } from "../../auth/popup/account-switching/services/account-switcher.service";
import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service";
import { ExtensionChangePasswordService } from "../../auth/popup/change-password/extension-change-password.service";
import { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service";
import { ExtensionSsoComponentService } from "../../auth/popup/login/extension-sso-component.service";
import { ExtensionLogoutService } from "../../auth/popup/logout/extension-logout.service";
@@ -664,6 +667,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultSshImportPromptService,
deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction],
}),
safeProvider({
provide: ChangePasswordService,
useClass: ExtensionChangePasswordService,
deps: [KeyService, MasterPasswordApiService, InternalMasterPasswordServiceAbstraction, WINDOW],
}),
safeProvider({
provide: NotificationsService,
useClass: ForegroundNotificationsService,

View File

@@ -10,16 +10,7 @@
<bit-item>
<a bit-item-content routerLink="/account-security">
<i slot="start" class="bwi bwi-lock" aria-hidden="true"></i>
<div class="tw-flex tw-items-center tw-justify-center">
<p class="tw-pr-2">{{ "accountSecurity" | i18n }}</p>
<span
*ngIf="showAcctSecurityNudge$ | async"
bitBadge
variant="notification"
[attr.aria-label]="'nudgeBadgeAria' | i18n"
>1</span
>
</div>
{{ "accountSecurity" | i18n }}
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>

View File

@@ -50,12 +50,6 @@ export class SettingsV2Component implements OnInit {
shareReplay({ bufferSize: 1, refCount: true }),
);
protected showAcctSecurityNudge$: Observable<boolean> = this.authenticatedAccount$.pipe(
switchMap((account) =>
this.nudgesService.showNudgeBadge$(NudgeType.AccountSecurity, account.id),
),
);
showDownloadBitwardenNudge$: Observable<boolean> = this.authenticatedAccount$.pipe(
switchMap((account) =>
this.nudgesService.showNudgeBadge$(NudgeType.DownloadBitwarden, account.id),

View File

@@ -3,8 +3,7 @@
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { BehaviorSubject, combineLatest, firstValueFrom, map, switchMap } from "rxjs";
import { filter } from "rxjs/operators";
import { BehaviorSubject, combineLatest, filter, firstValueFrom, map, switchMap } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -15,6 +14,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import {
CipherViewLike,
CipherViewLikeUtils,
@@ -70,9 +70,21 @@ export class ItemMoreOptionsComponent {
* Observable that emits a boolean value indicating if the user is authorized to clone the cipher.
* @protected
*/
protected canClone$ = this._cipher$.pipe(
filter((c) => c != null),
switchMap((c) => this.cipherAuthorizationService.canCloneCipher$(c)),
protected canClone$ = combineLatest([
this._cipher$,
this.restrictedItemTypesService.restricted$,
]).pipe(
filter(([c]) => c != null),
switchMap(([c, restrictedTypes]) => {
// This will check for restrictions from org policies before allowing cloning.
const isItemRestricted = restrictedTypes.some(
(restrictType) => restrictType.cipherType === c.type,
);
if (!isItemRestricted) {
return this.cipherAuthorizationService.canCloneCipher$(c);
}
return new BehaviorSubject(false);
}),
);
/** Observable Boolean dependent on the current user having access to an organization and editable collections */
@@ -103,6 +115,7 @@ export class ItemMoreOptionsComponent {
private organizationService: OrganizationService,
private cipherAuthorizationService: CipherAuthorizationService,
private collectionService: CollectionService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {}
get canEdit() {

View File

@@ -146,14 +146,16 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
ciphers: PopupCipherViewLike[];
}[]
>(() => {
const ciphers = this.ciphers();
// Not grouping by type, return a single group with all ciphers
if (!this.groupByType()) {
return [{ ciphers: this.ciphers() }];
if (!this.groupByType() && ciphers.length > 0) {
return [{ ciphers }];
}
const groups: Record<string, PopupCipherViewLike[]> = {};
this.ciphers().forEach((cipher) => {
ciphers.forEach((cipher) => {
let groupKey = "all";
switch (CipherViewLikeUtils.getType(cipher)) {
case CipherType.Card:

View File

@@ -69,8 +69,8 @@
"browser-hrtime": "1.1.8",
"chalk": "4.1.2",
"commander": "11.1.0",
"core-js": "3.42.0",
"form-data": "4.0.2",
"core-js": "3.44.0",
"form-data": "4.0.4",
"https-proxy-agent": "7.0.6",
"inquirer": "8.2.6",
"jsdom": "26.1.0",

View File

@@ -25,7 +25,6 @@ import { LoginExport } from "@bitwarden/common/models/export/login.export";
import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.export";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@@ -44,7 +43,6 @@ import { SelectionReadOnly } from "../admin-console/models/selection-read-only";
import { Response } from "../models/response";
import { StringResponse } from "../models/response/string.response";
import { TemplateResponse } from "../models/response/template.response";
import { SendResponse } from "../tools/send/models/send.response";
import { CliUtils } from "../utils";
import { CipherResponse } from "../vault/models/cipher.response";
import { FolderResponse } from "../vault/models/folder.response";
@@ -577,11 +575,11 @@ export class GetCommand extends DownloadCommand {
case "org-collection":
template = OrganizationCollectionRequest.template();
break;
case "send.text":
template = SendResponse.template(SendType.Text);
break;
case "send.file":
template = SendResponse.template(SendType.File);
case "send.text":
template = Response.badRequest(
`Invalid template object. Use \`bw send template ${id}\` instead.`,
);
break;
default:
return Response.badRequest("Unknown template object.");

View File

@@ -76,9 +76,14 @@ export class SendCreateCommand {
const filePath = req.file?.fileName ?? options.file;
const text = req.text?.text ?? options.text;
const hidden = req.text?.hidden ?? options.hidden;
const password = req.password ?? options.password;
const password = req.password ?? options.password ?? undefined;
const emails = req.emails ?? options.emails ?? undefined;
const maxAccessCount = req.maxAccessCount ?? options.maxAccessCount;
if (emails !== undefined && password !== undefined) {
return Response.badRequest("--password and --emails are mutually exclusive.");
}
req.key = null;
req.maxAccessCount = maxAccessCount;
@@ -133,6 +138,7 @@ export class SendCreateCommand {
// Add dates from template
encSend.deletionDate = sendView.deletionDate;
encSend.expirationDate = sendView.expirationDate;
encSend.emails = emails && emails.join(",");
await this.sendApiService.save([encSend, fileData]);
const newSend = await this.sendService.getFromState(encSend.id);
@@ -151,12 +157,14 @@ class Options {
text: string;
maxAccessCount: number;
password: string;
emails: Array<string>;
hidden: boolean;
constructor(passedOptions: Record<string, any>) {
this.file = passedOptions?.file;
this.text = passedOptions?.text;
this.password = passedOptions?.password;
this.emails = passedOptions?.email;
this.hidden = CliUtils.convertBooleanOption(passedOptions?.hidden);
this.maxAccessCount =
passedOptions?.maxAccessCount != null ? parseInt(passedOptions.maxAccessCount, null) : null;

View File

@@ -50,11 +50,21 @@ export class SendEditCommand {
const normalizedOptions = new Options(cmdOptions);
req.id = normalizedOptions.itemId || req.id;
if (req.id != null) {
req.id = req.id.toLowerCase();
if (normalizedOptions.emails) {
req.emails = normalizedOptions.emails;
req.password = undefined;
} else if (normalizedOptions.password) {
req.emails = undefined;
req.password = normalizedOptions.password;
} else if (req.password && (typeof req.password !== "string" || req.password === "")) {
req.password = undefined;
}
if (!req.id) {
return Response.error("`itemid` was not provided.");
}
req.id = req.id.toLowerCase();
const send = await this.sendService.getFromState(req.id);
if (send == null) {
@@ -76,10 +86,6 @@ export class SendEditCommand {
let sendView = await send.decrypt();
sendView = SendResponse.toView(req, sendView);
if (typeof req.password !== "string" || req.password === "") {
req.password = null;
}
try {
const [encSend, encFileData] = await this.sendService.encrypt(sendView, null, req.password);
// Add dates from template
@@ -97,8 +103,12 @@ export class SendEditCommand {
class Options {
itemId: string;
password: string;
emails: string[];
constructor(passedOptions: Record<string, any>) {
this.itemId = passedOptions?.itemId || passedOptions?.itemid;
this.password = passedOptions.password;
this.emails = passedOptions.email;
}
}

View File

@@ -5,3 +5,4 @@ export * from "./get.command";
export * from "./list.command";
export * from "./receive.command";
export * from "./remove-password.command";
export * from "./template.command";

View File

@@ -0,0 +1,35 @@
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { Response } from "../../../models/response";
import { TemplateResponse } from "../../../models/response/template.response";
import { SendResponse } from "../models/send.response";
export class SendTemplateCommand {
constructor() {}
run(type: string): Response {
let template: SendResponse | undefined;
let response: Response;
switch (type) {
case "send.text":
case "text":
template = SendResponse.template(SendType.Text);
break;
case "send.file":
case "file":
template = SendResponse.template(SendType.File);
break;
default:
response = Response.badRequest("Unknown template object.");
}
if (template) {
response = Response.success(new TemplateResponse(template));
}
response ??= Response.badRequest("An error occurred while retrieving the template.");
return response;
}
}

View File

@@ -26,6 +26,7 @@ export class SendResponse implements BaseResponse {
req.deletionDate = this.getStandardDeletionDate(deleteInDays);
req.expirationDate = null;
req.password = null;
req.emails = null;
req.disabled = false;
req.hideEmail = false;
return req;
@@ -50,6 +51,7 @@ export class SendResponse implements BaseResponse {
view.deletionDate = send.deletionDate;
view.expirationDate = send.expirationDate;
view.password = send.password;
view.emails = send.emails ?? [];
view.disabled = send.disabled;
view.hideEmail = send.hideEmail;
return view;
@@ -87,6 +89,7 @@ export class SendResponse implements BaseResponse {
expirationDate: Date;
password: string;
passwordSet: boolean;
emails?: Array<string>;
disabled: boolean;
hideEmail: boolean;

View File

@@ -4,13 +4,12 @@ import * as fs from "fs";
import * as path from "path";
import * as chalk from "chalk";
import { program, Command, OptionValues } from "commander";
import { program, Command, Option, OptionValues } from "commander";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { BaseProgram } from "../../base-program";
import { GetCommand } from "../../commands/get.command";
import { Response } from "../../models/response";
import { CliUtils } from "../../utils";
@@ -22,10 +21,12 @@ import {
SendListCommand,
SendReceiveCommand,
SendRemovePasswordCommand,
SendTemplateCommand,
} from "./commands";
import { SendFileResponse } from "./models/send-file.response";
import { SendTextResponse } from "./models/send-text.response";
import { SendResponse } from "./models/send.response";
import { parseEmail } from "./util";
const writeLn = CliUtils.writeLn;
@@ -48,6 +49,17 @@ export class SendProgram extends BaseProgram {
"The number of days in the future to set deletion date, defaults to 7",
"7",
)
.addOption(
new Option(
"--password <password>",
"optional password to access this Send. Can also be specified in JSON.",
).conflicts("email"),
)
.option(
"--email <email>",
"optional emails to access this Send. Can also be specified in JSON.",
parseEmail,
)
.option("-a, --maxAccessCount <amount>", "The amount of max possible accesses.")
.option("--hidden", "Hide <data> in web by default. Valid only if --file is not set.")
.option(
@@ -139,26 +151,9 @@ export class SendProgram extends BaseProgram {
return new Command("template")
.argument("<object>", "Valid objects are: send.text, send.file")
.description("Get json templates for send objects")
.action(async (object) => {
const cmd = new GetCommand(
this.serviceContainer.cipherService,
this.serviceContainer.folderService,
this.serviceContainer.collectionService,
this.serviceContainer.totpService,
this.serviceContainer.auditService,
this.serviceContainer.keyService,
this.serviceContainer.encryptService,
this.serviceContainer.searchService,
this.serviceContainer.apiService,
this.serviceContainer.organizationService,
this.serviceContainer.eventCollectionService,
this.serviceContainer.billingAccountProfileStateService,
this.serviceContainer.accountService,
this.serviceContainer.cliRestrictedItemTypesService,
);
const response = await cmd.run("template", object, null);
this.processResponse(response);
});
.action((options: OptionValues) =>
this.processResponse(new SendTemplateCommand().run(options.object)),
);
}
private getCommand(): Command {
@@ -208,10 +203,6 @@ export class SendProgram extends BaseProgram {
.option("--file <path>", "file to Send. Can also be specified in parent's JSON.")
.option("--text <text>", "text to Send. Can also be specified in parent's JSON.")
.option("--hidden", "text hidden flag. Valid only with the --text option.")
.option(
"--password <password>",
"optional password to access this Send. Can also be specified in JSON",
)
.on("--help", () => {
writeLn("");
writeLn("Note:");
@@ -219,13 +210,13 @@ export class SendProgram extends BaseProgram {
writeLn("", true);
})
.action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => {
// Work-around to support `--fullObject` option for `send create --fullObject`
// Calling `option('--fullObject', ...)` above won't work due to Commander doesn't like same option
// to be defind on both parent-command and sub-command
const { fullObject = false } = args.parent.opts();
// subcommands inherit flags from their parent; they cannot override them
const { fullObject = false, email = undefined, password = undefined } = args.parent.opts();
const mergedOptions = {
...options,
fullObject: fullObject,
email,
password,
};
const response = await this.runCreate(encodedJson, mergedOptions);
@@ -247,7 +238,7 @@ export class SendProgram extends BaseProgram {
writeLn(" You cannot update a File-type Send's file. Just delete and remake it");
writeLn("", true);
})
.action(async (encodedJson: string, options: OptionValues) => {
.action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => {
await this.exitIfLocked();
const getCmd = new SendGetCommand(
this.serviceContainer.sendService,
@@ -264,7 +255,16 @@ export class SendProgram extends BaseProgram {
this.serviceContainer.billingAccountProfileStateService,
this.serviceContainer.accountService,
);
const response = await cmd.run(encodedJson, options);
// subcommands inherit flags from their parent; they cannot override them
const { email = undefined, password = undefined } = args.parent.opts();
const mergedOptions = {
...options,
email,
password,
};
const response = await cmd.run(encodedJson, mergedOptions);
this.processResponse(response);
});
}

View File

@@ -0,0 +1,194 @@
import { parseEmail } from "./util";
describe("parseEmail", () => {
describe("single email address parsing", () => {
it("should parse a valid single email address", () => {
const result = parseEmail("test@example.com", []);
expect(result).toEqual(["test@example.com"]);
});
it("should parse email with dots in local part", () => {
const result = parseEmail("test.user@example.com", []);
expect(result).toEqual(["test.user@example.com"]);
});
it("should parse email with underscores and hyphens", () => {
const result = parseEmail("test_user-name@example.com", []);
expect(result).toEqual(["test_user-name@example.com"]);
});
it("should parse email with plus sign", () => {
const result = parseEmail("test+user@example.com", []);
expect(result).toEqual(["test+user@example.com"]);
});
it("should parse email with dots and hyphens in domain", () => {
const result = parseEmail("user@test-domain.co.uk", []);
expect(result).toEqual(["user@test-domain.co.uk"]);
});
it("should add single email to existing previousInput array", () => {
const result = parseEmail("new@example.com", ["existing@test.com"]);
expect(result).toEqual(["existing@test.com", "new@example.com"]);
});
});
describe("comma-separated email lists", () => {
it("should parse comma-separated email list", () => {
const result = parseEmail("test@example.com,user@domain.com", []);
expect(result).toEqual(["test@example.com", "user@domain.com"]);
});
it("should parse comma-separated emails with spaces", () => {
const result = parseEmail("test@example.com, user@domain.com, admin@site.org", []);
expect(result).toEqual(["test@example.com", "user@domain.com", "admin@site.org"]);
});
it("should combine comma-separated emails with previousInput", () => {
const result = parseEmail("new1@example.com,new2@domain.com", ["existing@test.com"]);
expect(result).toEqual(["existing@test.com", "new1@example.com", "new2@domain.com"]);
});
it("should throw error for invalid email in comma-separated list", () => {
expect(() => {
parseEmail("valid@example.com,invalid-email,another@domain.com", []);
}).toThrow("Invalid email address: invalid-email");
});
});
describe("space-separated email lists", () => {
it("should parse space-separated email list", () => {
const result = parseEmail("test@example.com user@domain.com", []);
expect(result).toEqual(["test@example.com", "user@domain.com"]);
});
it("should parse space-separated emails with multiple spaces", () => {
const result = parseEmail("test@example.com user@domain.com admin@site.org", []);
expect(result).toEqual(["test@example.com", "user@domain.com", "admin@site.org"]);
});
it("should combine space-separated emails with previousInput", () => {
const result = parseEmail("new1@example.com new2@domain.com", ["existing@test.com"]);
expect(result).toEqual(["existing@test.com", "new1@example.com", "new2@domain.com"]);
});
it("should throw error for invalid email in space-separated list", () => {
expect(() => {
parseEmail("valid@example.com invalid-email another@domain.com", []);
}).toThrow("Invalid email address: invalid-email");
});
});
describe("JSON array input format", () => {
it("should parse valid JSON array of emails", () => {
const result = parseEmail('["test@example.com", "user@domain.com"]', []);
expect(result).toEqual(["test@example.com", "user@domain.com"]);
});
it("should parse single email in JSON array", () => {
const result = parseEmail('["test@example.com"]', []);
expect(result).toEqual(["test@example.com"]);
});
it("should parse empty JSON array", () => {
const result = parseEmail("[]", []);
expect(result).toEqual([]);
});
it("should combine JSON array with previousInput", () => {
const result = parseEmail('["new1@example.com", "new2@domain.com"]', ["existing@test.com"]);
expect(result).toEqual(["existing@test.com", "new1@example.com", "new2@domain.com"]);
});
it("should throw error for malformed JSON", () => {
expect(() => {
parseEmail('["test@example.com", "user@domain.com"', []);
}).toThrow();
});
it("should throw error for JSON that is not an array", () => {
expect(() => {
parseEmail('{"email": "test@example.com"}', []);
}).toThrow("Invalid email address:");
});
it("should throw error for JSON string instead of array", () => {
expect(() => {
parseEmail('"test@example.com"', []);
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
});
it("should throw error for JSON number instead of array", () => {
expect(() => {
parseEmail("123", []);
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
});
});
describe("`previousInput` parameter handling", () => {
it("should handle undefined previousInput", () => {
const result = parseEmail("test@example.com", undefined as any);
expect(result).toEqual(["test@example.com"]);
});
it("should handle null previousInput", () => {
const result = parseEmail("test@example.com", null as any);
expect(result).toEqual(["test@example.com"]);
});
it("should preserve existing emails in previousInput", () => {
const existing = ["existing1@test.com", "existing2@test.com"];
const result = parseEmail("new@example.com", existing);
expect(result).toEqual(["existing1@test.com", "existing2@test.com", "new@example.com"]);
});
});
describe("error cases and edge conditions", () => {
it("should throw error for empty string input", () => {
expect(() => {
parseEmail("", []);
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
});
it("should return empty array for whitespace-only input", () => {
const result = parseEmail(" ", []);
expect(result).toEqual([]);
});
it("should throw error for invalid single email", () => {
expect(() => {
parseEmail("invalid-email", []);
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
});
it("should throw error for email without @ symbol", () => {
expect(() => {
parseEmail("testexample.com", []);
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
});
it("should throw error for email without domain", () => {
expect(() => {
parseEmail("test@", []);
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
});
it("should throw error for email without local part", () => {
expect(() => {
parseEmail("@example.com", []);
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
});
it("should throw error for input that looks like file path", () => {
expect(() => {
parseEmail("/path/to/file.txt", []);
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
});
it("should throw error for input that looks like URL", () => {
expect(() => {
parseEmail("https://example.com", []);
}).toThrow("`input` must be a single address, a comma-separated list, or a JSON array");
});
});
});

View File

@@ -0,0 +1,55 @@
/**
* Parses email addresses from various input formats and combines them with previously parsed emails.
*
* Supports: single email, JSON array, comma-separated, or space-separated lists.
* Note: Function signature follows Commander.js option parsing pattern.
*
* @param input - Email input string in any supported format
* @param previousInput - Previously parsed email addresses to append to
* @returns Combined array of email addresses
* @throws {Error} For invalid JSON, non-array JSON, invalid email addresses, or unrecognized format
*
* @example
* parseEmail("user@example.com", []) // ["user@example.com"]
* parseEmail('["user1@example.com", "user2@example.com"]', []) // ["user1@example.com", "user2@example.com"]
* parseEmail("user1@example.com, user2@example.com", []) // ["user1@example.com", "user2@example.com"]
*/
export function parseEmail(input: string, previousInput: string[]) {
let result = previousInput ?? [];
if (isEmail(input)) {
result.push(input);
} else if (input.startsWith("[")) {
const json = JSON.parse(input);
if (!Array.isArray(json)) {
throw new Error("invalid JSON");
}
result = result.concat(json);
} else if (input.includes(",")) {
result = result.concat(parseList(input, ","));
} else if (input.includes(" ")) {
result = result.concat(parseList(input, " "));
} else {
throw new Error("`input` must be a single address, a comma-separated list, or a JSON array");
}
return result;
}
function isEmail(input: string) {
return !!input && !!input.match(/^([\w._+-]+?)@([\w._+-]+?)$/);
}
function parseList(value: string, separator: string) {
const parts = value
.split(separator)
.map((v) => v.trim())
.filter((v) => !!v.length);
const invalid = parts.find((v) => !isEmail(v));
if (invalid) {
throw new Error(`Invalid email address: ${invalid}`);
}
return parts;
}

View File

@@ -83,3 +83,93 @@ impl KeyMaterial {
Ok(Sha256::digest(self.digest_material()))
}
}
#[cfg(test)]
mod tests {
use crate::biometric::{decrypt, encrypt, KeyMaterial};
use crate::crypto::CipherString;
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use std::str::FromStr;
fn key_material() -> KeyMaterial {
KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
}
}
#[test]
fn test_encrypt() {
let key_material = key_material();
let iv_b64 = "l9fhDUP/wDJcKwmEzcb/3w==".to_owned();
let secret = encrypt("secret", &key_material, &iv_b64)
.unwrap()
.parse::<CipherString>()
.unwrap();
match secret {
CipherString::AesCbc256_B64 { iv, data: _ } => {
assert_eq!(iv_b64, base64_engine.encode(iv));
}
_ => panic!("Invalid cipher string"),
}
}
#[test]
fn test_decrypt() {
let secret =
CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt
let key_material = key_material();
assert_eq!(decrypt(&secret, &key_material).unwrap(), "secret")
}
#[test]
fn key_material_produces_valid_key() {
let result = key_material().derive_key().unwrap();
assert_eq!(result.len(), 32);
}
#[test]
fn key_material_uses_os_part() {
let mut key_material = key_material();
let result = key_material.derive_key().unwrap();
key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned();
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
#[test]
fn key_material_uses_client_part() {
let mut key_material = key_material();
let result = key_material.derive_key().unwrap();
key_material.client_key_part_b64 =
Some("BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned());
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
#[test]
fn key_material_produces_consistent_os_only_key() {
let mut key_material = key_material();
key_material.client_key_part_b64 = None;
let result = key_material.derive_key().unwrap();
assert_eq!(
result,
[
81, 100, 62, 172, 151, 119, 182, 58, 123, 38, 129, 116, 209, 253, 66, 118, 218,
237, 236, 155, 201, 234, 11, 198, 229, 171, 246, 144, 71, 188, 84, 246
]
.into()
);
}
#[test]
fn key_material_produces_unique_os_only_key() {
let mut key_material = key_material();
key_material.client_key_part_b64 = None;
let result = key_material.derive_key().unwrap();
key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned();
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
}

View File

@@ -1,22 +1,18 @@
use std::{
ffi::c_void,
str::FromStr,
sync::{atomic::AtomicBool, Arc},
};
use std::{ffi::c_void, str::FromStr};
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use rand::RngCore;
use sha2::{Digest, Sha256};
use windows::{
core::{factory, h, HSTRING},
Security::{
Credentials::{
KeyCredentialCreationOption, KeyCredentialManager, KeyCredentialStatus, UI::*,
},
Cryptography::CryptographicBuffer,
core::{factory, HSTRING},
Security::Credentials::UI::{
UserConsentVerificationResult, UserConsentVerifier, UserConsentVerifierAvailability,
},
Win32::{
Foundation::HWND, System::WinRT::IUserConsentVerifierInterop,
UI::WindowsAndMessaging::GetForegroundWindow,
},
Win32::{Foundation::HWND, System::WinRT::IUserConsentVerifierInterop},
};
use windows_future::IAsyncOperation;
@@ -25,10 +21,7 @@ use crate::{
crypto::CipherString,
};
use super::{
decrypt, encrypt,
windows_focus::{focus_security_prompt, set_focus},
};
use super::{decrypt, encrypt, windows_focus::set_focus};
/// The Windows OS implementation of the biometric trait.
pub struct Biometric {}
@@ -44,9 +37,15 @@ impl super::BiometricTrait for Biometric {
// should set the window to the foreground and focus it.
set_focus(window);
// Windows Hello prompt must be in foreground, focused, otherwise the face or fingerprint
// unlock will not work. We get the current foreground window, which will either be the
// Bitwarden desktop app or the browser extension.
let foreground_window = unsafe { GetForegroundWindow() };
let interop = factory::<UserConsentVerifier, IUserConsentVerifierInterop>()?;
let operation: IAsyncOperation<UserConsentVerificationResult> =
unsafe { interop.RequestVerificationForWindowAsync(window, &HSTRING::from(message))? };
let operation: IAsyncOperation<UserConsentVerificationResult> = unsafe {
interop.RequestVerificationForWindowAsync(foreground_window, &HSTRING::from(message))?
};
let result = operation.get()?;
match result {
@@ -65,14 +64,6 @@ impl super::BiometricTrait for Biometric {
}
}
/// Derive the symmetric encryption key from the Windows Hello signature.
///
/// This works by signing a static challenge string with Windows Hello protected key store. The
/// signed challenge is then hashed using SHA-256 and used as the symmetric encryption key for the
/// Windows Hello protected keys.
///
/// Windows will only sign the challenge if the user has successfully authenticated with Windows,
/// ensuring user presence.
fn derive_key_material(challenge_str: Option<&str>) -> Result<OsDerivedKey> {
let challenge: [u8; 16] = match challenge_str {
Some(challenge_str) => base64_engine
@@ -81,51 +72,10 @@ impl super::BiometricTrait for Biometric {
.map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?,
None => random_challenge(),
};
let bitwarden = h!("Bitwarden");
let result = KeyCredentialManager::RequestCreateAsync(
bitwarden,
KeyCredentialCreationOption::FailIfExists,
)?
.get()?;
let result = match result.Status()? {
KeyCredentialStatus::CredentialAlreadyExists => {
KeyCredentialManager::OpenAsync(bitwarden)?.get()?
}
KeyCredentialStatus::Success => result,
_ => return Err(anyhow!("Failed to create key credential")),
};
let challenge_buffer = CryptographicBuffer::CreateFromByteArray(&challenge)?;
let async_operation = result.Credential()?.RequestSignAsync(&challenge_buffer)?;
focus_security_prompt();
let done = Arc::new(AtomicBool::new(false));
let done_clone = done.clone();
let _ = std::thread::spawn(move || loop {
if !done_clone.load(std::sync::atomic::Ordering::Relaxed) {
focus_security_prompt();
std::thread::sleep(std::time::Duration::from_millis(500));
} else {
break;
}
});
let signature = async_operation.get();
done.store(true, std::sync::atomic::Ordering::Relaxed);
let signature = signature?;
if signature.Status()? != KeyCredentialStatus::Success {
return Err(anyhow!("Failed to sign data"));
}
let signature_buffer = signature.Result()?;
let mut signature_value =
windows::core::Array::<u8>::with_len(signature_buffer.Length().unwrap() as usize);
CryptographicBuffer::CopyToByteArray(&signature_buffer, &mut signature_value)?;
let key = Sha256::digest(&*signature_value);
// Uses a key derived from the iv. This key is not intended to add any security
// but only a place-holder
let key = Sha256::digest(challenge);
let key_b64 = base64_engine.encode(key);
let iv_b64 = base64_engine.encode(challenge);
Ok(OsDerivedKey { key_b64, iv_b64 })
@@ -182,10 +132,9 @@ fn random_challenge() -> [u8; 16] {
mod tests {
use super::*;
use crate::biometric::{encrypt, BiometricTrait};
use crate::biometric::BiometricTrait;
#[test]
#[cfg(feature = "manual_test")]
fn test_derive_key_material() {
let iv_input = "l9fhDUP/wDJcKwmEzcb/3w==";
let result = <Biometric as BiometricTrait>::derive_key_material(Some(iv_input)).unwrap();
@@ -195,7 +144,6 @@ mod tests {
}
#[test]
#[cfg(feature = "manual_test")]
fn test_derive_key_material_no_iv() {
let result = <Biometric as BiometricTrait>::derive_key_material(None).unwrap();
let key = base64_engine.decode(result.key_b64).unwrap();
@@ -221,38 +169,8 @@ mod tests {
assert!(<Biometric as BiometricTrait>::available().await.unwrap())
}
#[test]
fn test_encrypt() {
let key_material = KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
};
let iv_b64 = "l9fhDUP/wDJcKwmEzcb/3w==".to_owned();
let secret = encrypt("secret", &key_material, &iv_b64)
.unwrap()
.parse::<CipherString>()
.unwrap();
match secret {
CipherString::AesCbc256_B64 { iv, data: _ } => {
assert_eq!(iv_b64, base64_engine.encode(iv));
}
_ => panic!("Invalid cipher string"),
}
}
#[test]
fn test_decrypt() {
let secret =
CipherString::from_str("0.l9fhDUP/wDJcKwmEzcb/3w==|uP4LcqoCCj5FxBDP77NV6Q==").unwrap(); // output from test_encrypt
let key_material = KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
};
assert_eq!(decrypt(&secret, &key_material).unwrap(), "secret")
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_requires_key() {
let result = <Biometric as BiometricTrait>::get_biometric_secret("", "", None).await;
assert!(result.is_err());
@@ -263,6 +181,7 @@ mod tests {
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_handles_unencrypted_secret() {
let test = "test";
let secret = "password";
@@ -284,6 +203,7 @@ mod tests {
}
#[tokio::test]
#[cfg(feature = "manual_test")]
async fn get_biometric_secret_handles_encrypted_secret() {
let test = "test";
let secret =
@@ -316,61 +236,4 @@ mod tests {
"Key material is required for Windows Hello protected keys"
);
}
fn key_material() -> KeyMaterial {
KeyMaterial {
os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(),
client_key_part_b64: Some("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned()),
}
}
#[test]
fn key_material_produces_valid_key() {
let result = key_material().derive_key().unwrap();
assert_eq!(result.len(), 32);
}
#[test]
fn key_material_uses_os_part() {
let mut key_material = key_material();
let result = key_material.derive_key().unwrap();
key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned();
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
#[test]
fn key_material_uses_client_part() {
let mut key_material = key_material();
let result = key_material.derive_key().unwrap();
key_material.client_key_part_b64 =
Some("BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned());
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
#[test]
fn key_material_produces_consistent_os_only_key() {
let mut key_material = key_material();
key_material.client_key_part_b64 = None;
let result = key_material.derive_key().unwrap();
assert_eq!(
result,
[
81, 100, 62, 172, 151, 119, 182, 58, 123, 38, 129, 116, 209, 253, 66, 118, 218,
237, 236, 155, 201, 234, 11, 198, 229, 171, 246, 144, 71, 188, 84, 246
]
.into()
);
}
#[test]
fn key_material_produces_unique_os_only_key() {
let mut key_material = key_material();
key_material.client_key_part_b64 = None;
let result = key_material.derive_key().unwrap();
key_material.os_key_part_b64 = "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned();
let result2 = key_material.derive_key().unwrap();
assert_ne!(result, result2);
}
}

View File

@@ -240,7 +240,8 @@
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"rpm": {
"artifactName": "${productName}-${version}-${arch}.${ext}"
"artifactName": "${productName}-${version}-${arch}.${ext}",
"fpm": ["--rpm-rpmbuild-define", "_build_id_links none"]
},
"freebsd": {
"artifactName": "${productName}-${version}-${arch}.${ext}"

View File

@@ -10,7 +10,9 @@
"license": "GPL-3.0",
"dependencies": {
"@bitwarden/common": "file:../../../libs/common",
"@bitwarden/logging": "dist/libs/logging/src",
"@bitwarden/node": "file:../../../libs/node",
"@bitwarden/storage-core": "file:../../../libs/storage-core",
"module-alias": "2.2.3",
"ts-node": "10.9.2",
"uuid": "11.1.0",
@@ -31,14 +33,28 @@
"version": "0.0.0",
"license": "GPL-3.0"
},
"../../../libs/storage-core": {
"name": "@bitwarden/storage-core",
"version": "0.0.1",
"license": "GPL-3.0"
},
"dist/libs/logging/src": {},
"node_modules/@bitwarden/common": {
"resolved": "../../../libs/common",
"link": true
},
"node_modules/@bitwarden/logging": {
"resolved": "dist/libs/logging/src",
"link": true
},
"node_modules/@bitwarden/node": {
"resolved": "../../../libs/node",
"link": true
},
"node_modules/@bitwarden/storage-core": {
"resolved": "../../../libs/storage-core",
"link": true
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",

View File

@@ -16,6 +16,8 @@
"dependencies": {
"@bitwarden/common": "file:../../../libs/common",
"@bitwarden/node": "file:../../../libs/node",
"@bitwarden/storage-core": "file:../../../libs/storage-core",
"@bitwarden/logging": "dist/libs/logging/src",
"module-alias": "2.2.3",
"ts-node": "10.9.2",
"uuid": "11.1.0",
@@ -27,6 +29,8 @@
},
"_moduleAliases": {
"@bitwarden/common": "dist/libs/common/src",
"@bitwarden/node/services/node-crypto-function.service": "dist/libs/node/src/services/node-crypto-function.service"
"@bitwarden/node/services/node-crypto-function.service": "dist/libs/node/src/services/node-crypto-function.service",
"@bitwarden/storage-core": "dist/libs/storage-core/src",
"@bitwarden/logging": "dist/libs/logging/src"
}
}

View File

@@ -1,6 +1,6 @@
{
"extends": "../tsconfig",
"compilerOptions": {
"baseUrl": "./",
"outDir": "dist",
"target": "es6",
"module": "CommonJS",
@@ -10,7 +10,15 @@
"sourceMap": false,
"declaration": false,
"paths": {
"@src/*": ["src/*"]
"@src/*": ["src/*"],
"@bitwarden/user-core": ["../../../libs/user-core/src/index.ts"],
"@bitwarden/storage-core": ["../../../libs/storage-core/src/index.ts"],
"@bitwarden/logging": ["../../../libs/logging/src/index.ts"],
"@bitwarden/admin-console/*": ["../../../libs/admin-console/src/*"],
"@bitwarden/auth/*": ["../../../libs/auth/src/*"],
"@bitwarden/common/*": ["../../../libs/common/src/*"],
"@bitwarden/key-management": ["../../../libs/key-management/src/"],
"@bitwarden/node/*": ["../../../libs/node/src/*"]
},
"plugins": [
{

View File

@@ -126,13 +126,13 @@
{{ biometricText | i18n }}
</label>
</div>
<small class="help-block" *ngIf="this.form.value.biometric && !this.isLinux">{{
additionalBiometricSettingsText | i18n
<small class="help-block" *ngIf="this.form.value.biometric && this.isMac">{{
"additionalTouchIdSettings" | i18n
}}</small>
</div>
<div
class="form-group"
*ngIf="supportsBiometric && this.form.value.biometric && !this.isLinux"
*ngIf="supportsBiometric && this.form.value.biometric && this.isMac"
>
<div class="checkbox form-group-child">
<label for="autoPromptBiometrics">
@@ -142,7 +142,7 @@
formControlName="autoPromptBiometrics"
(change)="updateAutoPromptBiometrics()"
/>
{{ autoPromptBiometricsText | i18n }}
{{ "autoPromptTouchId" | i18n }}
</label>
</div>
</div>
@@ -152,7 +152,7 @@
supportsBiometric &&
this.form.value.biometric &&
(userHasMasterPassword || (this.form.value.pin && userHasPinSet)) &&
this.isWindows
false
"
>
<div class="checkbox form-group-child">
@@ -170,9 +170,6 @@
}
</label>
</div>
<small class="help-block form-group-child" *ngIf="isWindows">{{
"recommendedForSecurity" | i18n
}}</small>
</div>
</ng-container>
</div>

View File

@@ -271,74 +271,46 @@ describe("SettingsComponent", () => {
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(true);
});
it("require password or pin on app start message when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = false;
policyService.policiesByType$.mockReturnValue(of([policy]));
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
i18nService.t.mockImplementation((id: string) => {
if (id === "requirePasswordOnStart") {
return "Require password or pin on app start";
} else if (id === "requirePasswordWithoutPinOnStart") {
return "Require password on app start";
}
return "";
describe("windows desktop", () => {
beforeEach(() => {
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
// Recreate component to apply the correct device
fixture = TestBed.createComponent(SettingsComponent);
component = fixture.componentInstance;
});
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
it("require password or pin on app start not visible when RemoveUnlockWithPin policy is disabled and pin set and windows desktop", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = false;
policyService.policiesByType$.mockReturnValue(of([policy]));
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"),
);
expect(requirePasswordOnStartLabelElement).not.toBeNull();
expect(requirePasswordOnStartLabelElement.children).toHaveLength(1);
expect(requirePasswordOnStartLabelElement.children[0].name).toBe("input");
expect(requirePasswordOnStartLabelElement.children[0].attributes).toMatchObject({
id: "requirePasswordOnStart",
type: "checkbox",
await component.ngOnInit();
fixture.detectChanges();
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"),
);
expect(requirePasswordOnStartLabelElement).toBeNull();
});
const textNodes = requirePasswordOnStartLabelElement.childNodes
.filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE)
.map((node) => node.nativeNode.wholeText?.trim());
expect(textNodes).toContain("Require password or pin on app start");
});
it("require password on app start message when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = true;
policyService.policiesByType$.mockReturnValue(of([policy]));
platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
i18nService.t.mockImplementation((id: string) => {
if (id === "requirePasswordOnStart") {
return "Require password or pin on app start";
} else if (id === "requirePasswordWithoutPinOnStart") {
return "Require password on app start";
}
return "";
it("require password on app start not visible when RemoveUnlockWithPin policy is enabled and pin set and windows desktop", async () => {
const policy = new Policy();
policy.type = PolicyType.RemoveUnlockWithPin;
policy.enabled = true;
policyService.policiesByType$.mockReturnValue(of([policy]));
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"),
);
expect(requirePasswordOnStartLabelElement).toBeNull();
});
pinServiceAbstraction.isPinSet.mockResolvedValue(true);
await component.ngOnInit();
fixture.detectChanges();
const requirePasswordOnStartLabelElement = fixture.debugElement.query(
By.css("label[for='requirePasswordOnStart']"),
);
expect(requirePasswordOnStartLabelElement).not.toBeNull();
expect(requirePasswordOnStartLabelElement.children).toHaveLength(1);
expect(requirePasswordOnStartLabelElement.children[0].name).toBe("input");
expect(requirePasswordOnStartLabelElement.children[0].attributes).toMatchObject({
id: "requirePasswordOnStart",
type: "checkbox",
});
const textNodes = requirePasswordOnStartLabelElement.childNodes
.filter((node) => node.nativeNode.nodeType === Node.TEXT_NODE)
.map((node) => node.nativeNode.wholeText?.trim());
expect(textNodes).toContain("Require password on app start");
});
});

View File

@@ -78,6 +78,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
showOpenAtLoginOption = false;
isWindows: boolean;
isLinux: boolean;
isMac: boolean;
enableTrayText: string;
enableTrayDescText: string;
@@ -170,31 +171,33 @@ export class SettingsComponent implements OnInit, OnDestroy {
private configService: ConfigService,
private validationService: ValidationService,
) {
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
this.isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
this.isLinux = this.platformUtilsService.getDevice() === DeviceType.LinuxDesktop;
this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
// Workaround to avoid ghosting trays https://github.com/electron/electron/issues/17622
this.requireEnableTray = this.platformUtilsService.getDevice() === DeviceType.LinuxDesktop;
const trayKey = isMac ? "enableMenuBar" : "enableTray";
const trayKey = this.isMac ? "enableMenuBar" : "enableTray";
this.enableTrayText = this.i18nService.t(trayKey);
this.enableTrayDescText = this.i18nService.t(trayKey + "Desc");
const minToTrayKey = isMac ? "enableMinToMenuBar" : "enableMinToTray";
const minToTrayKey = this.isMac ? "enableMinToMenuBar" : "enableMinToTray";
this.enableMinToTrayText = this.i18nService.t(minToTrayKey);
this.enableMinToTrayDescText = this.i18nService.t(minToTrayKey + "Desc");
const closeToTrayKey = isMac ? "enableCloseToMenuBar" : "enableCloseToTray";
const closeToTrayKey = this.isMac ? "enableCloseToMenuBar" : "enableCloseToTray";
this.enableCloseToTrayText = this.i18nService.t(closeToTrayKey);
this.enableCloseToTrayDescText = this.i18nService.t(closeToTrayKey + "Desc");
const startToTrayKey = isMac ? "startToMenuBar" : "startToTray";
const startToTrayKey = this.isMac ? "startToMenuBar" : "startToTray";
this.startToTrayText = this.i18nService.t(startToTrayKey);
this.startToTrayDescText = this.i18nService.t(startToTrayKey + "Desc");
this.showOpenAtLoginOption = !ipc.platform.isWindowsStore;
// DuckDuckGo browser is only for macos initially
this.showDuckDuckGoIntegrationOption = isMac;
this.showDuckDuckGoIntegrationOption = this.isMac;
const localeOptions: any[] = [];
this.i18nService.supportedTranslationLocales.forEach((locale) => {
@@ -239,7 +242,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
this.isLinux = (await this.platformUtilsService.getDevice()) === DeviceType.LinuxDesktop;
// Autotype is for Windows initially
const isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
@@ -250,8 +252,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
this.currentUserEmail = activeAccount.email;
this.currentUserId = activeAccount.id;
@@ -911,28 +911,4 @@ export class SettingsComponent implements OnInit, OnDestroy {
throw new Error("Unsupported platform");
}
}
get autoPromptBiometricsText() {
switch (this.platformUtilsService.getDevice()) {
case DeviceType.MacOsDesktop:
return "autoPromptTouchId";
case DeviceType.WindowsDesktop:
return "autoPromptWindowsHello";
case DeviceType.LinuxDesktop:
return "autoPromptPolkit";
default:
throw new Error("Unsupported platform");
}
}
get additionalBiometricSettingsText() {
switch (this.platformUtilsService.getDevice()) {
case DeviceType.MacOsDesktop:
return "additionalTouchIdSettings";
case DeviceType.WindowsDesktop:
return "additionalWindowsHelloSettings";
default:
throw new Error("Unsupported platform");
}
}
}

View File

@@ -68,6 +68,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { DialogRef, DialogService, ToastOptions, ToastService } from "@bitwarden/components";
import { CredentialGeneratorHistoryDialogComponent } from "@bitwarden/generator-components";
import { KeyService, BiometricStateService } from "@bitwarden/key-management";
@@ -172,6 +173,7 @@ export class AppComponent implements OnInit, OnDestroy {
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private readonly destroyRef: DestroyRef,
private readonly documentLangSetter: DocumentLangSetter,
private restrictedItemTypesService: RestrictedItemTypesService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
@@ -523,10 +525,12 @@ export class AppComponent implements OnInit, OnDestroy {
private async updateAppMenu() {
let updateRequest: MenuUpdateRequest;
const stateAccounts = await firstValueFrom(this.accountService.accounts$);
if (stateAccounts == null || Object.keys(stateAccounts).length < 1) {
updateRequest = {
accounts: null,
activeUserId: null,
restrictedCipherTypes: null,
};
} else {
const accounts: { [userId: string]: MenuAccount } = {};
@@ -557,6 +561,9 @@ export class AppComponent implements OnInit, OnDestroy {
activeUserId: await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
),
restrictedCipherTypes: (
await firstValueFrom(this.restrictedItemTypesService.restricted$)
).map((restrictedItems) => restrictedItems.cipherType),
};
}

View File

@@ -34,6 +34,7 @@ const policyFileName = "com.bitwarden.Bitwarden.policy";
const policyPath = "/usr/share/polkit-1/actions/";
const SERVICE = "Bitwarden_biometric";
function getLookupKeyForUser(userId: UserId): string {
return `${userId}_user_biometric`;
}
@@ -45,16 +46,18 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
private cryptoFunctionService: CryptoFunctionService,
private logService: LogService,
) {}
private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access
private _osKeyHalf: string | null = null;
private clientKeyHalves = new Map<UserId, Uint8Array | null>();
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
const clientKeyPartB64 = Utils.fromBufferToB64(
await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key),
);
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: clientKeyHalf ? Utils.fromBufferToB64(clientKeyHalf) : undefined,
});
await biometrics.setBiometricSecret(
SERVICE,
getLookupKeyForUser(userId),
@@ -63,6 +66,7 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
storageDetails.ivB64,
);
}
async deleteBiometricKey(userId: UserId): Promise<void> {
try {
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId));
@@ -91,11 +95,15 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
if (value == null || value == "") {
return null;
} else {
const clientKeyHalf = this.clientKeyHalves.get(userId);
const clientKeyPartB64 = Utils.fromBufferToB64(clientKeyHalf);
let clientKeyPartB64: string | null = null;
if (this.clientKeyHalves.has(userId)) {
clientKeyPartB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!);
}
const encValue = new EncString(value);
this.setIv(encValue.iv);
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: clientKeyPartB64 ?? undefined,
});
const storedValue = await biometrics.getBiometricSecret(
SERVICE,
getLookupKeyForUser(userId),
@@ -169,7 +177,6 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
if (this._osKeyHalf == null) {
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
// osKeyHalf is based on the iv and in contrast to windows is not locked behind user verification!
this._osKeyHalf = keyMaterial.keyB64;
this._iv = keyMaterial.ivB64;
}
@@ -209,8 +216,8 @@ export default class OsBiometricsServiceLinux implements OsBiometricService {
}
if (clientKeyHalf == null) {
// Set a key half if it doesn't exist
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
const encKey = await this.encryptService.encryptBytes(keyBytes, key);
clientKeyHalf = await this.cryptoFunctionService.randomBytes(32);
const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
}

View File

@@ -1,51 +1,65 @@
import { randomBytes } from "node:crypto";
import { BrowserWindow } from "electron";
import { mock } from "jest-mock-extended";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { passwords } from "@bitwarden/desktop-napi";
import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
import { WindowMain } from "../../main/window.main";
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
jest.mock("@bitwarden/desktop-napi", () => ({
biometrics: {
available: jest.fn(),
setBiometricSecret: jest.fn(),
getBiometricSecret: jest.fn(),
deleteBiometricSecret: jest.fn(),
prompt: jest.fn(),
deriveKeyMaterial: jest.fn(),
},
passwords: {
getPassword: jest.fn(),
deletePassword: jest.fn(),
isAvailable: jest.fn(),
PASSWORD_NOT_FOUND: "Password not found",
},
}));
import OsDerivedKey = biometrics.OsDerivedKey;
jest.mock("@bitwarden/desktop-napi", () => {
return {
biometrics: {
available: jest.fn().mockResolvedValue(true),
getBiometricSecret: jest.fn().mockResolvedValue(""),
setBiometricSecret: jest.fn().mockResolvedValue(""),
deleteBiometricSecret: jest.fn(),
deriveKeyMaterial: jest.fn().mockResolvedValue({
keyB64: "",
ivB64: "",
}),
prompt: jest.fn().mockResolvedValue(true),
},
passwords: {
getPassword: jest.fn().mockResolvedValue(null),
deletePassword: jest.fn().mockImplementation(() => {}),
isAvailable: jest.fn(),
PASSWORD_NOT_FOUND: "Password not found",
},
};
});
describe("OsBiometricsServiceWindows", function () {
const i18nService = mock<I18nService>();
const windowMain = mock<WindowMain>();
const browserWindow = mock<BrowserWindow>();
const encryptionService: EncryptService = mock<EncryptService>();
const cryptoFunctionService: CryptoFunctionService = mock<CryptoFunctionService>();
const biometricStateService: BiometricStateService = mock<BiometricStateService>();
const logService = mock<LogService>();
describe("OsBiometricsServiceWindows", () => {
let service: OsBiometricsServiceWindows;
let i18nService: I18nService;
let windowMain: WindowMain;
let logService: LogService;
let biometricStateService: BiometricStateService;
const mockUserId = "test-user-id" as UserId;
const key = new SymmetricCryptoKey(new Uint8Array(64));
const userId = "test-user-id" as UserId;
const serviceKey = "Bitwarden_biometric";
const storageKey = `${userId}_user_biometric`;
beforeEach(() => {
i18nService = mock<I18nService>();
windowMain = mock<WindowMain>();
logService = mock<LogService>();
biometricStateService = mock<BiometricStateService>();
const encryptionService = mock<EncryptService>();
const cryptoFunctionService = mock<CryptoFunctionService>();
windowMain.win = browserWindow;
service = new OsBiometricsServiceWindows(
i18nService,
windowMain,
@@ -62,20 +76,13 @@ describe("OsBiometricsServiceWindows", () => {
describe("getBiometricsFirstUnlockStatusForUser", () => {
const userId = "test-user-id" as UserId;
it("should return Available when requirePasswordOnRestart is false", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(false);
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available);
});
it("should return Available when requirePasswordOnRestart is true and client key half is set", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
it("should return Available when client key half is set", async () => {
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
(service as any).clientKeyHalves.set(userId, new Uint8Array([1, 2, 3, 4]));
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.Available);
});
it("should return UnlockNeeded when requirePasswordOnRestart is true and client key half is not set", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
it("should return UnlockNeeded when client key half is not set", async () => {
(service as any).clientKeyHalves = new Map<string, Uint8Array>();
const result = await service.getBiometricsFirstUnlockStatusForUser(userId);
expect(result).toBe(BiometricsStatus.UnlockNeeded);
@@ -83,32 +90,7 @@ describe("OsBiometricsServiceWindows", () => {
});
describe("getOrCreateBiometricEncryptionClientKeyHalf", () => {
const userId = "test-user-id" as UserId;
const key = new SymmetricCryptoKey(new Uint8Array(64));
let encryptionService: EncryptService;
let cryptoFunctionService: CryptoFunctionService;
beforeEach(() => {
encryptionService = mock<EncryptService>();
cryptoFunctionService = mock<CryptoFunctionService>();
service = new OsBiometricsServiceWindows(
mock<I18nService>(),
windowMain,
mock<LogService>(),
biometricStateService,
encryptionService,
cryptoFunctionService,
);
});
it("should return null if getRequirePasswordOnRestart is false", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(false);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
expect(result).toBeNull();
});
it("should return cached key half if already present", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
const cachedKeyHalf = new Uint8Array([10, 20, 30]);
(service as any).clientKeyHalves.set(userId.toString(), cachedKeyHalf);
const result = await service.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
@@ -116,7 +98,6 @@ describe("OsBiometricsServiceWindows", () => {
});
it("should decrypt and return existing encrypted client key half", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
biometricStateService.getEncryptedClientKeyHalf = jest
.fn()
.mockResolvedValue(new Uint8Array([1, 2, 3]));
@@ -132,7 +113,6 @@ describe("OsBiometricsServiceWindows", () => {
});
it("should generate, encrypt, store, and cache a new key half if none exists", async () => {
biometricStateService.getRequirePasswordOnStart = jest.fn().mockResolvedValue(true);
biometricStateService.getEncryptedClientKeyHalf = jest.fn().mockResolvedValue(null);
const randomBytes = new Uint8Array([7, 8, 9]);
cryptoFunctionService.randomBytes = jest.fn().mockResolvedValue(randomBytes);
@@ -148,101 +128,251 @@ describe("OsBiometricsServiceWindows", () => {
encrypted,
userId,
);
expect(result).toBeNull();
expect((service as any).clientKeyHalves.get(userId.toString())).toBeNull();
expect(result).toEqual(randomBytes);
expect((service as any).clientKeyHalves.get(userId.toString())).toEqual(randomBytes);
});
});
describe("supportsBiometrics", () => {
it("should return true if biometrics are available", async () => {
biometrics.available = jest.fn().mockResolvedValue(true);
const result = await service.supportsBiometrics();
expect(result).toBe(true);
});
it("should return false if biometrics are not available", async () => {
biometrics.available = jest.fn().mockResolvedValue(false);
const result = await service.supportsBiometrics();
expect(result).toBe(false);
});
});
describe("getBiometricKey", () => {
beforeEach(() => {
biometrics.prompt = jest.fn().mockResolvedValue(true);
});
it("should return null when unsuccessfully authenticated biometrics", async () => {
biometrics.prompt = jest.fn().mockResolvedValue(false);
const result = await service.getBiometricKey(userId);
expect(result).toBeNull();
});
it.each([null, undefined, ""])(
"should throw error when no biometric key is found '%s'",
async (password) => {
passwords.getPassword = jest.fn().mockResolvedValue(password);
await expect(service.getBiometricKey(userId)).rejects.toThrow(
"Biometric key not found for user",
);
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
},
);
it.each([[false], [true]])(
"should return the biometricKey and setBiometricSecret called if password is not encrypted and cached clientKeyHalves is %s",
async (haveClientKeyHalves) => {
const clientKeyHalveBytes = new Uint8Array([1, 2, 3]);
if (haveClientKeyHalves) {
service["clientKeyHalves"].set(userId, clientKeyHalveBytes);
}
const biometricKey = key.toBase64();
passwords.getPassword = jest.fn().mockResolvedValue(biometricKey);
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({
keyB64: "testKeyB64",
ivB64: "testIvB64",
} satisfies OsDerivedKey);
const result = await service.getBiometricKey(userId);
expect(result.toBase64()).toBe(biometricKey);
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
expect(biometrics.setBiometricSecret).toHaveBeenCalledWith(
serviceKey,
storageKey,
biometricKey,
{
osKeyPartB64: "testKeyB64",
clientKeyPartB64: haveClientKeyHalves
? Utils.fromBufferToB64(clientKeyHalveBytes)
: undefined,
},
"testIvB64",
);
},
);
it.each([[false], [true]])(
"should return the biometricKey if password is encrypted and cached clientKeyHalves is %s",
async (haveClientKeyHalves) => {
const clientKeyHalveBytes = new Uint8Array([1, 2, 3]);
if (haveClientKeyHalves) {
service["clientKeyHalves"].set(userId, clientKeyHalveBytes);
}
const biometricKey = key.toBase64();
const biometricKeyEncrypted = "2.testId|data|mac";
passwords.getPassword = jest.fn().mockResolvedValue(biometricKeyEncrypted);
biometrics.getBiometricSecret = jest.fn().mockResolvedValue(biometricKey);
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue({
keyB64: "testKeyB64",
ivB64: "testIvB64",
} satisfies OsDerivedKey);
const result = await service.getBiometricKey(userId);
expect(result.toBase64()).toBe(biometricKey);
expect(passwords.getPassword).toHaveBeenCalledWith(serviceKey, storageKey);
expect(biometrics.setBiometricSecret).not.toHaveBeenCalled();
expect(biometrics.getBiometricSecret).toHaveBeenCalledWith(serviceKey, storageKey, {
osKeyPartB64: "testKeyB64",
clientKeyPartB64: haveClientKeyHalves
? Utils.fromBufferToB64(clientKeyHalveBytes)
: undefined,
});
},
);
});
describe("deleteBiometricKey", () => {
const serviceName = "Bitwarden_biometric";
const keyName = "test-user-id_user_biometric";
const witnessKeyName = "test-user-id_user_biometric_witness";
it("should delete biometric key successfully", async () => {
await service.deleteBiometricKey(mockUserId);
await service.deleteBiometricKey(userId);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName);
});
it.each([
[false, false],
[false, true],
[true, false],
])(
"should not throw error if key found: %s and witness key found: %s",
async (keyFound, witnessKeyFound) => {
passwords.deletePassword = jest.fn().mockImplementation((_, account) => {
if (account === keyName) {
if (!keyFound) {
throw new Error(passwords.PASSWORD_NOT_FOUND);
}
return Promise.resolve();
}
if (account === witnessKeyName) {
if (!witnessKeyFound) {
throw new Error(passwords.PASSWORD_NOT_FOUND);
}
return Promise.resolve();
}
throw new Error("Unexpected key");
});
it.each([[false], [true]])("should not throw error if key found: %s", async (keyFound) => {
if (!keyFound) {
passwords.deletePassword = jest
.fn()
.mockRejectedValue(new Error(passwords.PASSWORD_NOT_FOUND));
}
await service.deleteBiometricKey(mockUserId);
await service.deleteBiometricKey(userId);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName);
if (!keyFound) {
expect(logService.debug).toHaveBeenCalledWith(
"[OsBiometricService] Biometric key %s not found for service %s.",
keyName,
serviceName,
);
}
if (!witnessKeyFound) {
expect(logService.debug).toHaveBeenCalledWith(
"[OsBiometricService] Biometric witness key %s not found for service %s.",
witnessKeyName,
serviceName,
);
}
},
);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
if (!keyFound) {
expect(logService.debug).toHaveBeenCalledWith(
"[OsBiometricService] Biometric key %s not found for service %s.",
keyName,
serviceName,
);
}
});
it("should throw error when deletePassword for key throws unexpected errors", async () => {
const error = new Error("Unexpected error");
passwords.deletePassword = jest.fn().mockImplementation((_, account) => {
if (account === keyName) {
throw error;
}
if (account === witnessKeyName) {
return Promise.resolve();
}
throw new Error("Unexpected key");
});
passwords.deletePassword = jest.fn().mockRejectedValue(error);
await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error);
await expect(service.deleteBiometricKey(userId)).rejects.toThrow(error);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
expect(passwords.deletePassword).not.toHaveBeenCalledWith(serviceName, witnessKeyName);
});
});
describe("authenticateBiometric", () => {
const hwnd = randomBytes(32).buffer;
const consentMessage = "Test Windows Hello Consent Message";
beforeEach(() => {
windowMain.win.getNativeWindowHandle = jest.fn().mockReturnValue(hwnd);
i18nService.t.mockReturnValue(consentMessage);
});
it("should throw error when deletePassword for witness key throws unexpected errors", async () => {
const error = new Error("Unexpected error");
passwords.deletePassword = jest.fn().mockImplementation((_, account) => {
if (account === keyName) {
return Promise.resolve();
}
if (account === witnessKeyName) {
throw error;
}
throw new Error("Unexpected key");
});
it("should return true when biometric authentication is successful", async () => {
const result = await service.authenticateBiometric();
await expect(service.deleteBiometricKey(mockUserId)).rejects.toThrow(error);
expect(result).toBe(true);
expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage);
});
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, keyName);
expect(passwords.deletePassword).toHaveBeenCalledWith(serviceName, witnessKeyName);
it("should return false when biometric authentication fails", async () => {
biometrics.prompt = jest.fn().mockResolvedValue(false);
const result = await service.authenticateBiometric();
expect(result).toBe(false);
expect(biometrics.prompt).toHaveBeenCalledWith(hwnd, consentMessage);
});
});
describe("getStorageDetails", () => {
it.each([
["testClientKeyHalfB64", "testIvB64"],
[undefined, "testIvB64"],
["testClientKeyHalfB64", null],
[undefined, null],
])(
"should derive key material and ivB64 and return it when os key half not saved yet",
async (clientKeyHalfB64, ivB64) => {
service["setIv"](ivB64);
const derivedKeyMaterial = {
keyB64: "derivedKeyB64",
ivB64: "derivedIvB64",
};
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial);
const result = await service["getStorageDetails"]({ clientKeyHalfB64 });
expect(result).toEqual({
key_material: {
osKeyPartB64: derivedKeyMaterial.keyB64,
clientKeyPartB64: clientKeyHalfB64,
},
ivB64: derivedKeyMaterial.ivB64,
});
expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith(ivB64);
expect(service["_osKeyHalf"]).toEqual(derivedKeyMaterial.keyB64);
expect(service["_iv"]).toEqual(derivedKeyMaterial.ivB64);
},
);
it("should throw an error when deriving key material and returned iv is null", async () => {
service["setIv"]("testIvB64");
const derivedKeyMaterial = {
keyB64: "derivedKeyB64",
ivB64: null as string | undefined | null,
};
biometrics.deriveKeyMaterial = jest.fn().mockResolvedValue(derivedKeyMaterial);
await expect(
service["getStorageDetails"]({ clientKeyHalfB64: "testClientKeyHalfB64" }),
).rejects.toThrow("Initialization Vector is null");
expect(biometrics.deriveKeyMaterial).toHaveBeenCalledWith("testIvB64");
});
});
describe("setIv", () => {
it("should set the iv and reset the osKeyHalf", () => {
const iv = "testIv";
service["_osKeyHalf"] = "testOsKeyHalf";
service["setIv"](iv);
expect(service["_iv"]).toBe(iv);
expect(service["_osKeyHalf"]).toBeNull();
});
it("should set the iv to null when iv is undefined and reset the osKeyHalf", () => {
service["_osKeyHalf"] = "testOsKeyHalf";
service["setIv"](undefined);
expect(service["_iv"]).toBeNull();
expect(service["_osKeyHalf"]).toBeNull();
});
});
});

View File

@@ -3,7 +3,6 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
@@ -14,10 +13,8 @@ import { WindowMain } from "../../main/window.main";
import { OsBiometricService } from "./os-biometrics.service";
const KEY_WITNESS_SUFFIX = "_witness";
const WITNESS_VALUE = "known key";
const SERVICE = "Bitwarden_biometric";
function getLookupKeyForUser(userId: UserId): string {
return `${userId}_user_biometric`;
}
@@ -43,18 +40,25 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
}
async getBiometricKey(userId: UserId): Promise<SymmetricCryptoKey | null> {
const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId));
let clientKeyHalfB64: string | null = null;
if (this.clientKeyHalves.has(userId)) {
clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId));
const success = await this.authenticateBiometric();
if (!success) {
return null;
}
const value = await passwords.getPassword(SERVICE, getLookupKeyForUser(userId));
if (value == null || value == "") {
return null;
} else if (!EncString.isSerializedEncString(value)) {
throw new Error("Biometric key not found for user");
}
let clientKeyHalfB64: string | null = null;
if (this.clientKeyHalves.has(userId)) {
clientKeyHalfB64 = Utils.fromBufferToB64(this.clientKeyHalves.get(userId)!);
}
if (!EncString.isSerializedEncString(value)) {
// Update to format encrypted with client key half
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: clientKeyHalfB64,
clientKeyHalfB64: clientKeyHalfB64 ?? undefined,
});
await biometrics.setBiometricSecret(
@@ -69,7 +73,7 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
const encValue = new EncString(value);
this.setIv(encValue.iv);
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: clientKeyHalfB64,
clientKeyHalfB64: clientKeyHalfB64 ?? undefined,
});
return SymmetricCryptoKey.fromString(
await biometrics.getBiometricSecret(
@@ -84,35 +88,16 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
async setBiometricKey(userId: UserId, key: SymmetricCryptoKey): Promise<void> {
const clientKeyHalf = await this.getOrCreateBiometricEncryptionClientKeyHalf(userId, key);
if (
await this.valueUpToDate({
value: key,
clientKeyPartB64: Utils.fromBufferToB64(clientKeyHalf),
service: SERVICE,
storageKey: getLookupKeyForUser(userId),
})
) {
return;
}
const storageDetails = await this.getStorageDetails({
clientKeyHalfB64: Utils.fromBufferToB64(clientKeyHalf),
});
const storedValue = await biometrics.setBiometricSecret(
await biometrics.setBiometricSecret(
SERVICE,
getLookupKeyForUser(userId),
key.toBase64(),
storageDetails.key_material,
storageDetails.ivB64,
);
const parsedStoredValue = new EncString(storedValue);
await this.storeValueWitness(
key,
parsedStoredValue,
SERVICE,
getLookupKeyForUser(userId),
Utils.fromBufferToB64(clientKeyHalf),
);
}
async deleteBiometricKey(userId: UserId): Promise<void> {
@@ -129,21 +114,11 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
throw e;
}
}
try {
await passwords.deletePassword(SERVICE, getLookupKeyForUser(userId) + KEY_WITNESS_SUFFIX);
} catch (e) {
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
this.logService.debug(
"[OsBiometricService] Biometric witness key %s not found for service %s.",
getLookupKeyForUser(userId) + KEY_WITNESS_SUFFIX,
SERVICE,
);
} else {
throw e;
}
}
}
/**
* Prompts Windows Hello
*/
async authenticateBiometric(): Promise<boolean> {
const hwnd = this.windowMain.win.getNativeWindowHandle();
return await biometrics.prompt(hwnd, this.i18nService.t("windowsHelloConsentMessage"));
@@ -155,7 +130,6 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
clientKeyHalfB64: string | undefined;
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
if (this._osKeyHalf == null) {
// Prompts Windows Hello
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
this._osKeyHalf = keyMaterial.keyB64;
this._iv = keyMaterial.ivB64;
@@ -187,118 +161,6 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
this._osKeyHalf = null;
}
/**
* Stores a witness key alongside the encrypted value. This is used to determine if the value is up to date.
*
* @param unencryptedValue The key to store
* @param encryptedValue The encrypted value of the key to store. Used to sync IV of the witness key with the stored key.
* @param service The service to store the witness key under
* @param storageKey The key to store the witness key under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX}
* @returns
*/
private async storeValueWitness(
unencryptedValue: SymmetricCryptoKey,
encryptedValue: EncString,
service: string,
storageKey: string,
clientKeyPartB64: string | undefined,
) {
if (encryptedValue.iv == null) {
return;
}
const storageDetails = {
keyMaterial: this.witnessKeyMaterial(unencryptedValue, clientKeyPartB64),
ivB64: encryptedValue.iv,
};
await biometrics.setBiometricSecret(
service,
storageKey + KEY_WITNESS_SUFFIX,
WITNESS_VALUE,
storageDetails.keyMaterial,
storageDetails.ivB64,
);
}
/**
* Uses a witness key stored alongside the encrypted value to determine if the value is up to date.
* @param value The value being validated
* @param service The service the value is stored under
* @param storageKey The key the value is stored under. The witness key will be stored under storageKey + {@link KEY_WITNESS_SUFFIX}
* @returns Boolean indicating if the value is up to date.
*/
// Uses a witness key stored alongside the encrypted value to determine if the value is up to date.
private async valueUpToDate({
value,
clientKeyPartB64,
service,
storageKey,
}: {
value: SymmetricCryptoKey;
clientKeyPartB64: string | undefined;
service: string;
storageKey: string;
}): Promise<boolean> {
const witnessKeyMaterial = this.witnessKeyMaterial(value, clientKeyPartB64);
if (witnessKeyMaterial == null) {
return false;
}
let witness = null;
try {
witness = await biometrics.getBiometricSecret(
service,
storageKey + KEY_WITNESS_SUFFIX,
witnessKeyMaterial,
);
} catch (e) {
if (e instanceof Error && e.message === passwords.PASSWORD_NOT_FOUND) {
this.logService.debug(
"[OsBiometricService] Biometric witness key %s not found for service %s, value is not up to date.",
storageKey + KEY_WITNESS_SUFFIX,
service,
);
} else {
this.logService.error(
"[OsBiometricService] Error retrieving witness key, assuming value is not up to date.",
e,
);
}
return false;
}
if (witness === WITNESS_VALUE) {
return true;
}
return false;
}
/** Derives a witness key from a symmetric key being stored for biometric protection */
private witnessKeyMaterial(
symmetricKey: SymmetricCryptoKey,
clientKeyPartB64: string | undefined,
): biometrics.KeyMaterial {
let key = null;
const innerKey = symmetricKey.inner();
if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
key = Utils.fromBufferToB64(innerKey.authenticationKey);
} else {
key = Utils.fromBufferToB64(innerKey.encryptionKey);
}
const result = {
osKeyPartB64: key,
clientKeyPartB64,
};
// napi-rs fails to convert null values
if (result.clientKeyPartB64 == null) {
delete result.clientKeyPartB64;
}
return result;
}
async needsSetup() {
return false;
}
@@ -312,14 +174,9 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
async getOrCreateBiometricEncryptionClientKeyHalf(
userId: UserId,
key: SymmetricCryptoKey,
): Promise<Uint8Array | null> {
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
if (!requireClientKeyHalf) {
return null;
}
): Promise<Uint8Array> {
if (this.clientKeyHalves.has(userId)) {
return this.clientKeyHalves.get(userId);
return this.clientKeyHalves.get(userId)!;
}
// Retrieve existing key half if it exists
@@ -331,8 +188,8 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
}
if (clientKeyHalf == null) {
// Set a key half if it doesn't exist
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
const encKey = await this.encryptService.encryptBytes(keyBytes, key);
clientKeyHalf = await this.cryptoFunctionService.randomBytes(32);
const encKey = await this.encryptService.encryptBytes(clientKeyHalf, key);
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
}
@@ -342,11 +199,6 @@ export default class OsBiometricsServiceWindows implements OsBiometricService {
}
async getBiometricsFirstUnlockStatusForUser(userId: UserId): Promise<BiometricsStatus> {
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
if (!requireClientKeyHalf) {
return BiometricsStatus.Available;
}
if (this.clientKeyHalves.has(userId)) {
return BiometricsStatus.Available;
} else {

View File

@@ -41,6 +41,9 @@
"searchVault": {
"message": "Search vault"
},
"resetSearch": {
"message": "Reset search"
},
"addItem": {
"message": "Add item"
},
@@ -1813,9 +1816,6 @@
"unlockWithWindowsHello": {
"message": "Unlock with Windows Hello"
},
"additionalWindowsHelloSettings": {
"message": "Additional Windows Hello settings"
},
"unlockWithPolkit": {
"message": "Unlock with system authentication"
},
@@ -1831,12 +1831,6 @@
"touchIdConsentMessage": {
"message": "unlock your vault"
},
"autoPromptWindowsHello": {
"message": "Ask for Windows Hello on app start"
},
"autoPromptPolkit": {
"message": "Ask for system authentication on launch"
},
"autoPromptTouchId": {
"message": "Ask for Touch ID on app start"
},
@@ -1846,9 +1840,6 @@
"requirePasswordWithoutPinOnStart": {
"message": "Require password on app start"
},
"recommendedForSecurity": {
"message": "Recommended for security."
},
"lockWithMasterPassOnRestart1": {
"message": "Lock with master password on restart"
},

View File

@@ -201,6 +201,16 @@ export class Main {
this.logService,
true,
);
this.windowMain = new WindowMain(
biometricStateService,
this.logService,
this.storageService,
this.desktopSettingsService,
(arg) => this.processDeepLink(arg),
(win) => this.trayMain.setupWindowListeners(win),
);
this.biometricsService = new MainBiometricsService(
this.i18nService,
this.windowMain,
@@ -211,14 +221,6 @@ export class Main {
this.mainCryptoFunctionService,
);
this.windowMain = new WindowMain(
biometricStateService,
this.logService,
this.storageService,
this.desktopSettingsService,
(arg) => this.processDeepLink(arg),
(win) => this.trayMain.setupWindowListeners(win),
);
this.messagingMain = new MessagingMain(this, this.desktopSettingsService);
this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain);

View File

@@ -2,6 +2,7 @@ import { BrowserWindow, MenuItemConstructorOptions } from "electron";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CipherType } from "@bitwarden/sdk-internal";
import { isMac, isMacAppStore } from "../../utils";
import { UpdaterMain } from "../updater.main";
@@ -54,6 +55,7 @@ export class FileMenu extends FirstMenu implements IMenubarMenu {
accounts: { [userId: string]: MenuAccount },
isLocked: boolean,
isLockable: boolean,
private restrictedCipherTypes: CipherType[],
) {
super(i18nService, messagingService, updater, window, accounts, isLocked, isLockable);
}
@@ -77,6 +79,23 @@ export class FileMenu extends FirstMenu implements IMenubarMenu {
};
}
private mapMenuItemToCipherType(itemId: string): CipherType {
switch (itemId) {
case "typeLogin":
return CipherType.Login;
case "typeCard":
return CipherType.Card;
case "typeIdentity":
return CipherType.Identity;
case "typeSecureNote":
return CipherType.SecureNote;
case "typeSshKey":
return CipherType.SshKey;
default:
throw new Error(`Unknown menu item id: ${itemId}`);
}
}
private get addNewItemSubmenu(): MenuItemConstructorOptions[] {
return [
{
@@ -109,7 +128,11 @@ export class FileMenu extends FirstMenu implements IMenubarMenu {
click: () => this.sendMessage("newSshKey"),
accelerator: "CmdOrCtrl+Shift+K",
},
];
].filter((item) => {
return !this.restrictedCipherTypes?.some(
(restrictedType) => restrictedType === this.mapMenuItemToCipherType(item.id),
);
});
}
private get addNewFolder(): MenuItemConstructorOptions {

View File

@@ -1,8 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CipherType } from "@bitwarden/common/vault/enums";
export class MenuUpdateRequest {
activeUserId: string;
accounts: { [userId: string]: MenuAccount };
activeUserId: string | null;
accounts: { [userId: string]: MenuAccount } | null;
restrictedCipherTypes: CipherType[] | null;
}
export class MenuAccount {

View File

@@ -83,6 +83,7 @@ export class Menubar {
updateRequest?.accounts,
isLocked,
isLockable,
updateRequest?.restrictedCipherTypes,
),
new EditMenu(i18nService, messagingService, isLocked),
new ViewMenu(i18nService, messagingService, isLocked),

View File

@@ -3,5 +3,6 @@
"angularCompilerOptions": {
"strictTemplates": true
},
"include": ["src", "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts"]
"include": ["src", "../../libs/common/src/key-management/crypto/services/encrypt.worker.ts"],
"exclude": ["src/**/*.spec.ts"]
}

View File

@@ -82,5 +82,7 @@ function cloneCollection(
cloned.organizationId = collection.organizationId;
cloned.readOnly = collection.readOnly;
cloned.manage = collection.manage;
cloned.type = collection.type;
return cloned;
}

View File

@@ -10,6 +10,7 @@ import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstract
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { DialogService, ToastService } from "@bitwarden/components";
@@ -53,6 +54,7 @@ export class VaultFilterComponent
protected configService: ConfigService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
) {
super(
vaultFilterService,
@@ -65,6 +67,7 @@ export class VaultFilterComponent
configService,
accountService,
restrictedItemTypesService,
cipherService,
);
}
@@ -131,7 +134,7 @@ export class VaultFilterComponent
async buildAllFilters(): Promise<VaultFilterList> {
const builderFilter = {} as VaultFilterList;
builderFilter.typeFilter = await this.addTypeFilter(["favorites"]);
builderFilter.typeFilter = await this.addTypeFilter(["favorites"], this._organization?.id);
builderFilter.collectionFilter = await this.addCollectionFilter();
builderFilter.trashFilter = await this.addTrashFilter();
return builderFilter;

View File

@@ -1,3 +1,8 @@
<app-organization-free-trial-warning
[organization]="organization"
(clicked)="navigateToPaymentMethod()"
>
</app-organization-free-trial-warning>
<app-header>
<bit-search
class="tw-grow"

View File

@@ -13,6 +13,7 @@ import {
Observable,
shareReplay,
switchMap,
tap,
} from "rxjs";
import {
@@ -61,6 +62,7 @@ import {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "../../../billing/organizations/change-plan-dialog.component";
import { OrganizationWarningsService } from "../../../billing/warnings/services";
import { BaseMembersComponent } from "../../common/base-members.component";
import { PeopleTableDataSource } from "../../common/people-table-data-source";
import { GroupApiService } from "../core";
@@ -148,6 +150,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private configService: ConfigService,
private organizationUserService: OrganizationUserService,
private organizationWarningsService: OrganizationWarningsService,
) {
super(
apiService,
@@ -247,6 +250,13 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.showUserManagementControls$ = organization$.pipe(
map((organization) => organization.canManageUsers),
);
organization$
.pipe(
takeUntilDestroyed(),
tap((org) => (this.organization = org)),
switchMap((org) => this.organizationWarningsService.showInactiveSubscriptionDialog$(org)),
)
.subscribe();
}
async getUsers(): Promise<OrganizationUserView[]> {
@@ -932,4 +942,14 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
.getCheckedUsers()
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
}
async navigateToPaymentMethod() {
const managePaymentDetailsOutsideCheckout = await this.configService.getFeatureFlag(
FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout,
);
const route = managePaymentDetailsOutsideCheckout ? "payment-details" : "payment-method";
await this.router.navigate(["organizations", `${this.organization?.id}`, "billing", route], {
state: { launchPaymentModalAutomatically: true },
});
}
}

View File

@@ -5,6 +5,7 @@ import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-s
import { PasswordCalloutComponent } from "@bitwarden/auth/angular";
import { ScrollLayoutDirective } from "@bitwarden/components";
import { OrganizationFreeTrialWarningComponent } from "../../../billing/warnings/components";
import { LooseComponentsModule } from "../../../shared";
import { SharedOrganizationModule } from "../shared";
@@ -29,6 +30,7 @@ import { MembersComponent } from "./members.component";
ScrollingModule,
PasswordStrengthV2Component,
ScrollLayoutDirective,
OrganizationFreeTrialWarningComponent,
],
declarations: [
BulkConfirmDialogComponent,

View File

@@ -12,7 +12,6 @@ import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-a
import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
@@ -30,7 +29,6 @@ describe("WebRegistrationFinishService", () => {
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
let logService: MockProxy<LogService>;
let policyService: MockProxy<PolicyService>;
let configService: MockProxy<ConfigService>;
beforeEach(() => {
keyService = mock<KeyService>();
@@ -39,7 +37,6 @@ describe("WebRegistrationFinishService", () => {
policyApiService = mock<PolicyApiServiceAbstraction>();
logService = mock<LogService>();
policyService = mock<PolicyService>();
configService = mock<ConfigService>();
service = new WebRegistrationFinishService(
keyService,
@@ -48,7 +45,6 @@ describe("WebRegistrationFinishService", () => {
policyApiService,
logService,
policyService,
configService,
);
});
@@ -414,22 +410,4 @@ describe("WebRegistrationFinishService", () => {
);
});
});
describe("determineLoginSuccessRoute", () => {
it("returns /setup-extension when the end user activation feature flag is enabled", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
const result = await service.determineLoginSuccessRoute();
expect(result).toBe("/setup-extension");
});
it("returns /vault when the end user activation feature flag is disabled", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
const result = await service.determineLoginSuccessRoute();
expect(result).toBe("/vault");
});
});
});

View File

@@ -14,12 +14,10 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import {
EncryptedString,
EncString,
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { KeyService } from "@bitwarden/key-management";
@@ -34,7 +32,6 @@ export class WebRegistrationFinishService
private policyApiService: PolicyApiServiceAbstraction,
private logService: LogService,
private policyService: PolicyService,
private configService: ConfigService,
) {
super(keyService, accountApiService);
}
@@ -79,18 +76,6 @@ export class WebRegistrationFinishService
return masterPasswordPolicyOpts;
}
override async determineLoginSuccessRoute(): Promise<string> {
const endUserActivationFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM19315EndUserActivationMvp,
);
if (endUserActivationFlagEnabled) {
return "/setup-extension";
} else {
return super.determineLoginSuccessRoute();
}
}
// Note: the org invite token and email verification are mutually exclusive. Only one will be present.
override async buildRegisterRequest(
email: string,

View File

@@ -36,6 +36,10 @@ import {
AdjustPaymentDialogComponent,
AdjustPaymentDialogResultType,
} from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component";
import {
TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE,
TrialPaymentDialogComponent,
} from "../../shared/trial-payment-dialog/trial-payment-dialog.component";
import { FreeTrial } from "../../types/free-trial";
@Component({
@@ -212,15 +216,15 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
};
changePayment = async () => {
const dialogRef = AdjustPaymentDialogComponent.open(this.dialogService, {
const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, {
data: {
initialPaymentMethod: this.paymentSource?.type,
organizationId: this.organizationId,
productTier: this.organization?.productTierType,
subscription: this.organizationSubscriptionResponse,
productTierType: this.organization?.productTierType,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === AdjustPaymentDialogResultType.Submitted) {
if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) {
this.location.replaceState(this.location.path(), "", {});
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
await this.syncService.fullSync(true);

View File

@@ -28,7 +28,11 @@ type DialogResult =
{{ "changePaymentMethod" | i18n }}
</span>
<div bitDialogContent>
<app-enter-payment-method [group]="formGroup" [includeBillingAddress]="true">
<app-enter-payment-method
[group]="formGroup"
[showBankAccount]="dialogParams.owner.type !== 'account'"
[includeBillingAddress]="true"
>
</app-enter-payment-method>
</div>
<ng-container bitDialogFooter>

View File

@@ -1,6 +1,6 @@
import { Component, Input, OnInit } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { BehaviorSubject, startWith, Subject, takeUntil } from "rxjs";
import { map, Observable, of, startWith, Subject, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -48,7 +48,7 @@ type PaymentMethodFormGroup = FormGroup<{
{{ "creditCard" | i18n }}
</bit-label>
</bit-radio-button>
@if (showBankAccount) {
@if (showBankAccount$ | async) {
<bit-radio-button id="bank-payment-method" [value]="'bankAccount'">
<bit-label>
<i class="bwi bwi-fw bwi-billing" aria-hidden="true"></i>
@@ -226,20 +226,12 @@ type PaymentMethodFormGroup = FormGroup<{
export class EnterPaymentMethodComponent implements OnInit {
@Input({ required: true }) group!: PaymentMethodFormGroup;
private showBankAccountSubject = new BehaviorSubject<boolean>(true);
showBankAccount$ = this.showBankAccountSubject.asObservable();
@Input()
set showBankAccount(value: boolean) {
this.showBankAccountSubject.next(value);
}
get showBankAccount(): boolean {
return this.showBankAccountSubject.value;
}
@Input() showPayPal: boolean = true;
@Input() showAccountCredit: boolean = false;
@Input() includeBillingAddress: boolean = false;
@Input() private showBankAccount = true;
@Input() showPayPal = true;
@Input() showAccountCredit = false;
@Input() includeBillingAddress = false;
protected showBankAccount$!: Observable<boolean>;
protected selectableCountries = selectableCountries;
private destroy$ = new Subject<void>();
@@ -267,7 +259,16 @@ export class EnterPaymentMethodComponent implements OnInit {
}
if (!this.includeBillingAddress) {
this.showBankAccount$ = of(this.showBankAccount);
this.group.controls.billingAddress.disable();
} else {
this.group.controls.billingAddress.patchValue({
country: "US",
});
this.showBankAccount$ = this.group.controls.billingAddress.controls.country.valueChanges.pipe(
startWith(this.group.controls.billingAddress.controls.country.value),
map((country) => this.showBankAccount && country === "US"),
);
}
this.group.controls.type.valueChanges

View File

@@ -0,0 +1,59 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
@Injectable({ providedIn: "root" })
export class PlanCardService {
constructor(private apiService: ApiService) {}
async getCadenceCards(
currentPlan: PlanResponse,
subscription: OrganizationSubscriptionResponse,
isSecretsManagerTrial: boolean,
) {
const plans = await this.apiService.getPlans();
const filteredPlans = plans.data.filter((plan) => !!plan.PasswordManager);
const result =
filteredPlans?.filter(
(plan) =>
plan.productTier === currentPlan.productTier && !plan.disabled && !plan.legacyYear,
) || [];
const planCards = result.map((plan) => {
let costPerMember = 0;
if (plan.PasswordManager.basePrice) {
costPerMember = plan.isAnnual
? plan.PasswordManager.basePrice / 12
: plan.PasswordManager.basePrice;
} else if (!plan.PasswordManager.basePrice && plan.PasswordManager.hasAdditionalSeatsOption) {
const secretsManagerCost = subscription.useSecretsManager
? plan.SecretsManager.seatPrice
: 0;
const passwordManagerCost = isSecretsManagerTrial ? 0 : plan.PasswordManager.seatPrice;
costPerMember = (secretsManagerCost + passwordManagerCost) / (plan.isAnnual ? 12 : 1);
}
const percentOff = subscription.customerDiscount?.percentOff ?? 0;
const discount =
(percentOff === 0 && plan.isAnnual) || isSecretsManagerTrial ? 20 : percentOff;
return {
title: plan.isAnnual ? "Annually" : "Monthly",
costPerMember,
discount,
isDisabled: false,
isSelected: plan.isAnnual,
isAnnual: plan.isAnnual,
productTier: plan.productTier,
};
});
return planCards.reverse();
}
}

View File

@@ -0,0 +1,155 @@
import { Injectable } from "@angular/core";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain/tax-information";
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { PricingSummaryData } from "../shared/pricing-summary/pricing-summary.component";
@Injectable({
providedIn: "root",
})
export class PricingSummaryService {
private estimatedTax: number = 0;
constructor(private taxService: TaxServiceAbstraction) {}
async getPricingSummaryData(
plan: PlanResponse,
sub: OrganizationSubscriptionResponse,
organization: Organization,
selectedInterval: PlanInterval,
taxInformation: TaxInformation,
isSecretsManagerTrial: boolean,
): Promise<PricingSummaryData> {
// Calculation helpers
const passwordManagerSeatTotal =
plan.PasswordManager?.hasAdditionalSeatsOption && !isSecretsManagerTrial
? plan.PasswordManager.seatPrice * Math.abs(sub?.seats || 0)
: 0;
const secretsManagerSeatTotal = plan.SecretsManager?.hasAdditionalSeatsOption
? plan.SecretsManager.seatPrice * Math.abs(sub?.smSeats || 0)
: 0;
const additionalServiceAccount = this.getAdditionalServiceAccount(plan, sub);
const additionalStorageTotal = plan.PasswordManager?.hasAdditionalStorageOption
? plan.PasswordManager.additionalStoragePricePerGb *
(sub?.maxStorageGb ? sub.maxStorageGb - 1 : 0)
: 0;
const additionalStoragePriceMonthly = plan.PasswordManager?.additionalStoragePricePerGb || 0;
const additionalServiceAccountTotal =
plan.SecretsManager?.hasAdditionalServiceAccountOption && additionalServiceAccount > 0
? plan.SecretsManager.additionalPricePerServiceAccount * additionalServiceAccount
: 0;
let passwordManagerSubtotal = plan.PasswordManager?.basePrice || 0;
if (plan.PasswordManager?.hasAdditionalSeatsOption) {
passwordManagerSubtotal += passwordManagerSeatTotal;
}
if (plan.PasswordManager?.hasPremiumAccessOption) {
passwordManagerSubtotal += plan.PasswordManager.premiumAccessOptionPrice;
}
const secretsManagerSubtotal = plan.SecretsManager
? (plan.SecretsManager.basePrice || 0) +
secretsManagerSeatTotal +
additionalServiceAccountTotal
: 0;
const totalAppliedDiscount = 0;
const discountPercentageFromSub = isSecretsManagerTrial
? 0
: (sub?.customerDiscount?.percentOff ?? 0);
const discountPercentage = 20;
const acceptingSponsorship = false;
const storageGb = sub?.maxStorageGb ? sub?.maxStorageGb - 1 : 0;
this.estimatedTax = await this.getEstimatedTax(organization, plan, sub, taxInformation);
const total = organization?.useSecretsManager
? passwordManagerSubtotal +
additionalStorageTotal +
secretsManagerSubtotal +
this.estimatedTax
: passwordManagerSubtotal + additionalStorageTotal + this.estimatedTax;
return {
selectedPlanInterval: selectedInterval === PlanInterval.Annually ? "year" : "month",
passwordManagerSeats:
plan.productTier === ProductTierType.Families ? plan.PasswordManager.baseSeats : sub?.seats,
passwordManagerSeatTotal,
secretsManagerSeatTotal,
additionalStorageTotal,
additionalStoragePriceMonthly,
additionalServiceAccountTotal,
totalAppliedDiscount,
secretsManagerSubtotal,
passwordManagerSubtotal,
total,
organization,
sub,
selectedPlan: plan,
selectedInterval,
discountPercentageFromSub,
discountPercentage,
acceptingSponsorship,
additionalServiceAccount,
storageGb,
isSecretsManagerTrial,
estimatedTax: this.estimatedTax,
};
}
async getEstimatedTax(
organization: Organization,
currentPlan: PlanResponse,
sub: OrganizationSubscriptionResponse,
taxInformation: TaxInformation,
) {
if (!taxInformation || !taxInformation.country || !taxInformation.postalCode) {
return 0;
}
const request: PreviewOrganizationInvoiceRequest = {
organizationId: organization.id,
passwordManager: {
additionalStorage: 0,
plan: currentPlan?.type,
seats: sub.seats,
},
taxInformation: {
postalCode: taxInformation.postalCode,
country: taxInformation.country,
taxId: taxInformation.taxId,
},
};
if (organization.useSecretsManager) {
request.secretsManager = {
seats: sub.smSeats ?? 0,
additionalMachineAccounts:
(sub.smServiceAccounts ?? 0) - (sub.plan.SecretsManager?.baseServiceAccount ?? 0),
};
}
const invoiceResponse = await this.taxService.previewOrganizationInvoice(request);
return invoiceResponse.taxAmount;
}
getAdditionalServiceAccount(plan: PlanResponse, sub: OrganizationSubscriptionResponse): number {
if (!plan || !plan.SecretsManager) {
return 0;
}
const baseServiceAccount = plan.SecretsManager?.baseServiceAccount || 0;
const usedServiceAccounts = sub?.smServiceAccounts || 0;
const additionalServiceAccounts = baseServiceAccount - usedServiceAccounts;
return additionalServiceAccounts <= 0 ? Math.abs(additionalServiceAccounts) : 0;
}
}

View File

@@ -28,7 +28,7 @@
>
<bit-option
[disabled]="true"
[value]=""
[value]="null"
[label]="'--' + ('select' | i18n) + '--'"
></bit-option>
<bit-option

View File

@@ -12,10 +12,13 @@ import { BillingHistoryComponent } from "./billing-history.component";
import { OffboardingSurveyComponent } from "./offboarding-survey.component";
import { PaymentComponent } from "./payment/payment.component";
import { PaymentMethodComponent } from "./payment-method.component";
import { PlanCardComponent } from "./plan-card/plan-card.component";
import { PricingSummaryComponent } from "./pricing-summary/pricing-summary.component";
import { IndividualSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/individual-self-hosting-license-uploader.component";
import { OrganizationSelfHostingLicenseUploaderComponent } from "./self-hosting-license-uploader/organization-self-hosting-license-uploader.component";
import { SecretsManagerSubscribeComponent } from "./sm-subscribe.component";
import { TaxInfoComponent } from "./tax-info.component";
import { TrialPaymentDialogComponent } from "./trial-payment-dialog/trial-payment-dialog.component";
import { UpdateLicenseDialogComponent } from "./update-license-dialog.component";
import { UpdateLicenseComponent } from "./update-license.component";
import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-account.component";
@@ -41,6 +44,9 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
AdjustStorageDialogComponent,
IndividualSelfHostingLicenseUploaderComponent,
OrganizationSelfHostingLicenseUploaderComponent,
TrialPaymentDialogComponent,
PlanCardComponent,
PricingSummaryComponent,
],
exports: [
SharedModule,

View File

@@ -0,0 +1,45 @@
@let isFocused = plan().isSelected;
@let isRecommended = plan().isAnnual;
<bit-card
class="tw-h-full"
[ngClass]="getPlanCardContainerClasses()"
(click)="cardClicked.emit()"
[attr.tabindex]="!isFocused || plan().isDisabled ? '-1' : '0'"
[attr.data-selected]="plan()?.isSelected"
>
<div class="tw-relative">
@if (isRecommended) {
<div
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1"
[ngClass]="{
'tw-bg-primary-700 !tw-text-contrast': plan().isSelected,
'tw-bg-secondary-100': !plan().isSelected,
}"
>
{{ "recommended" | i18n }}
</div>
}
<div
class="tw-px-2 tw-pb-[4px]"
[ngClass]="{
'tw-py-1': !plan().isSelected,
'tw-py-0': plan().isSelected,
}"
>
<h3
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
>
<span class="tw-capitalize tw-whitespace-nowrap">{{ plan().title }}</span>
<!-- Discount Badge -->
<span class="tw-mr-1 tw-ml-2" *ngIf="isRecommended" bitBadge variant="success">
{{ "upgradeDiscount" | i18n: plan().discount }}</span
>
</h3>
<span>
<b class="tw-text-lg tw-font-semibold">{{ plan().costPerMember | currency: "$" }} </b>
<span class="tw-text-xs tw-px-0"> /{{ "monthPerMember" | i18n }}</span>
</span>
</div>
</div>
</bit-card>

View File

@@ -0,0 +1,68 @@
import { Component, input, output } from "@angular/core";
import { ProductTierType } from "@bitwarden/common/billing/enums";
export interface PlanCard {
title: string;
costPerMember: number;
discount?: number;
isDisabled: boolean;
isAnnual: boolean;
isSelected: boolean;
productTier: ProductTierType;
}
@Component({
selector: "app-plan-card",
templateUrl: "./plan-card.component.html",
standalone: false,
})
export class PlanCardComponent {
plan = input.required<PlanCard>();
productTiers = ProductTierType;
cardClicked = output();
getPlanCardContainerClasses(): string[] {
const isSelected = this.plan().isSelected;
const isDisabled = this.plan().isDisabled;
if (isDisabled) {
return [
"tw-cursor-not-allowed",
"tw-bg-secondary-100",
"tw-font-normal",
"tw-bg-blur",
"tw-text-muted",
"tw-block",
"tw-rounded",
];
}
return isSelected
? [
"tw-cursor-pointer",
"tw-block",
"tw-rounded",
"tw-border",
"tw-border-solid",
"tw-border-primary-600",
"tw-border-2",
"tw-rounded-lg",
"hover:tw-border-primary-700",
"focus:tw-border-3",
"focus:tw-border-primary-700",
"focus:tw-rounded-lg",
]
: [
"tw-cursor-pointer",
"tw-block",
"tw-rounded",
"tw-border",
"tw-border-solid",
"tw-border-secondary-300",
"hover:tw-border-text-main",
"focus:tw-border-2",
"focus:tw-border-primary-700",
];
}
}

View File

@@ -0,0 +1,259 @@
<ng-container>
<div class="tw-mt-4">
<p class="tw-text-lg tw-mb-1">
<span class="tw-font-semibold"
>{{ "total" | i18n }}:
{{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }} USD</span
>
<span class="tw-text-xs tw-font-light"> / {{ summaryData.selectedPlanInterval | i18n }}</span>
<button
(click)="toggleTotalOpened()"
type="button"
[bitIconButton]="summaryData.totalOpened ? 'bwi-angle-down' : 'bwi-angle-up'"
size="small"
aria-hidden="true"
></button>
</p>
</div>
<ng-container *ngIf="summaryData.totalOpened">
<!-- Main content container -->
<div class="tw-flex tw-flex-wrap tw-gap-4">
<bit-hint class="tw-w-full">
<ng-container *ngIf="summaryData.isSecretsManagerTrial; else showPasswordManagerFirst">
<ng-container *ngTemplateOutlet="secretsManagerSection"></ng-container>
<ng-container *ngTemplateOutlet="passwordManagerSection"></ng-container>
</ng-container>
<ng-template #showPasswordManagerFirst>
<ng-container *ngTemplateOutlet="passwordManagerSection"></ng-container>
<ng-container *ngTemplateOutlet="secretsManagerSection"></ng-container>
</ng-template>
<!-- Password Manager section -->
<ng-template #passwordManagerSection>
<ng-container
*ngIf="!summaryData.isSecretsManagerTrial || summaryData.organization.useSecretsManager"
>
<p class="tw-font-semibold tw-mt-3 tw-mb-1">{{ "passwordManager" | i18n }}</p>
<!-- Base Price -->
<ng-container *ngIf="summaryData.selectedPlan.PasswordManager.basePrice">
<p class="tw-mb-1 tw-flex tw-justify-between" bitTypography="body2">
<span>
<ng-container [ngSwitch]="summaryData.selectedInterval">
<ng-container *ngSwitchCase="planIntervals.Annually">
{{ summaryData.passwordManagerSeats }} {{ "members" | i18n }} &times;
{{
(summaryData.selectedPlan.isAnnual
? summaryData.selectedPlan.PasswordManager.basePrice / 12
: summaryData.selectedPlan.PasswordManager.basePrice
) | currency: "$"
}}
/{{ summaryData.selectedPlanInterval | i18n }}
</ng-container>
<ng-container *ngSwitchDefault>
{{ "basePrice" | i18n }}:
{{ summaryData.selectedPlan.PasswordManager.basePrice | currency: "$" }}
{{ "monthAbbr" | i18n }}
</ng-container>
</ng-container>
</span>
<span>
<ng-container
*ngIf="summaryData.acceptingSponsorship; else notAcceptingSponsorship"
>
<span class="tw-line-through">{{
summaryData.selectedPlan.PasswordManager.basePrice | currency: "$"
}}</span>
{{ "freeWithSponsorship" | i18n }}
</ng-container>
<ng-template #notAcceptingSponsorship>
{{ summaryData.selectedPlan.PasswordManager.basePrice | currency: "$" }}
</ng-template>
</span>
</p>
</ng-container>
<!-- Additional Seats -->
<ng-container *ngIf="summaryData.selectedPlan.PasswordManager.hasAdditionalSeatsOption">
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
<span>
<span *ngIf="summaryData.selectedPlan.PasswordManager.baseSeats"
>{{ "additionalUsers" | i18n }}:</span
>
{{ summaryData.passwordManagerSeats || 0 }}&nbsp;
<span *ngIf="!summaryData.selectedPlan.PasswordManager.baseSeats">{{
"members" | i18n
}}</span>
&times;
{{ summaryData.selectedPlan.PasswordManager.seatPrice | currency: "$" }}
/{{ summaryData.selectedPlanInterval | i18n }}
</span>
<span *ngIf="!summaryData.isSecretsManagerTrial">
{{ summaryData.passwordManagerSeatTotal | currency: "$" }}
</span>
<span *ngIf="summaryData.isSecretsManagerTrial">
{{ "freeForOneYear" | i18n }}
</span>
</p>
</ng-container>
<!-- Additional Storage -->
<ng-container
*ngIf="
summaryData.selectedPlan.PasswordManager.hasAdditionalStorageOption &&
summaryData.storageGb > 0
"
>
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
<span>
{{ summaryData.storageGb }} {{ "additionalStorageGbMessage" | i18n }}
&times;
{{ summaryData.additionalStoragePriceMonthly | currency: "$" }}
/{{ summaryData.selectedPlanInterval | i18n }}
</span>
<span>
<ng-container [ngSwitch]="summaryData.selectedInterval">
<ng-container *ngSwitchCase="planIntervals.Annually">
{{ summaryData.additionalStorageTotal | currency: "$" }}
</ng-container>
<ng-container *ngSwitchDefault>
{{
summaryData.storageGb *
summaryData.selectedPlan.PasswordManager.additionalStoragePricePerGb
| currency: "$"
}}
</ng-container>
</ng-container>
</span>
</p>
</ng-container>
</ng-container>
</ng-template>
<!-- Secrets Manager section -->
<ng-template #secretsManagerSection>
<ng-container *ngIf="summaryData.organization.useSecretsManager">
<p class="tw-font-semibold tw-mt-3 tw-mb-1">{{ "secretsManager" | i18n }}</p>
<!-- Base Price -->
<ng-container *ngIf="summaryData.selectedPlan?.SecretsManager?.basePrice">
<p class="tw-mb-1 tw-flex tw-justify-between" bitTypography="body2">
<span>
<ng-container [ngSwitch]="summaryData.selectedInterval">
<ng-container *ngSwitchCase="planIntervals.Annually">
{{ summaryData.sub?.smSeats }} {{ "members" | i18n }} &times;
{{
(summaryData.selectedPlan.isAnnual
? summaryData.selectedPlan.SecretsManager.basePrice / 12
: summaryData.selectedPlan.SecretsManager.basePrice
) | currency: "$"
}}
/{{ summaryData.selectedPlanInterval | i18n }}
</ng-container>
<ng-container *ngSwitchDefault>
{{ "basePrice" | i18n }}:
{{ summaryData.selectedPlan.SecretsManager.basePrice | currency: "$" }}
{{ "monthAbbr" | i18n }}
</ng-container>
</ng-container>
</span>
<span *ngIf="summaryData.selectedInterval === planIntervals.Monthly">
{{ summaryData.selectedPlan.SecretsManager.basePrice | currency: "$" }}
</span>
</p>
</ng-container>
<!-- Additional Seats -->
<ng-container
*ngIf="summaryData.selectedPlan?.SecretsManager?.hasAdditionalSeatsOption"
>
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
<span>
<span *ngIf="summaryData.selectedPlan.SecretsManager.baseSeats"
>{{ "additionalUsers" | i18n }}:</span
>
{{ summaryData.sub?.smSeats || 0 }}&nbsp;
<span *ngIf="!summaryData.selectedPlan.SecretsManager.baseSeats">{{
"members" | i18n
}}</span>
&times;
{{ summaryData.selectedPlan.SecretsManager.seatPrice | currency: "$" }}
/{{ summaryData.selectedPlanInterval | i18n }}
</span>
<span>
{{ summaryData.secretsManagerSeatTotal | currency: "$" }}
</span>
</p>
</ng-container>
<!-- Additional Service Accounts -->
<ng-container
*ngIf="
summaryData.selectedPlan?.SecretsManager?.hasAdditionalServiceAccountOption &&
summaryData.additionalServiceAccount > 0
"
>
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
<span>
{{ summaryData.additionalServiceAccount }}
{{ "serviceAccounts" | i18n | lowercase }}
&times;
{{
summaryData.selectedPlan?.SecretsManager?.additionalPricePerServiceAccount
| currency: "$"
}}
/{{ summaryData.selectedPlanInterval | i18n }}
</span>
<span>{{ summaryData.additionalServiceAccountTotal | currency: "$" }}</span>
</p>
</ng-container>
</ng-container>
</ng-template>
<!-- Discount Section -->
<ng-container *ngIf="summaryData.discountPercentageFromSub > 0">
<p class="tw-mb-0 tw-flex tw-justify-between" bitTypography="body2">
<span class="tw-text-xs">
{{
"providerDiscount" | i18n: this.summaryData.discountPercentageFromSub | lowercase
}}
</span>
<span class="tw-line-through tw-text-xs">
{{ summaryData.totalAppliedDiscount | currency: "$" }}
</span>
</p>
</ng-container>
</bit-hint>
</div>
<!-- Tax and Total Section -->
<div class="tw-flex tw-flex-wrap tw-gap-4 tw-mt-4">
<bit-hint class="tw-w-full">
<p
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
>
<span class="tw-font-semibold">{{ "estimatedTax" | i18n }}</span>
<span>{{ summaryData.estimatedTax | currency: "USD" : "$" }}</span>
</p>
</bit-hint>
</div>
<div class="tw-flex tw-flex-wrap tw-gap-4 tw-mt-4">
<bit-hint class="tw-w-full">
<p
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
>
<span class="tw-font-semibold">{{ "total" | i18n }}</span>
<span>
{{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }}
<span class="tw-text-xs tw-font-semibold"
>/ {{ summaryData.selectedPlanInterval | i18n }}</span
>
</span>
</p>
</bit-hint>
</div>
</ng-container>
</ng-container>

View File

@@ -0,0 +1,48 @@
import { Component, Input } from "@angular/core";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PlanInterval } from "@bitwarden/common/billing/enums";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
export interface PricingSummaryData {
selectedPlanInterval: string;
passwordManagerSeats: number;
passwordManagerSeatTotal: number;
secretsManagerSeatTotal: number;
additionalStorageTotal: number;
additionalStoragePriceMonthly: number;
additionalServiceAccountTotal: number;
totalAppliedDiscount: number;
secretsManagerSubtotal: number;
passwordManagerSubtotal: number;
total: number;
organization?: Organization;
sub?: OrganizationSubscriptionResponse;
selectedPlan?: PlanResponse;
selectedInterval?: PlanInterval;
discountPercentageFromSub?: number;
discountPercentage?: number;
acceptingSponsorship?: boolean;
additionalServiceAccount?: number;
totalOpened?: boolean;
storageGb?: number;
isSecretsManagerTrial?: boolean;
estimatedTax?: number;
}
@Component({
selector: "app-pricing-summary",
templateUrl: "./pricing-summary.component.html",
standalone: false,
})
export class PricingSummaryComponent {
@Input() summaryData!: PricingSummaryData;
planIntervals = PlanInterval;
toggleTotalOpened(): void {
if (this.summaryData) {
this.summaryData.totalOpened = !this.summaryData.totalOpened;
}
}
}

View File

@@ -0,0 +1,117 @@
<bit-dialog dialogSize="default">
<span bitDialogTitle class="tw-font-semibold">
{{ "subscribetoEnterprise" | i18n: currentPlanName }}
</span>
<div bitDialogContent>
<p>{{ "subscribeEnterpriseSubtitle" | i18n: currentPlanName }}</p>
<!-- Plan Features List -->
<ng-container [ngSwitch]="currentPlan?.productTier">
<ul class="bwi-ul tw-text-xs" *ngSwitchCase="productTypes.Enterprise">
<li>
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "includeEnterprisePolicies" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "passwordLessSso" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "accountRecovery" | i18n }}
</li>
<li *ngIf="!organization?.canAccessSecretsManager">
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "customRoles" | i18n }}
</li>
<li *ngIf="organization?.canAccessSecretsManager">
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "unlimitedSecretsAndProjects" | i18n }}
</li>
</ul>
<ul class="bwi-ul tw-text-xs" *ngSwitchCase="productTypes.Teams">
<li>
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "secureDataSharing" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "eventLogMonitoring" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "directoryIntegration" | i18n }}
</li>
<li *ngIf="organization?.canAccessSecretsManager">
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "unlimitedSecretsAndProjects" | i18n }}
</li>
</ul>
<ul class="bwi-ul tw-text-xs" *ngSwitchCase="productTypes.Families">
<li>
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "premiumAccounts" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "unlimitedSharing" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-muted bwi-li" aria-hidden="true"></i>
{{ "createUnlimitedCollections" | i18n }}
</li>
</ul>
</ng-container>
<div *ngIf="!(currentPlan?.productTier === productTypes.Families)">
<div class="tw-mb-3 tw-flex tw-justify-between">
<h4 class="tw-text-lg tw-text-main">{{ "selectAPlan" | i18n }}</h4>
</div>
<ng-container *ngIf="planCards().length > 0">
<div
class="tw-grid tw-grid-flow-col tw-gap-4 tw-mb-2"
[class]="'tw-grid-cols-' + planCards().length"
>
@for (planCard of planCards(); track $index) {
<app-plan-card [plan]="planCard" (cardClicked)="setSelected(planCard)"></app-plan-card>
}
</div>
</ng-container>
</div>
<!-- Payment Information -->
<ng-container>
<h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2>
<ng-container bitDialogContent>
<app-payment
[showAccountCredit]="false"
[showBankAccount]="!!organizationId"
[initialPaymentMethod]="initialPaymentMethod"
></app-payment>
<app-manage-tax-information
*ngIf="taxInformation"
[showTaxIdField]="showTaxIdField"
[startWith]="taxInformation"
(taxInformationChanged)="taxInformationChanged($event)"
/>
</ng-container>
<!-- Pricing Breakdown -->
<app-pricing-summary
*ngIf="pricingSummaryData"
[summaryData]="pricingSummaryData"
></app-pricing-summary>
</ng-container>
</div>
<!-- Dialog Footer -->
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" type="button" [bitAction]="onSubscribe.bind(this)">
{{ "subscribe" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.CLOSED">
{{ "later" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,365 @@
import { Component, EventEmitter, Inject, OnInit, Output, signal, ViewChild } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
import { PaymentMethodType, PlanInterval, ProductTierType } from "@bitwarden/common/billing/enums";
import { TaxInformation } from "@bitwarden/common/billing/models/domain";
import { ChangePlanFrequencyRequest } from "@bitwarden/common/billing/models/request/change-plan-frequency.request";
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
import { PreviewOrganizationInvoiceRequest } from "@bitwarden/common/billing/models/request/preview-organization-invoice.request";
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogService,
ToastService,
} from "@bitwarden/components";
import { PlanCardService } from "../../services/plan-card.service";
import { PaymentComponent } from "../payment/payment.component";
import { PlanCard } from "../plan-card/plan-card.component";
import { PricingSummaryData } from "../pricing-summary/pricing-summary.component";
import { PricingSummaryService } from "./../../services/pricing-summary.service";
type TrialPaymentDialogParams = {
organizationId: string;
subscription: OrganizationSubscriptionResponse;
productTierType: ProductTierType;
initialPaymentMethod?: PaymentMethodType;
};
export const TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE = {
CLOSED: "closed",
SUBMITTED: "submitted",
} as const;
export type TrialPaymentDialogResultType =
(typeof TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE)[keyof typeof TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE];
interface OnSuccessArgs {
organizationId: string;
}
@Component({
selector: "app-trial-payment-dialog",
templateUrl: "./trial-payment-dialog.component.html",
standalone: false,
})
export class TrialPaymentDialogComponent implements OnInit {
@ViewChild(PaymentComponent) paymentComponent!: PaymentComponent;
@ViewChild(ManageTaxInformationComponent) taxComponent!: ManageTaxInformationComponent;
currentPlan!: PlanResponse;
currentPlanName!: string;
productTypes = ProductTierType;
organization!: Organization;
organizationId!: string;
sub!: OrganizationSubscriptionResponse;
selectedInterval: PlanInterval = PlanInterval.Annually;
planCards = signal<PlanCard[]>([]);
plans!: ListResponse<PlanResponse>;
@Output() onSuccess = new EventEmitter<OnSuccessArgs>();
protected initialPaymentMethod: PaymentMethodType;
protected taxInformation!: TaxInformation;
protected readonly ResultType = TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE;
pricingSummaryData!: PricingSummaryData;
constructor(
@Inject(DIALOG_DATA) private dialogParams: TrialPaymentDialogParams,
private dialogRef: DialogRef<TrialPaymentDialogResultType>,
private organizationService: OrganizationService,
private i18nService: I18nService,
private organizationApiService: OrganizationApiServiceAbstraction,
private accountService: AccountService,
private planCardService: PlanCardService,
private pricingSummaryService: PricingSummaryService,
private apiService: ApiService,
private toastService: ToastService,
private billingApiService: BillingApiServiceAbstraction,
private organizationBillingApiServiceAbstraction: OrganizationBillingApiServiceAbstraction,
) {
this.initialPaymentMethod = this.dialogParams.initialPaymentMethod ?? PaymentMethodType.Card;
}
async ngOnInit(): Promise<void> {
this.currentPlanName = this.resolvePlanName(this.dialogParams.productTierType);
this.sub =
this.dialogParams.subscription ??
(await this.organizationApiService.getSubscription(this.dialogParams.organizationId));
this.organizationId = this.dialogParams.organizationId;
this.currentPlan = this.sub?.plan;
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
if (!userId) {
throw new Error("User ID is required");
}
const organization = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
);
if (!organization) {
throw new Error("Organization not found");
}
this.organization = organization;
const planCards = await this.planCardService.getCadenceCards(
this.currentPlan,
this.sub,
this.isSecretsManagerTrial(),
);
this.planCards.set(planCards);
if (!this.selectedInterval) {
this.selectedInterval = planCards.find((card) => card.isSelected)?.isAnnual
? PlanInterval.Annually
: PlanInterval.Monthly;
}
const taxInfo = await this.organizationApiService.getTaxInfo(this.organizationId);
this.taxInformation = TaxInformation.from(taxInfo);
this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData(
this.currentPlan,
this.sub,
this.organization,
this.selectedInterval,
this.taxInformation,
this.isSecretsManagerTrial(),
);
this.plans = await this.apiService.getPlans();
}
static open = (
dialogService: DialogService,
dialogConfig: DialogConfig<TrialPaymentDialogParams>,
) => dialogService.open<TrialPaymentDialogResultType>(TrialPaymentDialogComponent, dialogConfig);
async setSelected(planCard: PlanCard) {
this.selectedInterval = planCard.isAnnual ? PlanInterval.Annually : PlanInterval.Monthly;
this.planCards.update((planCards) => {
return planCards.map((planCard) => {
if (planCard.isSelected) {
return {
...planCard,
isSelected: false,
};
} else {
return {
...planCard,
isSelected: true,
};
}
});
});
await this.selectPlan();
this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData(
this.currentPlan,
this.sub,
this.organization,
this.selectedInterval,
this.taxInformation,
this.isSecretsManagerTrial(),
);
}
protected async selectPlan() {
if (
this.selectedInterval === PlanInterval.Monthly &&
this.currentPlan.productTier == ProductTierType.Families
) {
return;
}
const filteredPlans = this.plans.data.filter(
(plan) =>
plan.productTier === this.currentPlan.productTier &&
plan.isAnnual === (this.selectedInterval === PlanInterval.Annually),
);
if (filteredPlans.length > 0) {
this.currentPlan = filteredPlans[0];
}
try {
await this.refreshSalesTax();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const translatedMessage = this.i18nService.t(errorMessage);
this.toastService.showToast({
title: "",
variant: "error",
message: !translatedMessage || translatedMessage === "" ? errorMessage : translatedMessage,
});
}
}
protected get showTaxIdField(): boolean {
switch (this.currentPlan.productTier) {
case ProductTierType.Free:
case ProductTierType.Families:
return false;
default:
return true;
}
}
private async refreshSalesTax(): Promise<void> {
if (
this.taxInformation === undefined ||
!this.taxInformation.country ||
!this.taxInformation.postalCode
) {
return;
}
const request: PreviewOrganizationInvoiceRequest = {
organizationId: this.organizationId,
passwordManager: {
additionalStorage: 0,
plan: this.currentPlan?.type,
seats: this.sub.seats,
},
taxInformation: {
postalCode: this.taxInformation.postalCode,
country: this.taxInformation.country,
taxId: this.taxInformation.taxId,
},
};
if (this.organization.useSecretsManager) {
request.secretsManager = {
seats: this.sub.smSeats ?? 0,
additionalMachineAccounts:
(this.sub.smServiceAccounts ?? 0) -
(this.sub.plan.SecretsManager?.baseServiceAccount ?? 0),
};
}
this.pricingSummaryData = await this.pricingSummaryService.getPricingSummaryData(
this.currentPlan,
this.sub,
this.organization,
this.selectedInterval,
this.taxInformation,
this.isSecretsManagerTrial(),
);
}
async taxInformationChanged(event: TaxInformation) {
this.taxInformation = event;
this.toggleBankAccount();
await this.refreshSalesTax();
}
toggleBankAccount = () => {
this.paymentComponent.showBankAccount = this.taxInformation.country === "US";
if (
!this.paymentComponent.showBankAccount &&
this.paymentComponent.selected === PaymentMethodType.BankAccount
) {
this.paymentComponent.select(PaymentMethodType.Card);
}
};
isSecretsManagerTrial(): boolean {
return (
this.sub?.subscription?.items?.some((item) =>
this.sub?.customerDiscount?.appliesTo?.includes(item.productId),
) ?? false
);
}
async onSubscribe(): Promise<void> {
if (!this.taxComponent.validate()) {
this.taxComponent.markAllAsTouched();
}
try {
await this.updateOrganizationPaymentMethod(
this.organizationId,
this.paymentComponent,
this.taxInformation,
);
if (this.currentPlan.type !== this.sub.planType) {
const changePlanRequest = new ChangePlanFrequencyRequest();
changePlanRequest.newPlanType = this.currentPlan.type;
await this.organizationBillingApiServiceAbstraction.changeSubscriptionFrequency(
this.organizationId,
changePlanRequest,
);
}
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("updatedPaymentMethod"),
});
this.onSuccess.emit({ organizationId: this.organizationId });
this.dialogRef.close(TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED);
} catch (error) {
const msg =
typeof error === "object" && error !== null && "message" in error
? (error as { message: string }).message
: String(error);
this.toastService.showToast({
variant: "error",
title: undefined,
message: this.i18nService.t(msg) || msg,
});
}
}
private async updateOrganizationPaymentMethod(
organizationId: string,
paymentComponent: PaymentComponent,
taxInformation: TaxInformation,
): Promise<void> {
const paymentSource = await paymentComponent.tokenize();
const request = new UpdatePaymentMethodRequest();
request.paymentSource = paymentSource;
request.taxInformation = ExpandedTaxInfoUpdateRequest.From(taxInformation);
await this.billingApiService.updateOrganizationPaymentMethod(organizationId, request);
}
resolvePlanName(productTier: ProductTierType): string {
switch (productTier) {
case ProductTierType.Enterprise:
return this.i18nService.t("planNameEnterprise");
case ProductTierType.Free:
return this.i18nService.t("planNameFree");
case ProductTierType.Families:
return this.i18nService.t("planNameFamilies");
case ProductTierType.Teams:
return this.i18nService.t("planNameTeams");
case ProductTierType.TeamsStarter:
return this.i18nService.t("planNameTeamsStarter");
default:
return this.i18nService.t("planNameFree");
}
}
}

View File

@@ -1,8 +1,10 @@
import { AsyncPipe } from "@angular/common";
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Observable } from "rxjs";
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { Observable, Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { AnchorLinkDirective, BannerComponent } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -37,16 +39,28 @@ import { OrganizationFreeTrialWarning } from "../types";
`,
imports: [AnchorLinkDirective, AsyncPipe, BannerComponent, I18nPipe],
})
export class OrganizationFreeTrialWarningComponent implements OnInit {
export class OrganizationFreeTrialWarningComponent implements OnInit, OnDestroy {
@Input({ required: true }) organization!: Organization;
@Output() clicked = new EventEmitter<void>();
warning$!: Observable<OrganizationFreeTrialWarning>;
private destroy$ = new Subject<void>();
constructor(private organizationWarningsService: OrganizationWarningsService) {}
ngOnInit() {
this.warning$ = this.organizationWarningsService.getFreeTrialWarning$(this.organization);
this.organizationWarningsService
.refreshWarningsForOrganization$(this.organization.id as OrganizationId)
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.refresh();
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
refresh = () => {

View File

@@ -1,6 +1,7 @@
import { Location } from "@angular/common";
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { filter, from, lastValueFrom, map, Observable, switchMap, takeWhile } from "rxjs";
import { filter, from, lastValueFrom, map, Observable, Subject, switchMap, takeWhile } from "rxjs";
import { take } from "rxjs/operators";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
@@ -10,10 +11,15 @@ import { OrganizationWarningsResponse } from "@bitwarden/common/billing/models/r
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import { openChangePlanDialog } from "../../organizations/change-plan-dialog.component";
import {
TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE,
TrialPaymentDialogComponent,
} from "../../shared/trial-payment-dialog/trial-payment-dialog.component";
import { OrganizationFreeTrialWarning, OrganizationResellerRenewalWarning } from "../types";
const format = (date: Date) =>
@@ -26,6 +32,7 @@ const format = (date: Date) =>
@Injectable({ providedIn: "root" })
export class OrganizationWarningsService {
private cache$ = new Map<OrganizationId, Observable<OrganizationWarningsResponse>>();
private refreshWarnings$ = new Subject<OrganizationId>();
constructor(
private configService: ConfigService,
@@ -34,6 +41,8 @@ export class OrganizationWarningsService {
private organizationApiService: OrganizationApiServiceAbstraction,
private organizationBillingApiService: OrganizationBillingApiServiceAbstraction,
private router: Router,
private location: Location,
protected syncService: SyncService,
) {}
getFreeTrialWarning$ = (
@@ -174,10 +183,33 @@ export class OrganizationWarningsService {
});
break;
}
case "add_payment_method_optional_trial": {
const organizationSubscriptionResponse =
await this.organizationApiService.getSubscription(organization.id);
const dialogRef = TrialPaymentDialogComponent.open(this.dialogService, {
data: {
organizationId: organization.id,
subscription: organizationSubscriptionResponse,
productTierType: organization?.productTierType,
},
});
const result = await lastValueFrom(dialogRef.closed);
if (result === TRIAL_PAYMENT_METHOD_DIALOG_RESULT_TYPE.SUBMITTED) {
this.refreshWarnings$.next(organization.id as OrganizationId);
}
}
}
}),
);
refreshWarningsForOrganization$(organizationId: OrganizationId): Observable<void> {
return this.refreshWarnings$.pipe(
filter((id) => id === organizationId),
map((): void => void 0),
);
}
private getResponse$ = (
organization: Organization,
bypassCache: boolean = false,

View File

@@ -264,7 +264,6 @@ const safeProviders: SafeProvider[] = [
PolicyApiServiceAbstraction,
LogService,
PolicyService,
ConfigService,
],
}),
safeProvider({

View File

@@ -342,9 +342,9 @@ export class EventService {
);
break;
case EventType.OrganizationUser_Deleted:
msg = this.i18nService.t("deletedUserId", this.formatOrgUserId(ev));
msg = this.i18nService.t("deletedUserIdEventMessage", this.formatOrgUserId(ev));
humanReadableMsg = this.i18nService.t(
"deletedUserId",
"deletedUserIdEventMessage",
this.getShortId(ev.organizationUserId),
);
break;

View File

@@ -83,6 +83,7 @@ import { SendComponent } from "./tools/send/send.component";
import { BrowserExtensionPromptInstallComponent } from "./vault/components/browser-extension-prompt/browser-extension-prompt-install.component";
import { BrowserExtensionPromptComponent } from "./vault/components/browser-extension-prompt/browser-extension-prompt.component";
import { SetupExtensionComponent } from "./vault/components/setup-extension/setup-extension.component";
import { setupExtensionRedirectGuard } from "./vault/guards/setup-extension-redirect.guard";
import { VaultModule } from "./vault/individual-vault/vault.module";
const routes: Routes = [
@@ -628,6 +629,7 @@ const routes: Routes = [
children: [
{
path: "vault",
canActivate: [setupExtensionRedirectGuard],
loadChildren: () => VaultModule,
},
{

View File

@@ -18,7 +18,13 @@
>
{{ "getTheExtension" | i18n }}
</a>
<a bitButton buttonType="secondary" routerLink="/vault" bitDialogClose>
<a
bitButton
buttonType="secondary"
routerLink="/vault"
bitDialogClose
(click)="dismissExtensionPage()"
>
{{ "skipToWebApp" | i18n }}
</a>
</ng-container>

View File

@@ -1,3 +1,4 @@
import { DialogRef } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
@@ -5,20 +6,26 @@ import { RouterModule } from "@angular/router";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DIALOG_DATA } from "@bitwarden/components";
import { AddExtensionLaterDialogComponent } from "./add-extension-later-dialog.component";
describe("AddExtensionLaterDialogComponent", () => {
let fixture: ComponentFixture<AddExtensionLaterDialogComponent>;
const getDevice = jest.fn().mockReturnValue(null);
const onDismiss = jest.fn();
beforeEach(async () => {
onDismiss.mockClear();
await TestBed.configureTestingModule({
imports: [AddExtensionLaterDialogComponent, RouterModule.forRoot([])],
providers: [
provideNoopAnimations(),
{ provide: PlatformUtilsService, useValue: { getDevice } },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: DialogRef, useValue: { close: jest.fn() } },
{ provide: DIALOG_DATA, useValue: { onDismiss } },
],
}).compileComponents();
@@ -39,4 +46,11 @@ describe("AddExtensionLaterDialogComponent", () => {
expect(skipLink.attributes.href).toBe("/vault");
});
it('invokes `onDismiss` when "Skip to Web App" is clicked', () => {
const skipLink = fixture.debugElement.queryAll(By.css("a[bitButton]"))[1];
skipLink.triggerEventHandler("click", {});
expect(onDismiss).toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,17 @@ import { RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url";
import { ButtonComponent, DialogModule, TypographyModule } from "@bitwarden/components";
import {
ButtonComponent,
DIALOG_DATA,
DialogModule,
TypographyModule,
} from "@bitwarden/components";
export type AddExtensionLaterDialogData = {
/** Method invoked when the dialog is dismissed */
onDismiss: () => void;
};
@Component({
selector: "vault-add-extension-later-dialog",
@@ -13,6 +23,7 @@ import { ButtonComponent, DialogModule, TypographyModule } from "@bitwarden/comp
})
export class AddExtensionLaterDialogComponent implements OnInit {
private platformUtilsService = inject(PlatformUtilsService);
private data: AddExtensionLaterDialogData = inject(DIALOG_DATA);
/** Download Url for the extension based on the browser */
protected webStoreUrl: string = "";
@@ -20,4 +31,8 @@ export class AddExtensionLaterDialogComponent implements OnInit {
ngOnInit(): void {
this.webStoreUrl = getWebStoreUrl(this.platformUtilsService.getDevice());
}
async dismissExtensionPage() {
this.data.onDismiss();
}
}

View File

@@ -3,12 +3,14 @@ import { By } from "@angular/platform-browser";
import { Router, RouterModule } from "@angular/router";
import { BehaviorSubject } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
@@ -21,11 +23,13 @@ describe("SetupExtensionComponent", () => {
const getFeatureFlag = jest.fn().mockResolvedValue(false);
const navigate = jest.fn().mockResolvedValue(true);
const openExtension = jest.fn().mockResolvedValue(true);
const update = jest.fn().mockResolvedValue(true);
const extensionInstalled$ = new BehaviorSubject<boolean | null>(null);
beforeEach(async () => {
navigate.mockClear();
openExtension.mockClear();
update.mockClear();
getFeatureFlag.mockClear().mockResolvedValue(true);
window.matchMedia = jest.fn().mockReturnValue(false);
@@ -36,6 +40,14 @@ describe("SetupExtensionComponent", () => {
{ provide: ConfigService, useValue: { getFeatureFlag } },
{ provide: WebBrowserInteractionService, useValue: { extensionInstalled$, openExtension } },
{ provide: PlatformUtilsService, useValue: { getDevice: () => DeviceType.UnknownBrowser } },
{
provide: AccountService,
useValue: { activeAccount$: new BehaviorSubject({ account: { id: "account-id" } }) },
},
{
provide: StateProvider,
useValue: { getUser: () => ({ update }) },
},
],
}).compileComponents();
@@ -120,6 +132,10 @@ describe("SetupExtensionComponent", () => {
expect(openExtension).toHaveBeenCalled();
});
it("dismisses the extension page", () => {
expect(update).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@@ -2,13 +2,16 @@ import { DOCUMENT, NgIf } from "@angular/common";
import { Component, DestroyRef, inject, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router, RouterModule } from "@angular/router";
import { pairwise, startWith } from "rxjs";
import { firstValueFrom, pairwise, startWith } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import { getWebStoreUrl } from "@bitwarden/common/vault/utils/get-web-store-url";
import {
@@ -20,9 +23,13 @@ import {
} from "@bitwarden/components";
import { VaultIcons } from "@bitwarden/vault";
import { SETUP_EXTENSION_DISMISSED } from "../../guards/setup-extension-redirect.guard";
import { WebBrowserInteractionService } from "../../services/web-browser-interaction.service";
import { AddExtensionLaterDialogComponent } from "./add-extension-later-dialog.component";
import {
AddExtensionLaterDialogComponent,
AddExtensionLaterDialogData,
} from "./add-extension-later-dialog.component";
import { AddExtensionVideosComponent } from "./add-extension-videos.component";
const SetupExtensionState = {
@@ -53,6 +60,8 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
private destroyRef = inject(DestroyRef);
private platformUtilsService = inject(PlatformUtilsService);
private dialogService = inject(DialogService);
private stateProvider = inject(StateProvider);
private accountService = inject(AccountService);
private document = inject(DOCUMENT);
protected SetupExtensionState = SetupExtensionState;
@@ -96,6 +105,7 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
// Extension was not installed and now it is, show success state
if (previousState === false && currentState) {
this.dialogRef?.close();
void this.dismissExtensionPage();
this.state = SetupExtensionState.Success;
}
@@ -125,17 +135,31 @@ export class SetupExtensionComponent implements OnInit, OnDestroy {
const isMobile = Utils.isMobileBrowser;
if (!isFeatureEnabled || isMobile) {
await this.dismissExtensionPage();
await this.router.navigate(["/vault"]);
}
}
/** Opens the add extension later dialog */
addItLater() {
this.dialogRef = this.dialogService.open(AddExtensionLaterDialogComponent);
this.dialogRef = this.dialogService.open<unknown, AddExtensionLaterDialogData>(
AddExtensionLaterDialogComponent,
{
data: {
onDismiss: this.dismissExtensionPage.bind(this),
},
},
);
}
/** Opens the browser extension */
openExtension() {
void this.webBrowserExtensionInteractionService.openExtension();
}
/** Update local state to never show this page again. */
private async dismissExtensionPage() {
const accountId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
void this.stateProvider.getUser(accountId, SETUP_EXTENSION_DISMISSED).update(() => true);
}
}

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { toSignal, takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs";
import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common";
@@ -64,6 +64,8 @@ export class VaultItemsComponent<C extends CipherViewLike> {
@Input() addAccessToggle: boolean;
@Input() activeCollection: CollectionView | undefined;
private restrictedPolicies = toSignal(this.restrictedItemTypesService.restricted$);
private _ciphers?: C[] = [];
@Input() get ciphers(): C[] {
return this._ciphers;
@@ -94,7 +96,7 @@ export class VaultItemsComponent<C extends CipherViewLike> {
constructor(
protected cipherAuthorizationService: CipherAuthorizationService,
private restrictedItemTypesService: RestrictedItemTypesService,
protected restrictedItemTypesService: RestrictedItemTypesService,
) {
this.canDeleteSelected$ = this.selection.changed.pipe(
startWith(null),
@@ -281,6 +283,14 @@ export class VaultItemsComponent<C extends CipherViewLike> {
// TODO: PM-13944 Refactor to use cipherAuthorizationService.canClone$ instead
protected canClone(vaultItem: VaultItem<C>) {
// This will check for restrictions from org policies before allowing cloning.
const isItemRestricted = this.restrictedPolicies().some(
(rt) => rt.cipherType === vaultItem.cipher.type,
);
if (isItemRestricted) {
return false;
}
if (vaultItem.cipher.organizationId == null) {
return true;
}

View File

@@ -0,0 +1,132 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router";
import { BehaviorSubject } from "rxjs";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { WebBrowserInteractionService } from "../services/web-browser-interaction.service";
import { setupExtensionRedirectGuard } from "./setup-extension-redirect.guard";
describe("setupExtensionRedirectGuard", () => {
const _state = Object.freeze({}) as RouterStateSnapshot;
const emptyRoute = Object.freeze({ queryParams: {} }) as ActivatedRouteSnapshot;
const seventeenDaysAgo = new Date();
seventeenDaysAgo.setDate(seventeenDaysAgo.getDate() - 17);
const account = {
id: "account-id",
} as unknown as Account;
const activeAccount$ = new BehaviorSubject<Account | null>(account);
const extensionInstalled$ = new BehaviorSubject<boolean>(false);
const state$ = new BehaviorSubject<boolean>(false);
const createUrlTree = jest.fn();
const getFeatureFlag = jest.fn().mockImplementation((key) => {
if (key === FeatureFlag.PM19315EndUserActivationMvp) {
return Promise.resolve(true);
}
return Promise.resolve(false);
});
const getProfileCreationDate = jest.fn().mockResolvedValue(seventeenDaysAgo);
beforeEach(() => {
Utils.isMobileBrowser = false;
getFeatureFlag.mockClear();
getProfileCreationDate.mockClear();
createUrlTree.mockClear();
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { createUrlTree } },
{ provide: ConfigService, useValue: { getFeatureFlag } },
{ provide: AccountService, useValue: { activeAccount$ } },
{ provide: StateProvider, useValue: { getUser: () => ({ state$ }) } },
{ provide: WebBrowserInteractionService, useValue: { extensionInstalled$ } },
{
provide: VaultProfileService,
useValue: { getProfileCreationDate },
},
],
});
});
function setupExtensionGuard(route?: ActivatedRouteSnapshot) {
// Run the guard within injection context so `inject` works as you'd expect
// Pass state object to make TypeScript happy
return TestBed.runInInjectionContext(async () =>
setupExtensionRedirectGuard(route ?? emptyRoute, _state),
);
}
it("returns `true` when the profile was created more than 30 days ago", async () => {
const thirtyOneDaysAgo = new Date();
thirtyOneDaysAgo.setDate(thirtyOneDaysAgo.getDate() - 31);
getProfileCreationDate.mockResolvedValueOnce(thirtyOneDaysAgo);
expect(await setupExtensionGuard()).toBe(true);
});
it("returns `true` when the profile check fails", async () => {
getProfileCreationDate.mockRejectedValueOnce(new Error("Profile check failed"));
expect(await setupExtensionGuard()).toBe(true);
});
it("returns `true` when the feature flag is disabled", async () => {
getFeatureFlag.mockResolvedValueOnce(false);
expect(await setupExtensionGuard()).toBe(true);
});
it("returns `true` when the user is on a mobile device", async () => {
Utils.isMobileBrowser = true;
expect(await setupExtensionGuard()).toBe(true);
});
it("returns `true` when the user has dismissed the extension page", async () => {
state$.next(true);
expect(await setupExtensionGuard()).toBe(true);
});
it("returns `true` when the user has the extension installed", async () => {
state$.next(false);
extensionInstalled$.next(true);
expect(await setupExtensionGuard()).toBe(true);
});
it('redirects the user to "/setup-extension" when all criteria do not pass', async () => {
state$.next(false);
extensionInstalled$.next(false);
await setupExtensionGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/setup-extension"]);
});
describe("missing current account", () => {
afterAll(() => {
// reset `activeAccount$` observable
activeAccount$.next(account);
});
it("redirects to login when account is missing", async () => {
activeAccount$.next(null);
await setupExtensionGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/login"]);
});
});
});

View File

@@ -0,0 +1,109 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
SETUP_EXTENSION_DISMISSED_DISK,
StateProvider,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { WebBrowserInteractionService } from "../services/web-browser-interaction.service";
export const SETUP_EXTENSION_DISMISSED = new UserKeyDefinition<boolean>(
SETUP_EXTENSION_DISMISSED_DISK,
"setupExtensionDismissed",
{
deserializer: (dismissed) => dismissed,
clearOn: [],
},
);
export const setupExtensionRedirectGuard: CanActivateFn = async () => {
const router = inject(Router);
const configService = inject(ConfigService);
const accountService = inject(AccountService);
const vaultProfileService = inject(VaultProfileService);
const stateProvider = inject(StateProvider);
const webBrowserInteractionService = inject(WebBrowserInteractionService);
const isMobile = Utils.isMobileBrowser;
const endUserFeatureEnabled = await configService.getFeatureFlag(
FeatureFlag.PM19315EndUserActivationMvp,
);
// The extension page isn't applicable for mobile users, do not redirect them.
// Include before any other checks to avoid unnecessary processing.
if (!endUserFeatureEnabled || isMobile) {
return true;
}
const currentAcct = await firstValueFrom(accountService.activeAccount$);
if (!currentAcct) {
return router.createUrlTree(["/login"]);
}
const hasExtensionInstalledPromise = firstValueFrom(
webBrowserInteractionService.extensionInstalled$,
);
const dismissedExtensionPage = await firstValueFrom(
stateProvider
.getUser(currentAcct.id, SETUP_EXTENSION_DISMISSED)
.state$.pipe(map((dismissed) => dismissed ?? false)),
);
const isProfileOlderThan30Days = await profileIsOlderThan30Days(
vaultProfileService,
currentAcct.id,
).catch(
() =>
// If the call for the profile fails for any reason, do not block the user
true,
);
if (dismissedExtensionPage || isProfileOlderThan30Days) {
return true;
}
// Checking for the extension is a more expensive operation, do it last to avoid unnecessary delays.
const hasExtensionInstalled = await hasExtensionInstalledPromise;
if (hasExtensionInstalled) {
return true;
}
return router.createUrlTree(["/setup-extension"]);
};
/** Returns true when the user's profile is older than 30 days */
async function profileIsOlderThan30Days(
vaultProfileService: VaultProfileService,
userId: string,
): Promise<boolean> {
const creationDate = await vaultProfileService.getProfileCreationDate(userId);
return isMoreThan30DaysAgo(creationDate);
}
/** Returns the true when the date given is older than 30 days */
function isMoreThan30DaysAgo(date?: string | Date): boolean {
if (!date) {
return false;
}
const inputDate = new Date(date).getTime();
const today = new Date().getTime();
const differenceInMS = today - inputDate;
const msInADay = 1000 * 60 * 60 * 24;
const differenceInDays = Math.round(differenceInMS / msInADay);
return differenceInDays > 30;
}

View File

@@ -1,6 +1,7 @@
import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { Router } from "@angular/router";
import {
combineLatest,
distinctUntilChanged,
firstValueFrom,
map,
@@ -20,6 +21,7 @@ import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstract
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
@@ -155,6 +157,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
protected configService: ConfigService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
protected cipherService: CipherService,
) {}
async ngOnInit(): Promise<void> {
@@ -292,16 +295,47 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
return orgFilterSection;
}
protected async addTypeFilter(excludeTypes: CipherStatus[] = []): Promise<VaultFilterSection> {
protected async addTypeFilter(
excludeTypes: CipherStatus[] = [],
organizationId?: string,
): Promise<VaultFilterSection> {
const allFilter: CipherTypeFilter = { id: "AllItems", name: "allItems", type: "all", icon: "" };
const data$ = this.restrictedItemTypesService.restricted$.pipe(
map((restricted) => {
// List of types restricted by all orgs
const restrictedByAll = restricted
.filter((r) => r.allowViewOrgIds.length === 0)
const userId = await firstValueFrom(this.activeUserId$);
const data$ = combineLatest([
this.restrictedItemTypesService.restricted$,
this.cipherService.cipherViews$(userId),
]).pipe(
map(([restrictedTypes, ciphers]) => {
const restrictedForUser = restrictedTypes
.filter((r) => {
// - All orgs restrict the type
if (r.allowViewOrgIds.length === 0) {
return true;
}
// - Admin console: user has no ciphers of that type in the selected org
// - Individual vault view: user has no ciphers of that type in any allowed org
return !ciphers?.some((c) => {
if (c.deletedDate || c.type !== r.cipherType) {
return false;
}
// If the cipher doesn't belong to an org it is automatically restricted
if (!c.organizationId) {
return false;
}
if (organizationId) {
return (
c.organizationId === organizationId &&
r.allowViewOrgIds.includes(c.organizationId)
);
}
return r.allowViewOrgIds.includes(c.organizationId);
});
})
.map((r) => r.cipherType);
const toExclude = [...excludeTypes, ...restrictedByAll];
const toExclude = [...excludeTypes, ...restrictedForUser];
return this.allTypeFilters.filter(
(f) => typeof f.type === "string" || !toExclude.includes(f.type),
);

View File

@@ -38,7 +38,7 @@ describe("WebBrowserInteractionService", () => {
expect(installed).toBe(false);
});
tick(1500);
tick(150);
}));
it("returns true when the extension is installed", (done) => {
@@ -58,13 +58,13 @@ describe("WebBrowserInteractionService", () => {
});
// initial timeout, should emit false
tick(1500);
tick(26);
expect(results[0]).toBe(false);
tick(2500);
// then emit `HasBwInstalled`
dispatchEvent(VaultMessages.HasBwInstalled);
tick();
tick(26);
expect(results[1]).toBe(true);
}));
});

View File

@@ -21,10 +21,19 @@ import { ExtensionPageUrls } from "@bitwarden/common/vault/enums";
import { VaultMessages } from "@bitwarden/common/vault/enums/vault-messages.enum";
/**
* The amount of time in milliseconds to wait for a response from the browser extension.
* The amount of time in milliseconds to wait for a response from the browser extension. A longer duration is
* used to allow for the extension to open and then emit to the message.
* NOTE: This value isn't computed by any means, it is just a reasonable timeout for the extension to respond.
*/
const MESSAGE_RESPONSE_TIMEOUT_MS = 1500;
const OPEN_RESPONSE_TIMEOUT_MS = 1500;
/**
* Timeout for checking if the extension is installed.
*
* A shorter timeout is used to avoid waiting for too long for the extension. The listener for
* checking the installation runs in the background scripts so the response should be relatively quick.
*/
const CHECK_FOR_EXTENSION_TIMEOUT_MS = 25;
@Injectable({
providedIn: "root",
@@ -63,7 +72,7 @@ export class WebBrowserInteractionService {
filter((event) => event.data.command === VaultMessages.PopupOpened),
map(() => true),
),
timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
timer(OPEN_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
)
.pipe(take(1))
.subscribe((didOpen) => {
@@ -85,7 +94,7 @@ export class WebBrowserInteractionService {
filter((event) => event.data.command === VaultMessages.HasBwInstalled),
map(() => true),
),
timer(MESSAGE_RESPONSE_TIMEOUT_MS).pipe(map(() => false)),
timer(CHECK_FOR_EXTENSION_TIMEOUT_MS).pipe(map(() => false)),
).pipe(
tap({
subscribe: () => {

View File

@@ -568,6 +568,9 @@
"cancel": {
"message": "Cancel"
},
"later": {
"message": "Later"
},
"canceled": {
"message": "Canceled"
},
@@ -626,6 +629,9 @@
"searchGroups": {
"message": "Search groups"
},
"resetSearch": {
"message": "Reset search"
},
"allItems": {
"message": "All items"
},
@@ -4630,6 +4636,9 @@
"receiveMarketingEmailsV2": {
"message": "Get advice, announcements, and research opportunities from Bitwarden in your inbox."
},
"subscribe": {
"message": "Subscribe"
},
"unsubscribe": {
"message": "Unsubscribe"
},
@@ -10289,8 +10298,8 @@
"organizationUserDeletedDesc": {
"message": "The user was removed from the organization and all associated user data has been deleted."
},
"deletedUserId": {
"message": "Deleted user $ID$ - an owner / admin deleted the user account",
"deletedUserIdEventMessage": {
"message": "Deleted user $ID$",
"placeholders": {
"id": {
"content": "$1",
@@ -10900,5 +10909,26 @@
"example": "12-3456789"
}
}
},
"subscribetoEnterprise": {
"message": "Subscribe to $PLAN$",
"placeholders": {
"plan": {
"content": "$1",
"example": "Teams"
}
}
},
"subscribeEnterpriseSubtitle": {
"message": "Your 7-day $PLAN$ trial starts today. Add a payment method now to continue using these features after your trial ends: ",
"placeholders": {
"plan": {
"content": "$1",
"example": "Teams"
}
}
},
"unlimitedSecretsAndProjects": {
"message": "Unlimited secrets and projects"
}
}
}

View File

@@ -158,6 +158,13 @@ export interface PasswordHealthReportApplicationsRequest {
url: string;
}
export interface EncryptedDataModel {
organizationId: OrganizationId;
encryptedData: string;
encryptionKey: string;
date: Date;
}
// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum DrawerType {

View File

@@ -0,0 +1,83 @@
import { mock } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EncryptedDataModel } from "../models/password-health";
import { RiskInsightsApiService } from "./risk-insights-api.service";
describe("RiskInsightsApiService", () => {
let service: RiskInsightsApiService;
const mockApiService = mock<ApiService>();
beforeEach(() => {
service = new RiskInsightsApiService(mockApiService);
});
it("should be created", () => {
expect(service).toBeTruthy();
});
describe("getRiskInsightsSummary", () => {
it("should call apiService.send with correct parameters and return an Observable", (done) => {
const orgId = "org123";
const minDate = new Date("2024-01-01");
const maxDate = new Date("2024-01-31");
const mockResponse: EncryptedDataModel[] = [{ encryptedData: "abc" } as EncryptedDataModel];
mockApiService.send.mockResolvedValueOnce(mockResponse);
service.getRiskInsightsSummary(orgId, minDate, maxDate).subscribe((result) => {
expect(mockApiService.send).toHaveBeenCalledWith(
"GET",
`organization-report-summary/org123?from=2024-01-01&to=2024-01-31`,
null,
true,
true,
);
expect(result).toEqual(mockResponse);
done();
});
});
});
describe("saveRiskInsightsSummary", () => {
it("should call apiService.send with correct parameters and return an Observable", (done) => {
const data: EncryptedDataModel = { encryptedData: "xyz" } as EncryptedDataModel;
mockApiService.send.mockResolvedValueOnce(undefined);
service.saveRiskInsightsSummary(data).subscribe((result) => {
expect(mockApiService.send).toHaveBeenCalledWith(
"POST",
"organization-report-summary",
data,
true,
true,
);
expect(result).toBeUndefined();
done();
});
});
});
describe("updateRiskInsightsSummary", () => {
it("should call apiService.send with correct parameters and return an Observable", (done) => {
const data: EncryptedDataModel = { encryptedData: "xyz" } as EncryptedDataModel;
mockApiService.send.mockResolvedValueOnce(undefined);
service.updateRiskInsightsSummary(data).subscribe((result) => {
expect(mockApiService.send).toHaveBeenCalledWith(
"PUT",
"organization-report-summary",
data,
true,
true,
);
expect(result).toBeUndefined();
done();
});
});
});
});

Some files were not shown because too many files have changed in this diff Show More