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:
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
|
||||
50
.github/workflows/alert-ddg-files-modified.yml
vendored
Normal file
50
.github/workflows/alert-ddg-files-modified.yml
vendored
Normal 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
|
||||
});
|
||||
6
.github/workflows/build-browser-target.yml
vendored
6
.github/workflows/build-browser-target.yml
vendored
@@ -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
|
||||
|
||||
|
||||
60
.github/workflows/build-browser.yml
vendored
60
.github/workflows/build-browser.yml
vendored
@@ -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()
|
||||
|
||||
5
.github/workflows/build-cli-target.yml
vendored
5
.github/workflows/build-cli-target.yml
vendored
@@ -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
|
||||
|
||||
|
||||
61
.github/workflows/build-cli.yml
vendored
61
.github/workflows/build-cli.yml
vendored
@@ -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()
|
||||
|
||||
6
.github/workflows/build-desktop-target.yml
vendored
6
.github/workflows/build-desktop-target.yml
vendored
@@ -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
|
||||
|
||||
|
||||
136
.github/workflows/build-desktop.yml
vendored
136
.github/workflows/build-desktop.yml
vendored
@@ -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 }}
|
||||
|
||||
|
||||
7
.github/workflows/build-web-target.yml
vendored
7
.github/workflows/build-web-target.yml
vendored
@@ -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
|
||||
|
||||
|
||||
60
.github/workflows/build-web.yml
vendored
60
.github/workflows/build-web.yml
vendored
@@ -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()
|
||||
|
||||
26
.github/workflows/chromatic.yml
vendored
26
.github/workflows/chromatic.yml
vendored
@@ -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
|
||||
|
||||
41
.github/workflows/crowdin-pull.yml
vendored
41
.github/workflows/crowdin-pull.yml
vendored
@@ -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:
|
||||
|
||||
79
.github/workflows/deploy-web.yml
vendored
79
.github/workflows/deploy-web.yml
vendored
@@ -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 }}
|
||||
|
||||
22
.github/workflows/lint-crowdin-config.yml
vendored
22
.github/workflows/lint-crowdin-config.yml
vendored
@@ -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 }}'
|
||||
|
||||
53
.github/workflows/publish-cli.yml
vendored
53
.github/workflows/publish-cli.yml
vendored
@@ -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
|
||||
|
||||
50
.github/workflows/publish-desktop.yml
vendored
50
.github/workflows/publish-desktop.yml
vendored
@@ -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
|
||||
|
||||
30
.github/workflows/publish-web.yml
vendored
30
.github/workflows/publish-web.yml
vendored
@@ -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:
|
||||
|
||||
112
.github/workflows/release-desktop-beta.yml
vendored
112
.github/workflows/release-desktop-beta.yml
vendored
@@ -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
|
||||
|
||||
54
.github/workflows/repository-management.yml
vendored
54
.github/workflows/repository-management.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
13
.github/workflows/staged-rollout-desktop.yml
vendored
13
.github/workflows/staged-rollout-desktop.yml
vendored
@@ -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 }}
|
||||
|
||||
24
.github/workflows/version-auto-bump.yml
vendored
24
.github/workflows/version-auto-bump.yml
vendored
@@ -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
|
||||
|
||||
@@ -547,6 +547,9 @@
|
||||
"searchVault": {
|
||||
"message": "Search vault"
|
||||
},
|
||||
"resetSearch": {
|
||||
"message": "Reset search"
|
||||
},
|
||||
"edit": {
|
||||
"message": "Edit"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
35
apps/cli/src/tools/send/commands/template.command.ts
Normal file
35
apps/cli/src/tools/send/commands/template.command.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
194
apps/cli/src/tools/send/util.spec.ts
Normal file
194
apps/cli/src/tools/send/util.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
55
apps/cli/src/tools/send/util.ts
Normal file
55
apps/cli/src/tools/send/util.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -83,6 +83,7 @@ export class Menubar {
|
||||
updateRequest?.accounts,
|
||||
isLocked,
|
||||
isLockable,
|
||||
updateRequest?.restrictedCipherTypes,
|
||||
),
|
||||
new EditMenu(i18nService, messagingService, isLocked),
|
||||
new ViewMenu(i18nService, messagingService, isLocked),
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -82,5 +82,7 @@ function cloneCollection(
|
||||
cloned.organizationId = collection.organizationId;
|
||||
cloned.readOnly = collection.readOnly;
|
||||
cloned.manage = collection.manage;
|
||||
cloned.type = collection.type;
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
59
apps/web/src/app/billing/services/plan-card.service.ts
Normal file
59
apps/web/src/app/billing/services/plan-card.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
155
apps/web/src/app/billing/services/pricing-summary.service.ts
Normal file
155
apps/web/src/app/billing/services/pricing-summary.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
>
|
||||
<bit-option
|
||||
[disabled]="true"
|
||||
[value]=""
|
||||
[value]="null"
|
||||
[label]="'--' + ('select' | i18n) + '--'"
|
||||
></bit-option>
|
||||
<bit-option
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 }} ×
|
||||
{{
|
||||
(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 }}
|
||||
<span *ngIf="!summaryData.selectedPlan.PasswordManager.baseSeats">{{
|
||||
"members" | i18n
|
||||
}}</span>
|
||||
×
|
||||
{{ 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 }}
|
||||
×
|
||||
{{ 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 }} ×
|
||||
{{
|
||||
(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 }}
|
||||
<span *ngIf="!summaryData.selectedPlan.SecretsManager.baseSeats">{{
|
||||
"members" | i18n
|
||||
}}</span>
|
||||
×
|
||||
{{ 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 }}
|
||||
×
|
||||
{{
|
||||
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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -264,7 +264,6 @@ const safeProviders: SafeProvider[] = [
|
||||
PolicyApiServiceAbstraction,
|
||||
LogService,
|
||||
PolicyService,
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts
Normal file
109
apps/web/src/app/vault/guards/setup-extension-redirect.guard.ts
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user