mirror of
https://github.com/bitwarden/browser
synced 2026-02-05 11:13:44 +00:00
Merge branch 'CL-896' into PM-26650
This commit is contained in:
6
.github/workflows/build-browser.yml
vendored
6
.github/workflows/build-browser.yml
vendored
@@ -147,7 +147,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
4
.github/workflows/build-cli.yml
vendored
4
.github/workflows/build-cli.yml
vendored
@@ -124,7 +124,7 @@ jobs:
|
||||
awk '{print tolower($0)}')" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -302,7 +302,7 @@ jobs:
|
||||
choco install nasm --no-progress
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
245
.github/workflows/build-desktop.yml
vendored
245
.github/workflows/build-desktop.yml
vendored
@@ -174,7 +174,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -323,7 +323,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -429,7 +429,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -665,6 +665,239 @@ jobs:
|
||||
path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml
|
||||
if-no-files-found: error
|
||||
|
||||
windows-beta:
|
||||
name: Windows Beta Build
|
||||
runs-on: windows-2022
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
NODE_OPTIONS: --max_old_space_size=4096
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
node-version: ${{ env._NODE_VERSION }}
|
||||
|
||||
- name: Install AST
|
||||
run: dotnet tool install --global AzureSignTool --version 4.0.1
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
node --version
|
||||
npm --version
|
||||
choco --version
|
||||
rustup show
|
||||
|
||||
- name: Log in to Azure
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
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: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "code-signing-vault-url,
|
||||
code-signing-client-id,
|
||||
code-signing-tenant-id,
|
||||
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: ./
|
||||
|
||||
- name: Download SDK Artifacts
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: bitwarden/gh-actions/download-artifacts@main
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
workflow: build-wasm-internal.yml
|
||||
workflow_conclusion: success
|
||||
branch: ${{ inputs.sdk_branch }}
|
||||
artifacts: sdk-internal
|
||||
repo: bitwarden/sdk-internal
|
||||
path: ../sdk-internal
|
||||
if_no_artifact_found: fail
|
||||
|
||||
- name: Override SDK
|
||||
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
|
||||
working-directory: ./
|
||||
run: |
|
||||
ls -l ../
|
||||
npm link ../sdk-internal
|
||||
|
||||
- name: Cache Native Module
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
apps/desktop/desktop_native/napi/*.node
|
||||
apps/desktop/desktop_native/dist/*
|
||||
key: rust-${{ runner.os }}-${{ hashFiles('apps/desktop/desktop_native/**/*') }}
|
||||
|
||||
- name: Build Native Module
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
working-directory: apps/desktop/desktop_native
|
||||
run: node build.js cross-platform
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Pack
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'false' }}
|
||||
run: npm run pack:win:beta
|
||||
|
||||
- name: Pack & Sign
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
env:
|
||||
ELECTRON_BUILDER_SIGN: 1
|
||||
SIGNING_VAULT_URL: ${{ steps.retrieve-secrets.outputs.code-signing-vault-url }}
|
||||
SIGNING_CLIENT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-client-id }}
|
||||
SIGNING_TENANT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-tenant-id }}
|
||||
SIGNING_CLIENT_SECRET: ${{ steps.retrieve-secrets.outputs.code-signing-client-secret }}
|
||||
SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }}
|
||||
run: npm run pack:win:beta
|
||||
|
||||
- name: Rename appx files for store
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
run: |
|
||||
Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx" `
|
||||
-Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx"
|
||||
Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx" `
|
||||
-Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx"
|
||||
Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx" `
|
||||
-Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx"
|
||||
|
||||
- name: Fix NSIS artifact names for auto-updater
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
run: |
|
||||
Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z `
|
||||
-NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z
|
||||
Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z `
|
||||
-NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z
|
||||
Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z `
|
||||
-NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z
|
||||
|
||||
- name: Upload portable exe artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe
|
||||
path: apps/desktop/dist/Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload installer exe artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe
|
||||
path: apps/desktop/dist/nsis-web/Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload appx ia32 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx
|
||||
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload store appx ia32 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx
|
||||
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload NSIS ia32 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z
|
||||
path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload appx x64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx
|
||||
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload store appx x64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx
|
||||
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload NSIS x64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z
|
||||
path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload appx ARM64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx
|
||||
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload store appx ARM64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx
|
||||
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload NSIS ARM64 artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z
|
||||
path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload auto-update artifact
|
||||
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: ${{ needs.setup.outputs.release_channel }}-beta.yml
|
||||
path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
macos-build:
|
||||
name: MacOS Build
|
||||
@@ -688,7 +921,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -914,7 +1147,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -1172,7 +1405,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
2
.github/workflows/chromatic.yml
vendored
2
.github/workflows/chromatic.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true'
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
2
.github/workflows/nx.yml
vendored
2
.github/workflows/nx.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
2
.github/workflows/publish-cli.yml
vendored
2
.github/workflows/publish-cli.yml
vendored
@@ -205,7 +205,7 @@ jobs:
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
npm-version: "11.5.1" # FIXME: npm 11.5.1 or later is required to publish w/ OIDC; move version management to somewhere maintainable by automation
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
10
CLAUDE.md
10
CLAUDE.md
@@ -96,3 +96,13 @@ enum CipherType {
|
||||
```
|
||||
|
||||
Example: `/libs/common/src/vault/enums/cipher-type.ts`
|
||||
|
||||
## References
|
||||
|
||||
- [Web Clients Architecture](https://contributing.bitwarden.com/architecture/clients)
|
||||
- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/)
|
||||
- [Contributing Guide](https://contributing.bitwarden.com/)
|
||||
- [Web Clients Setup Guide](https://contributing.bitwarden.com/getting-started/clients/)
|
||||
- [Code Style](https://contributing.bitwarden.com/contributing/code-style/)
|
||||
- [Security Whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/)
|
||||
- [Security Definitions](https://contributing.bitwarden.com/architecture/security/definitions)
|
||||
|
||||
494
apps/browser/project.json
Normal file
494
apps/browser/project.json
Normal file
@@ -0,0 +1,494 @@
|
||||
{
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"name": "browser",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "apps/browser/src",
|
||||
"tags": ["scope:browser", "type:app"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/webpack:webpack",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": "chrome-dev",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/browser",
|
||||
"webpackConfig": "apps/browser/webpack.config.js",
|
||||
"tsConfig": "apps/browser/tsconfig.json",
|
||||
"main": "apps/browser/src/popup/main.ts",
|
||||
"target": "web",
|
||||
"compiler": "tsc"
|
||||
},
|
||||
"configurations": {
|
||||
"chrome": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/chrome",
|
||||
"env": {
|
||||
"BROWSER": "chrome",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"chrome-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/chrome-dev",
|
||||
"env": {
|
||||
"BROWSER": "chrome",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/edge",
|
||||
"env": {
|
||||
"BROWSER": "edge",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"edge-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/edge-dev",
|
||||
"env": {
|
||||
"BROWSER": "edge",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"firefox": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/firefox",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"firefox-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/firefox-dev",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"firefox-mv2": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/firefox-mv2",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"firefox-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/firefox-mv2-dev",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"opera": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/opera",
|
||||
"env": {
|
||||
"BROWSER": "opera",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"opera-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/opera-dev",
|
||||
"env": {
|
||||
"BROWSER": "opera",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"safari": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/safari",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"safari-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/safari-dev",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"safari-mv2": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/safari-mv2",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"safari-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/safari-mv2-dev",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-chrome": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/commercial-chrome",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "chrome",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"commercial-chrome-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-chrome-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "chrome",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-edge": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/commercial-edge",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "edge",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"commercial-edge-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-edge-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "edge",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-firefox": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/commercial-firefox",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"commercial-firefox-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-firefox-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-firefox-mv2": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/commercial-firefox-mv2",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"commercial-firefox-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-firefox-mv2-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-opera": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/commercial-opera",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "opera",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"commercial-opera-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-opera-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "opera",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-safari": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/commercial-safari",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"commercial-safari-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-safari-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-safari-mv2": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/browser/commercial-safari-mv2",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"commercial-safari-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-safari-mv2-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/webpack:webpack",
|
||||
"defaultConfiguration": "chrome-dev",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/browser",
|
||||
"webpackConfig": "apps/browser/webpack.config.js",
|
||||
"tsConfig": "apps/browser/tsconfig.json",
|
||||
"main": "apps/browser/src/popup/main.ts",
|
||||
"target": "web",
|
||||
"compiler": "tsc",
|
||||
"watch": true
|
||||
},
|
||||
"configurations": {
|
||||
"chrome-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/chrome-dev",
|
||||
"env": {
|
||||
"BROWSER": "chrome",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"firefox-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/firefox-dev",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"firefox-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/firefox-mv2-dev",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"safari-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/safari-dev",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"safari-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/safari-mv2-dev",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"edge-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/edge-dev",
|
||||
"env": {
|
||||
"BROWSER": "edge",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"opera-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/opera-dev",
|
||||
"env": {
|
||||
"BROWSER": "opera",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-chrome-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-chrome-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "chrome",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-firefox-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-firefox-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-firefox-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-firefox-mv2-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "firefox",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-safari-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-safari-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-safari-mv2-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-safari-mv2-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "safari",
|
||||
"MANIFEST_VERSION": "2",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-edge-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-edge-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "edge",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-opera-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/browser/commercial-opera-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-browser/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
"tsConfig": "bitwarden_license/bit-browser/tsconfig.json",
|
||||
"env": {
|
||||
"BROWSER": "opera",
|
||||
"MANIFEST_VERSION": "3",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "apps/browser/jest.config.js"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/browser/**/*.ts", "apps/browser/**/*.html"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,7 +558,7 @@
|
||||
"message": "Archive",
|
||||
"description": "Verb"
|
||||
},
|
||||
"unarchive": {
|
||||
"unArchive": {
|
||||
"message": "Unarchive"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
@@ -570,11 +570,11 @@
|
||||
"noItemsInArchiveDesc": {
|
||||
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
|
||||
},
|
||||
"itemSentToArchive": {
|
||||
"message": "Item sent to archive"
|
||||
"itemWasSentToArchive": {
|
||||
"message": "Item was sent to archive"
|
||||
},
|
||||
"itemRemovedFromArchive": {
|
||||
"message": "Item removed from archive"
|
||||
"itemUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
"archiveItem": {
|
||||
"message": "Archive item"
|
||||
|
||||
@@ -172,7 +172,9 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const showOnLocked =
|
||||
!this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari();
|
||||
!this.platformUtilsService.isFirefox() &&
|
||||
!this.platformUtilsService.isSafari() &&
|
||||
!(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel");
|
||||
|
||||
this.vaultTimeoutOptions = [
|
||||
{ name: this.i18nService.t("immediately"), value: 0 },
|
||||
|
||||
@@ -4105,6 +4105,7 @@ describe("AutofillService", () => {
|
||||
});
|
||||
|
||||
it("returns null if the field cannot be hidden", () => {
|
||||
usernameField.form = "differentFormId";
|
||||
const result = autofillService["findUsernameField"](
|
||||
pageDetails,
|
||||
passwordField,
|
||||
@@ -4116,6 +4117,18 @@ describe("AutofillService", () => {
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it("returns the field if the username field is in the form", () => {
|
||||
const result = autofillService["findUsernameField"](
|
||||
pageDetails,
|
||||
passwordField,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toBe(usernameField);
|
||||
});
|
||||
|
||||
it("returns the field if the field can be hidden", () => {
|
||||
const result = autofillService["findUsernameField"](
|
||||
pageDetails,
|
||||
|
||||
@@ -2286,11 +2286,16 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
this.findMatchingFieldIndex(f, AutoFillConstants.UsernameFieldNames) > -1;
|
||||
const isInSameForm = f.form === passwordField.form;
|
||||
|
||||
// An email or tel field in the same form as the password field is likely a qualified
|
||||
// candidate for autofill, even if visibility checks are unreliable
|
||||
const isQualifiedUsernameField =
|
||||
f.form === passwordField.form && (f.type === "email" || f.type === "tel");
|
||||
|
||||
if (
|
||||
!f.disabled &&
|
||||
(canBeReadOnly || !f.readonly) &&
|
||||
(withoutForm || isInSameForm || includesUsernameFieldName) &&
|
||||
(canBeHidden || f.viewable) &&
|
||||
(canBeHidden || f.viewable || isQualifiedUsernameField) &&
|
||||
(f.type === "text" || f.type === "email" || f.type === "tel")
|
||||
) {
|
||||
// Prioritize fields in the same form as the password field
|
||||
|
||||
@@ -100,6 +100,8 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-
|
||||
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
|
||||
import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service";
|
||||
import {
|
||||
DefaultVaultTimeoutSettingsService,
|
||||
@@ -452,6 +454,7 @@ export default class MainBackground {
|
||||
taskService: TaskService;
|
||||
cipherEncryptionService: CipherEncryptionService;
|
||||
private restrictedItemTypesService: RestrictedItemTypesService;
|
||||
private securityStateService: SecurityStateService;
|
||||
|
||||
ipcContentScriptManagerService: IpcContentScriptManagerService;
|
||||
ipcService: IpcService;
|
||||
@@ -668,6 +671,8 @@ export default class MainBackground {
|
||||
logoutCallback,
|
||||
);
|
||||
|
||||
this.securityStateService = new DefaultSecurityStateService(this.stateProvider);
|
||||
|
||||
this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService(
|
||||
messageListener,
|
||||
this.globalStateProvider,
|
||||
@@ -830,6 +835,7 @@ export default class MainBackground {
|
||||
this.accountService,
|
||||
this.kdfConfigService,
|
||||
this.keyService,
|
||||
this.securityStateService,
|
||||
this.apiService,
|
||||
this.stateProvider,
|
||||
this.configService,
|
||||
@@ -999,7 +1005,6 @@ export default class MainBackground {
|
||||
this.avatarService = new AvatarService(this.apiService, this.stateProvider);
|
||||
|
||||
this.providerService = new ProviderService(this.stateProvider);
|
||||
|
||||
this.syncService = new DefaultSyncService(
|
||||
this.masterPasswordService,
|
||||
this.accountService,
|
||||
@@ -1025,6 +1030,7 @@ export default class MainBackground {
|
||||
this.tokenService,
|
||||
this.authService,
|
||||
this.stateProvider,
|
||||
this.securityStateService,
|
||||
);
|
||||
|
||||
this.syncServiceListener = new SyncServiceListener(
|
||||
|
||||
@@ -337,7 +337,7 @@ export class ItemMoreOptionsComponent {
|
||||
await this.cipherArchiveService.archiveWithServer(this.cipher.id as CipherId, activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemSentToArchive"),
|
||||
message: this.i18nService.t("itemWasSentToArchive"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
{{ "clone" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="unarchive(cipher)">
|
||||
{{ "unarchive" | i18n }}
|
||||
{{ "unArchive" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -133,7 +133,7 @@ export class ArchiveComponent {
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemRemovedFromArchive"),
|
||||
message: this.i18nService.t("itemUnarchived"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,18 @@ const configurator = require("./config/config");
|
||||
const manifest = require("./webpack/manifest");
|
||||
const AngularCheckPlugin = require("./webpack/angular-check");
|
||||
|
||||
module.exports.getEnv = function getEnv() {
|
||||
const ENV = (process.env.ENV = process.env.NODE_ENV);
|
||||
module.exports.getEnv = function getEnv(params) {
|
||||
const ENV = params.env || (process.env.ENV = process.env.NODE_ENV);
|
||||
const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2;
|
||||
const browser = process.env.BROWSER ?? "chrome";
|
||||
|
||||
return { ENV, manifestVersion, browser };
|
||||
};
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
outputPath: path.resolve(__dirname, "build"),
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* configName: string;
|
||||
@@ -29,15 +33,20 @@ module.exports.getEnv = function getEnv() {
|
||||
* entry: string;
|
||||
* };
|
||||
* tsConfig: string;
|
||||
* outputPath?: string;
|
||||
* mode?: string;
|
||||
* env?: string;
|
||||
* additionalEntries?: { [outputPath: string]: string }
|
||||
* }} params - The input parameters for building the config.
|
||||
*/
|
||||
module.exports.buildConfig = function buildConfig(params) {
|
||||
params = { ...DEFAULT_PARAMS, ...params };
|
||||
|
||||
if (process.env.NODE_ENV == null) {
|
||||
process.env.NODE_ENV = "development";
|
||||
}
|
||||
|
||||
const { ENV, manifestVersion, browser } = module.exports.getEnv();
|
||||
const { ENV, manifestVersion, browser } = module.exports.getEnv(params);
|
||||
|
||||
console.log(`Building Manifest Version ${manifestVersion} app - ${params.configName} version`);
|
||||
|
||||
@@ -103,7 +112,7 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
{
|
||||
loader: "babel-loader",
|
||||
options: {
|
||||
configFile: "../../babel.config.json",
|
||||
configFile: path.resolve(__dirname, "../../babel.config.json"),
|
||||
cacheDirectory: ENV === "development",
|
||||
compact: ENV !== "development",
|
||||
},
|
||||
@@ -130,43 +139,52 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
|
||||
const plugins = [
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/popup/index.ejs",
|
||||
template: path.resolve(__dirname, "src/popup/index.ejs"),
|
||||
filename: "popup/index.html",
|
||||
chunks: ["popup/polyfills", "popup/vendor-angular", "popup/vendor", "popup/main"],
|
||||
browser: browser,
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/autofill/notification/bar.html",
|
||||
template: path.resolve(__dirname, "src/autofill/notification/bar.html"),
|
||||
filename: "notification/bar.html",
|
||||
chunks: ["notification/bar"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/autofill/overlay/inline-menu/pages/button/button.html",
|
||||
template: path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/overlay/inline-menu/pages/button/button.html",
|
||||
),
|
||||
filename: "overlay/menu-button.html",
|
||||
chunks: ["overlay/menu-button"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/autofill/overlay/inline-menu/pages/list/list.html",
|
||||
template: path.resolve(__dirname, "src/autofill/overlay/inline-menu/pages/list/list.html"),
|
||||
filename: "overlay/menu-list.html",
|
||||
chunks: ["overlay/menu-list"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html",
|
||||
template: path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html",
|
||||
),
|
||||
filename: "overlay/menu.html",
|
||||
chunks: ["overlay/menu"],
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: manifestVersion == 3 ? "./src/manifest.v3.json" : "./src/manifest.json",
|
||||
from:
|
||||
manifestVersion == 3
|
||||
? path.resolve(__dirname, "src/manifest.v3.json")
|
||||
: path.resolve(__dirname, "src/manifest.json"),
|
||||
to: "manifest.json",
|
||||
transform: manifest.transform(browser),
|
||||
},
|
||||
{ from: "./src/managed_schema.json", to: "managed_schema.json" },
|
||||
{ from: "./src/_locales", to: "_locales" },
|
||||
{ from: "./src/images", to: "images" },
|
||||
{ from: "./src/popup/images", to: "popup/images" },
|
||||
{ from: "./src/autofill/content/autofill.css", to: "content" },
|
||||
{ from: path.resolve(__dirname, "src/managed_schema.json"), to: "managed_schema.json" },
|
||||
{ from: path.resolve(__dirname, "src/_locales"), to: "_locales" },
|
||||
{ from: path.resolve(__dirname, "src/images"), to: "images" },
|
||||
{ from: path.resolve(__dirname, "src/popup/images"), to: "popup/images" },
|
||||
{ from: path.resolve(__dirname, "src/autofill/content/autofill.css"), to: "content" },
|
||||
],
|
||||
}),
|
||||
new MiniCssExtractPlugin({
|
||||
@@ -196,33 +214,76 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
name: "main",
|
||||
mode: ENV,
|
||||
devtool: false,
|
||||
|
||||
entry: {
|
||||
"popup/polyfills": "./src/popup/polyfills.ts",
|
||||
"popup/polyfills": path.resolve(__dirname, "src/popup/polyfills.ts"),
|
||||
"popup/main": params.popup.entry,
|
||||
"content/trigger-autofill-script-injection":
|
||||
"./src/autofill/content/trigger-autofill-script-injection.ts",
|
||||
"content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts",
|
||||
"content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts",
|
||||
"content/bootstrap-autofill-overlay-menu":
|
||||
"./src/autofill/content/bootstrap-autofill-overlay-menu.ts",
|
||||
"content/bootstrap-autofill-overlay-notifications":
|
||||
"./src/autofill/content/bootstrap-autofill-overlay-notifications.ts",
|
||||
"content/autofiller": "./src/autofill/content/autofiller.ts",
|
||||
"content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts",
|
||||
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
|
||||
"content/content-message-handler": "./src/autofill/content/content-message-handler.ts",
|
||||
"content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts",
|
||||
"content/fido2-page-script": "./src/autofill/fido2/content/fido2-page-script.ts",
|
||||
"content/ipc-content-script": "./src/platform/ipc/content/ipc-content-script.ts",
|
||||
"notification/bar": "./src/autofill/notification/bar.ts",
|
||||
"overlay/menu-button":
|
||||
"./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts",
|
||||
"overlay/menu-list":
|
||||
"./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts",
|
||||
"overlay/menu":
|
||||
"./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts",
|
||||
"content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts",
|
||||
"content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts",
|
||||
"content/trigger-autofill-script-injection": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/content/trigger-autofill-script-injection.ts",
|
||||
),
|
||||
"content/bootstrap-autofill": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/content/bootstrap-autofill.ts",
|
||||
),
|
||||
"content/bootstrap-autofill-overlay": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/content/bootstrap-autofill-overlay.ts",
|
||||
),
|
||||
"content/bootstrap-autofill-overlay-menu": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/content/bootstrap-autofill-overlay-menu.ts",
|
||||
),
|
||||
"content/bootstrap-autofill-overlay-notifications": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/content/bootstrap-autofill-overlay-notifications.ts",
|
||||
),
|
||||
"content/autofiller": path.resolve(__dirname, "src/autofill/content/autofiller.ts"),
|
||||
"content/auto-submit-login": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/content/auto-submit-login.ts",
|
||||
),
|
||||
"content/contextMenuHandler": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/content/context-menu-handler.ts",
|
||||
),
|
||||
"content/content-message-handler": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/content/content-message-handler.ts",
|
||||
),
|
||||
"content/fido2-content-script": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/fido2/content/fido2-content-script.ts",
|
||||
),
|
||||
"content/fido2-page-script": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/fido2/content/fido2-page-script.ts",
|
||||
),
|
||||
"content/ipc-content-script": path.resolve(
|
||||
__dirname,
|
||||
"src/platform/ipc/content/ipc-content-script.ts",
|
||||
),
|
||||
"notification/bar": path.resolve(__dirname, "src/autofill/notification/bar.ts"),
|
||||
"overlay/menu-button": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts",
|
||||
),
|
||||
"overlay/menu-list": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts",
|
||||
),
|
||||
"overlay/menu": path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts",
|
||||
),
|
||||
"content/send-on-installed-message": path.resolve(
|
||||
__dirname,
|
||||
"src/vault/content/send-on-installed-message.ts",
|
||||
),
|
||||
"content/send-popup-open-message": path.resolve(
|
||||
__dirname,
|
||||
"src/vault/content/send-popup-open-message.ts",
|
||||
),
|
||||
...params.additionalEntries,
|
||||
},
|
||||
cache:
|
||||
@@ -291,7 +352,7 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
resolve: {
|
||||
extensions: [".ts", ".js"],
|
||||
symlinks: false,
|
||||
modules: [path.resolve("../../node_modules")],
|
||||
modules: [path.resolve(__dirname, "../../node_modules")],
|
||||
fallback: {
|
||||
assert: false,
|
||||
buffer: require.resolve("buffer/"),
|
||||
@@ -306,7 +367,7 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
filename: "[name].js",
|
||||
chunkFilename: "assets/[name].js",
|
||||
webassemblyModuleFilename: "assets/[modulehash].wasm",
|
||||
path: path.resolve(__dirname, "build"),
|
||||
path: params.outputPath,
|
||||
clean: true,
|
||||
},
|
||||
module: {
|
||||
@@ -335,7 +396,7 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
// Manifest V2 uses Background Pages which requires a html page.
|
||||
mainConfig.plugins.push(
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/platform/background.html",
|
||||
template: path.resolve(__dirname, "src/platform/background.html"),
|
||||
filename: "background.html",
|
||||
chunks: ["vendor", "background"],
|
||||
}),
|
||||
@@ -344,19 +405,23 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
// Manifest V2 background pages can be run through the regular build pipeline.
|
||||
// Since it's a standard webpage.
|
||||
mainConfig.entry.background = params.background.entry;
|
||||
mainConfig.entry["content/fido2-page-script-delay-append-mv2"] =
|
||||
"./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts";
|
||||
mainConfig.entry["content/fido2-page-script-delay-append-mv2"] = path.resolve(
|
||||
__dirname,
|
||||
"src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts",
|
||||
);
|
||||
|
||||
configs.push(mainConfig);
|
||||
} else {
|
||||
// Firefox does not use the offscreen API
|
||||
if (browser !== "firefox") {
|
||||
mainConfig.entry["offscreen-document/offscreen-document"] =
|
||||
"./src/platform/offscreen-document/offscreen-document.ts";
|
||||
mainConfig.entry["offscreen-document/offscreen-document"] = path.resolve(
|
||||
__dirname,
|
||||
"src/platform/offscreen-document/offscreen-document.ts",
|
||||
);
|
||||
|
||||
mainConfig.plugins.push(
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/platform/offscreen-document/index.html",
|
||||
template: path.resolve(__dirname, "src/platform/offscreen-document/index.html"),
|
||||
filename: "offscreen-document/index.html",
|
||||
chunks: ["offscreen-document/offscreen-document"],
|
||||
}),
|
||||
@@ -372,11 +437,12 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
name: "background",
|
||||
mode: ENV,
|
||||
devtool: false,
|
||||
|
||||
entry: params.background.entry,
|
||||
target: target,
|
||||
output: {
|
||||
filename: "background.js",
|
||||
path: path.resolve(__dirname, "build"),
|
||||
path: params.outputPath,
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
@@ -409,7 +475,7 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
resolve: {
|
||||
extensions: [".ts", ".js"],
|
||||
symlinks: false,
|
||||
modules: [path.resolve("../../node_modules")],
|
||||
modules: [path.resolve(__dirname, "../../node_modules")],
|
||||
plugins: [new TsconfigPathsPlugin()],
|
||||
fallback: {
|
||||
fs: false,
|
||||
@@ -428,8 +494,11 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
backgroundConfig.plugins.push(
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{ from: "./src/safari/mv3/fake-background.html", to: "background.html" },
|
||||
{ from: "./src/safari/mv3/fake-vendor.js", to: "vendor.js" },
|
||||
{
|
||||
from: path.resolve(__dirname, "src/safari/mv3/fake-background.html"),
|
||||
to: "background.html",
|
||||
},
|
||||
{ from: path.resolve(__dirname, "src/safari/mv3/fake-vendor.js"), to: "vendor.js" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,13 +1,54 @@
|
||||
const path = require("path");
|
||||
const { buildConfig } = require("./webpack.base");
|
||||
|
||||
module.exports = buildConfig({
|
||||
configName: "OSS",
|
||||
popup: {
|
||||
entry: "./src/popup/main.ts",
|
||||
entryModule: "src/popup/app.module#AppModule",
|
||||
},
|
||||
background: {
|
||||
entry: "./src/platform/background.ts",
|
||||
},
|
||||
tsConfig: "tsconfig.json",
|
||||
});
|
||||
module.exports = (webpackConfig, context) => {
|
||||
// Detect if called by Nx (context parameter exists)
|
||||
const isNxBuild = context && context.options;
|
||||
|
||||
if (isNxBuild) {
|
||||
// Nx build configuration
|
||||
const mode = context.options.mode || "development";
|
||||
if (process.env.NODE_ENV == null) {
|
||||
process.env.NODE_ENV = mode;
|
||||
}
|
||||
const ENV = (process.env.ENV = process.env.NODE_ENV);
|
||||
|
||||
// Set environment variables from Nx context
|
||||
if (context.options.env) {
|
||||
Object.keys(context.options.env).forEach((key) => {
|
||||
process.env[key] = context.options.env[key];
|
||||
});
|
||||
}
|
||||
|
||||
return buildConfig({
|
||||
configName: "OSS",
|
||||
popup: {
|
||||
entry: path.resolve(__dirname, "src/popup/main.ts"),
|
||||
entryModule: "src/popup/app.module#AppModule",
|
||||
},
|
||||
background: {
|
||||
entry: path.resolve(__dirname, "src/platform/background.ts"),
|
||||
},
|
||||
tsConfig: path.resolve(__dirname, "tsconfig.json"),
|
||||
outputPath:
|
||||
context.context && context.context.root
|
||||
? path.resolve(context.context.root, context.options.outputPath)
|
||||
: context.options.outputPath,
|
||||
mode: mode,
|
||||
env: ENV,
|
||||
});
|
||||
} else {
|
||||
// npm build configuration
|
||||
return buildConfig({
|
||||
configName: "OSS",
|
||||
popup: {
|
||||
entry: path.resolve(__dirname, "src/popup/main.ts"),
|
||||
entryModule: "src/popup/app.module#AppModule",
|
||||
},
|
||||
background: {
|
||||
entry: path.resolve(__dirname, "src/platform/background.ts"),
|
||||
},
|
||||
tsConfig: "tsconfig.json",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,6 +73,8 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-
|
||||
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
|
||||
import {
|
||||
DefaultVaultTimeoutService,
|
||||
DefaultVaultTimeoutSettingsService,
|
||||
@@ -305,6 +307,7 @@ export class ServiceContainer {
|
||||
cipherEncryptionService: CipherEncryptionService;
|
||||
restrictedItemTypesService: RestrictedItemTypesService;
|
||||
cliRestrictedItemTypesService: CliRestrictedItemTypesService;
|
||||
securityStateService: SecurityStateService;
|
||||
cipherArchiveService: CipherArchiveService;
|
||||
|
||||
constructor() {
|
||||
@@ -406,6 +409,8 @@ export class ServiceContainer {
|
||||
this.derivedStateProvider,
|
||||
);
|
||||
|
||||
this.securityStateService = new DefaultSecurityStateService(this.stateProvider);
|
||||
|
||||
this.environmentService = new DefaultEnvironmentService(
|
||||
this.stateProvider,
|
||||
this.accountService,
|
||||
@@ -612,6 +617,7 @@ export class ServiceContainer {
|
||||
this.accountService,
|
||||
this.kdfConfigService,
|
||||
this.keyService,
|
||||
this.securityStateService,
|
||||
this.apiService,
|
||||
this.stateProvider,
|
||||
this.configService,
|
||||
@@ -818,6 +824,7 @@ export class ServiceContainer {
|
||||
this.tokenService,
|
||||
this.authService,
|
||||
this.stateProvider,
|
||||
this.securityStateService,
|
||||
);
|
||||
|
||||
this.totpService = new TotpService(this.sdkService);
|
||||
|
||||
42
apps/desktop/desktop_native/Cargo.lock
generated
42
apps/desktop/desktop_native/Cargo.lock
generated
@@ -754,6 +754,22 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb"
|
||||
dependencies = [
|
||||
"ctor-proc-macro",
|
||||
"dtor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor-proc-macro"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
@@ -1049,6 +1065,21 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dtor"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934"
|
||||
dependencies = [
|
||||
"dtor-proc-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dtor-proc-macro"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
@@ -1886,7 +1917,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ctor",
|
||||
"ctor 0.2.9",
|
||||
"napi-derive",
|
||||
"napi-sys",
|
||||
"once_cell",
|
||||
@@ -2559,6 +2590,15 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "process_isolation"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"ctor 0.5.0",
|
||||
"desktop_core",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
|
||||
@@ -6,6 +6,7 @@ members = [
|
||||
"core",
|
||||
"macos_provider",
|
||||
"napi",
|
||||
"process_isolation",
|
||||
"proxy",
|
||||
"windows_plugin_authenticator"
|
||||
]
|
||||
@@ -27,6 +28,7 @@ byteorder = "=1.5.0"
|
||||
bytes = "=1.10.1"
|
||||
cbc = "=0.1.2"
|
||||
core-foundation = "=0.10.1"
|
||||
ctor = "=0.5.0"
|
||||
dirs = "=6.0.0"
|
||||
ed25519 = "=2.2.3"
|
||||
embed_plist = "=1.2.2"
|
||||
|
||||
@@ -45,6 +45,20 @@ function buildProxyBin(target, release = true) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildProcessIsolation() {
|
||||
if (process.platform !== "linux") {
|
||||
return;
|
||||
}
|
||||
|
||||
child_process.execSync(`cargo build --release`, {
|
||||
stdio: 'inherit',
|
||||
cwd: path.join(__dirname, "process_isolation")
|
||||
});
|
||||
|
||||
console.log("Copying process isolation library to dist folder");
|
||||
fs.copyFileSync(path.join(__dirname, "target", "release", "libprocess_isolation.so"), path.join(__dirname, "dist", `libprocess_isolation.so`));
|
||||
}
|
||||
|
||||
function installTarget(target) {
|
||||
child_process.execSync(`rustup target add ${target}`, { stdio: 'inherit', cwd: __dirname });
|
||||
}
|
||||
@@ -53,6 +67,7 @@ if (!crossPlatform && !target) {
|
||||
console.log(`Building native modules in ${mode} mode for the native architecture`);
|
||||
buildNapiModule(false, mode === "release");
|
||||
buildProxyBin(false, mode === "release");
|
||||
buildProcessIsolation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,6 +76,7 @@ if (target) {
|
||||
installTarget(target);
|
||||
buildNapiModule(target, mode === "release");
|
||||
buildProxyBin(target, mode === "release");
|
||||
buildProcessIsolation();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,4 +94,5 @@ platformTargets.forEach(([target, _]) => {
|
||||
installTarget(target);
|
||||
buildNapiModule(target);
|
||||
buildProxyBin(target);
|
||||
buildProcessIsolation();
|
||||
});
|
||||
|
||||
14
apps/desktop/desktop_native/process_isolation/Cargo.toml
Normal file
14
apps/desktop/desktop_native/process_isolation/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "process_isolation"
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
version = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
ctor = { workspace = true }
|
||||
desktop_core = { path = "../core" }
|
||||
libc = { workspace = true }
|
||||
46
apps/desktop/desktop_native/process_isolation/src/lib.rs
Normal file
46
apps/desktop/desktop_native/process_isolation/src/lib.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
//! This library compiles to a pre-loadable shared object. When preloaded, it
|
||||
//! immediately isolates the process using the methods available on the platform.
|
||||
//! On Linux, this is PR_SET_DUMPABLE to prevent debuggers from attaching, the env
|
||||
//! from being read and the memory from being stolen.
|
||||
|
||||
use desktop_core::process_isolation;
|
||||
use std::{ffi::c_char, sync::LazyLock};
|
||||
|
||||
static ORIGINAL_UNSETENV: LazyLock<unsafe extern "C" fn(*const c_char) -> i32> =
|
||||
LazyLock::new(|| unsafe {
|
||||
std::mem::transmute(libc::dlsym(libc::RTLD_NEXT, c"unsetenv".as_ptr()))
|
||||
});
|
||||
|
||||
/// Hooks unsetenv to fix a bug in zypak-wrapper.
|
||||
/// Zypak unsets the env in Flatpak as a side-effect, which means that only the top level
|
||||
/// processes would be hooked. With this work-around all processes in the tree are hooked
|
||||
#[unsafe(no_mangle)]
|
||||
unsafe extern "C" fn unsetenv(name: *const c_char) -> i32 {
|
||||
unsafe {
|
||||
let Ok(name_str) = std::ffi::CStr::from_ptr(name).to_str() else {
|
||||
return ORIGINAL_UNSETENV(name);
|
||||
};
|
||||
|
||||
if name_str == "LD_PRELOAD" {
|
||||
// This env variable is provided by the flatpak configuration
|
||||
let ld_preload = std::env::var("PROCESS_ISOLATION_LD_PRELOAD").unwrap_or_default();
|
||||
std::env::set_var("LD_PRELOAD", ld_preload);
|
||||
return 0;
|
||||
}
|
||||
|
||||
ORIGINAL_UNSETENV(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Hooks the shared object being loaded into the process
|
||||
#[ctor::ctor]
|
||||
fn preload_init() {
|
||||
let pid = unsafe { libc::getpid() };
|
||||
unsafe {
|
||||
println!("[Process Isolation] Enabling memory security for process {pid}");
|
||||
process_isolation::isolate_process();
|
||||
process_isolation::disable_coredumps();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script tests the memory isolation status of bitwarden-desktop processes. The script will print "isolated"
|
||||
# if the memory is not accessible by other processes.
|
||||
|
||||
CURRENT_USER=$(whoami)
|
||||
|
||||
# Find processes with "bitwarden" in the command
|
||||
pids=$(pgrep -f bitwarden)
|
||||
|
||||
if [[ -z "$pids" ]]; then
|
||||
echo "No bitwarden processes found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for pid in $pids; do
|
||||
# Get process info: command, PPID, RSS memory
|
||||
read cmd ppid rss <<<$(ps -o comm=,ppid=,rss= -p "$pid")
|
||||
|
||||
# Explicitly skip if the command line does not contain "bitwarden"
|
||||
if ! grep -q "bitwarden" <<<"$cmd"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check ownership of /proc/$pid/environ
|
||||
owner=$(stat -c "%U" /proc/$pid/environ 2>/dev/null)
|
||||
|
||||
if [[ "$owner" == "root" ]]; then
|
||||
status="isolated"
|
||||
elif [[ "$owner" == "$CURRENT_USER" ]]; then
|
||||
status="insecure"
|
||||
else
|
||||
status="unknown-owner:$owner"
|
||||
fi
|
||||
|
||||
# Convert memory to MB
|
||||
mem_mb=$((rss / 1024))
|
||||
|
||||
echo "PID: $pid | CMD: $cmd | Mem: ${mem_mb}MB | Owner: $owner | Status: $status"
|
||||
done
|
||||
132
apps/desktop/electron-builder.beta.json
Normal file
132
apps/desktop/electron-builder.beta.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"extraMetadata": {
|
||||
"name": "bitwarden-beta"
|
||||
},
|
||||
"productName": "Bitwarden Beta",
|
||||
"appId": "com.bitwarden.desktop.beta",
|
||||
"buildDependenciesFromSource": true,
|
||||
"copyright": "Copyright © 2015-2025 Bitwarden Inc.",
|
||||
"directories": {
|
||||
"buildResources": "resources",
|
||||
"output": "dist",
|
||||
"app": "build"
|
||||
},
|
||||
"afterSign": "scripts/after-sign.js",
|
||||
"afterPack": "scripts/after-pack.js",
|
||||
"asarUnpack": ["**/*.node"],
|
||||
"files": [
|
||||
"**/*",
|
||||
"!**/node_modules/@bitwarden/desktop-napi/**/*",
|
||||
"**/node_modules/@bitwarden/desktop-napi/index.js",
|
||||
"**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node"
|
||||
],
|
||||
"electronVersion": "36.8.1",
|
||||
"generateUpdatesFilesForAllChannels": true,
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://artifacts.bitwarden.com/desktop"
|
||||
},
|
||||
"win": {
|
||||
"electronUpdaterCompatibility": ">=0.0.1",
|
||||
"target": ["portable", "nsis-web", "appx"],
|
||||
"signtoolOptions": {
|
||||
"sign": "./sign.js"
|
||||
},
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe",
|
||||
"to": "desktop_proxy.exe"
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsisWeb": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": false,
|
||||
"artifactName": "Bitwarden-Beta-Installer-${version}.${ext}",
|
||||
"uninstallDisplayName": "${productName}",
|
||||
"deleteAppDataOnUninstall": true,
|
||||
"include": "installer.nsh"
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "Bitwarden-Beta-Portable-${version}.${ext}"
|
||||
},
|
||||
"appx": {
|
||||
"artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}",
|
||||
"backgroundColor": "#175DDC",
|
||||
"applicationId": "BitwardenBeta",
|
||||
"identityName": "8bitSolutionsLLC.BitwardenBeta",
|
||||
"publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418",
|
||||
"publisherDisplayName": "Bitwarden Inc",
|
||||
"languages": [
|
||||
"en-US",
|
||||
"af",
|
||||
"ar",
|
||||
"az-latn",
|
||||
"be",
|
||||
"bg",
|
||||
"bn",
|
||||
"bs",
|
||||
"ca",
|
||||
"cs",
|
||||
"cy",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en-gb",
|
||||
"en-in",
|
||||
"es",
|
||||
"et",
|
||||
"eu",
|
||||
"fa",
|
||||
"fi",
|
||||
"fil",
|
||||
"fr",
|
||||
"gl",
|
||||
"he",
|
||||
"hi",
|
||||
"hr",
|
||||
"hu",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ka",
|
||||
"km",
|
||||
"kn",
|
||||
"ko",
|
||||
"lt",
|
||||
"lv",
|
||||
"ml",
|
||||
"mr",
|
||||
"nb",
|
||||
"ne",
|
||||
"nl",
|
||||
"nn",
|
||||
"or",
|
||||
"pl",
|
||||
"pt-br",
|
||||
"pt-pt",
|
||||
"ro",
|
||||
"ru",
|
||||
"si",
|
||||
"sk",
|
||||
"sl",
|
||||
"sr-cyrl",
|
||||
"sv",
|
||||
"ta",
|
||||
"te",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh-cn",
|
||||
"zh-tw"
|
||||
]
|
||||
},
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Bitwarden",
|
||||
"schemes": ["bitwarden"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -106,6 +106,10 @@
|
||||
{
|
||||
"from": "desktop_native/dist/desktop_proxy.${platform}-${arch}",
|
||||
"to": "desktop_proxy"
|
||||
},
|
||||
{
|
||||
"from": "desktop_native/dist/libprocess_isolation.so",
|
||||
"to": "libprocess_isolation.so"
|
||||
}
|
||||
],
|
||||
"target": ["deb", "freebsd", "rpm", "AppImage", "snap"],
|
||||
|
||||
@@ -35,9 +35,10 @@
|
||||
"build:renderer:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer --watch",
|
||||
"electron": "node ./scripts/start.js",
|
||||
"electron:ignore": "node ./scripts/start.js --ignore-certificate-errors",
|
||||
"flatpak:dev": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --force-clean --install --user ../../.flatpak/ ./resources/com.bitwarden.desktop.devel.yaml && flatpak run com.bitwarden.desktop",
|
||||
"clean:dist": "rimraf ./dist",
|
||||
"pack:dir": "npm run clean:dist && electron-builder --dir -p never",
|
||||
"pack:lin:flatpak": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --repo=build/.repo build/.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ./build/.repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
|
||||
"pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
|
||||
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/",
|
||||
"pack:lin:arm64": "npm run clean:dist && electron-builder --dir -p never && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .",
|
||||
"pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never",
|
||||
@@ -48,6 +49,7 @@
|
||||
"pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never",
|
||||
"pack:mac:masdev:with-extension": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
|
||||
"pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
|
||||
"pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
|
||||
"pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never",
|
||||
"dist:dir": "npm run build && npm run pack:dir",
|
||||
"dist:lin": "npm run build && npm run pack:lin",
|
||||
|
||||
115
apps/desktop/project.json
Normal file
115
apps/desktop/project.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"name": "desktop",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "apps/desktop/src",
|
||||
"tags": ["scope:desktop", "type:app"],
|
||||
"targets": {
|
||||
"build-native": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "cd desktop_native && node build.js",
|
||||
"cwd": "apps/desktop"
|
||||
}
|
||||
},
|
||||
"build-main": {
|
||||
"executor": "nx:run-commands",
|
||||
"outputs": ["{workspaceRoot}/dist/apps/desktop"],
|
||||
"options": {
|
||||
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main",
|
||||
"cwd": "apps/desktop"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main"
|
||||
},
|
||||
"production": {
|
||||
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main"
|
||||
}
|
||||
}
|
||||
},
|
||||
"build-preload": {
|
||||
"executor": "nx:run-commands",
|
||||
"outputs": ["{workspaceRoot}/dist/apps/desktop"],
|
||||
"options": {
|
||||
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload",
|
||||
"cwd": "apps/desktop"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload"
|
||||
},
|
||||
"production": {
|
||||
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload"
|
||||
}
|
||||
}
|
||||
},
|
||||
"build-renderer": {
|
||||
"executor": "nx:run-commands",
|
||||
"outputs": ["{workspaceRoot}/dist/apps/desktop"],
|
||||
"options": {
|
||||
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer",
|
||||
"cwd": "apps/desktop"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer"
|
||||
},
|
||||
"production": {
|
||||
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["build-native"],
|
||||
"outputs": ["{workspaceRoot}/dist/apps/desktop"],
|
||||
"options": {
|
||||
"parallel": true,
|
||||
"commands": [
|
||||
"nx run desktop:build-main",
|
||||
"nx run desktop:build-preload",
|
||||
"nx run desktop:build-renderer"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"commands": [
|
||||
"nx run desktop:build-main --configuration=development",
|
||||
"nx run desktop:build-preload --configuration=development",
|
||||
"nx run desktop:build-renderer --configuration=development"
|
||||
]
|
||||
},
|
||||
"production": {
|
||||
"commands": [
|
||||
"nx run desktop:build-main --configuration=production",
|
||||
"nx run desktop:build-preload --configuration=production",
|
||||
"nx run desktop:build-renderer --configuration=production"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["build-native"],
|
||||
"options": {
|
||||
"command": "node scripts/nx-serve.js",
|
||||
"cwd": "apps/desktop"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/apps/desktop"],
|
||||
"options": {
|
||||
"jestConfig": "apps/desktop/jest.config.js"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/desktop/**/*.ts", "apps/desktop/**/*.html"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,4 +46,6 @@ modules:
|
||||
commands:
|
||||
- ulimit -c 0
|
||||
- export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID"
|
||||
- export ZYPAK_LD_PRELOAD="/app/bin/libprocess_isolation.so"
|
||||
- export PROCESS_ISOLATION_LD_PRELOAD="/app/bin/libprocess_isolation.so"
|
||||
- exec zypak-wrapper /app/bin/bitwarden-app "$@"
|
||||
|
||||
@@ -7,12 +7,19 @@ ulimit -c 0
|
||||
RAW_PATH=$(readlink -f "$0")
|
||||
APP_PATH=$(dirname $RAW_PATH)
|
||||
|
||||
# force use of base image libdus in snap
|
||||
if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]
|
||||
then
|
||||
# force use of base image libdbus in snap
|
||||
if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]; then
|
||||
export LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libdbus-1.so.3"
|
||||
fi
|
||||
|
||||
# If running in non-snap, add libprocess_isolation.so from app path to LD_PRELOAD
|
||||
# This prevents debugger / memory dumping on all desktop processes
|
||||
if [ -z "$SNAP" ] && [ -f "$APP_PATH/libprocess_isolation.so" ]; then
|
||||
LIBPROCESS_ISOLATION_SO="$APP_PATH/libprocess_isolation.so"
|
||||
LD_PRELOAD="$LIBPROCESS_ISOLATION_SO${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||
export LD_PRELOAD
|
||||
fi
|
||||
|
||||
PARAMS="--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto"
|
||||
if [ "$USE_X11" = "true" ]; then
|
||||
PARAMS=""
|
||||
|
||||
42
apps/desktop/scripts/nx-serve.js
Normal file
42
apps/desktop/scripts/nx-serve.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const path = require("path");
|
||||
|
||||
const concurrently = require("concurrently");
|
||||
const rimraf = require("rimraf");
|
||||
const args = process.argv.splice(2);
|
||||
const outputPath = path.resolve(__dirname, "../../../dist/apps/desktop");
|
||||
|
||||
rimraf.sync(outputPath);
|
||||
require("fs").mkdirSync(outputPath, { recursive: true });
|
||||
|
||||
concurrently(
|
||||
[
|
||||
{
|
||||
name: "Main",
|
||||
command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name main --watch`,
|
||||
prefixColor: "yellow",
|
||||
},
|
||||
{
|
||||
name: "Prel",
|
||||
command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name preload --watch`,
|
||||
prefixColor: "magenta",
|
||||
},
|
||||
{
|
||||
name: "Rend",
|
||||
command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name renderer --watch`,
|
||||
prefixColor: "cyan",
|
||||
},
|
||||
{
|
||||
name: "Elec",
|
||||
command: `npx wait-on ${outputPath}/main.js ${outputPath}/index.html && npx electron --no-sandbox --inspect=5858 ${args.join(
|
||||
" ",
|
||||
)} ${outputPath} --watch`,
|
||||
prefixColor: "green",
|
||||
},
|
||||
],
|
||||
{
|
||||
prefix: "name",
|
||||
outputStream: process.stdout,
|
||||
killOthers: ["success", "failure"],
|
||||
},
|
||||
);
|
||||
@@ -13,7 +13,7 @@ exports.default = async function (configuration) {
|
||||
`-fd ${configuration.hash} ` +
|
||||
`-du ${configuration.site} ` +
|
||||
`-tr http://timestamp.digicert.com ` +
|
||||
`${configuration.path}`,
|
||||
`"${configuration.path}"`,
|
||||
{
|
||||
stdio: "inherit",
|
||||
},
|
||||
|
||||
@@ -4149,7 +4149,7 @@
|
||||
"message": "Archive",
|
||||
"description": "Verb"
|
||||
},
|
||||
"unarchive": {
|
||||
"unArchive": {
|
||||
"message": "Unarchive"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
@@ -4161,11 +4161,11 @@
|
||||
"noItemsInArchiveDesc": {
|
||||
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
|
||||
},
|
||||
"itemSentToArchive": {
|
||||
"message": "Item sent to archive"
|
||||
"itemWasSentToArchive": {
|
||||
"message": "Item was sent to archive"
|
||||
},
|
||||
"itemRemovedFromArchive": {
|
||||
"message": "Item removed from archive"
|
||||
"itemUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
"archiveItem": {
|
||||
"message": "Archive item"
|
||||
|
||||
@@ -53,7 +53,8 @@ export function isWindowsStore() {
|
||||
if (
|
||||
windows &&
|
||||
!windowsStore &&
|
||||
process.resourcesPath?.indexOf("8bitSolutionsLLC.bitwardendesktop_") > -1
|
||||
(process.resourcesPath?.indexOf("8bitSolutionsLLC.bitwardendesktop_") > -1 ||
|
||||
process.resourcesPath?.indexOf("8bitSolutionsLLC.BitwardenBeta_") > -1)
|
||||
) {
|
||||
windowsStore = true;
|
||||
}
|
||||
|
||||
@@ -723,9 +723,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
|
||||
this.cipherId = cipher.id;
|
||||
this.cipher = cipher;
|
||||
if (this.activeUserId) {
|
||||
await this.cipherService.clearCache(this.activeUserId).catch(() => {});
|
||||
}
|
||||
|
||||
await this.vaultItemsComponent?.load(this.activeFilter.buildFilter()).catch(() => {});
|
||||
await this.go().catch(() => {});
|
||||
await this.vaultItemsComponent?.refresh().catch(() => {});
|
||||
|
||||
@@ -8,7 +8,7 @@ const { AngularWebpackPlugin } = require("@ngtools/webpack");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
|
||||
const { EnvironmentPlugin, DefinePlugin } = require("webpack");
|
||||
const configurator = require("./config/config");
|
||||
const configurator = require(path.resolve(__dirname, "config/config"));
|
||||
|
||||
module.exports.getEnv = function getEnv() {
|
||||
const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV;
|
||||
@@ -17,6 +17,14 @@ module.exports.getEnv = function getEnv() {
|
||||
return { NODE_ENV, ENV };
|
||||
};
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
outputPath: process.env.OUTPUT_PATH
|
||||
? path.isAbsolute(process.env.OUTPUT_PATH)
|
||||
? process.env.OUTPUT_PATH
|
||||
: path.resolve(__dirname, process.env.OUTPUT_PATH)
|
||||
: path.resolve(__dirname, "build"),
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* configName: string;
|
||||
@@ -33,9 +41,11 @@ module.exports.getEnv = function getEnv() {
|
||||
* entry: string;
|
||||
* tsConfig: string;
|
||||
* };
|
||||
* outputPath?: string;
|
||||
* }} params
|
||||
*/
|
||||
module.exports.buildConfig = function buildConfig(params) {
|
||||
params = { ...DEFAULT_PARAMS, ...params };
|
||||
const { NODE_ENV, ENV } = module.exports.getEnv();
|
||||
|
||||
console.log(`Building ${params.configName} Desktop App`);
|
||||
@@ -47,13 +57,16 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
resolve: {
|
||||
extensions: [".tsx", ".ts", ".js"],
|
||||
symlinks: false,
|
||||
modules: [path.resolve("../../node_modules")],
|
||||
modules: [
|
||||
path.resolve(__dirname, "../../node_modules"),
|
||||
path.resolve(process.cwd(), "node_modules"),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const getOutputConfig = (isDev) => ({
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "build"),
|
||||
path: params.outputPath,
|
||||
...(isDev && { devtoolModuleFilenameTemplate: "[absolute-resource-path]" }),
|
||||
});
|
||||
|
||||
@@ -96,9 +109,9 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
plugins: [
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
"./src/package.json",
|
||||
{ from: "./src/images", to: "images" },
|
||||
{ from: "./src/locales", to: "locales" },
|
||||
path.resolve(__dirname, "src/package.json"),
|
||||
{ from: path.resolve(__dirname, "src/images"), to: "images" },
|
||||
{ from: path.resolve(__dirname, "src/locales"), to: "locales" },
|
||||
],
|
||||
}),
|
||||
new DefinePlugin({
|
||||
@@ -164,7 +177,7 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
},
|
||||
output: {
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "build"),
|
||||
path: params.outputPath,
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
@@ -200,7 +213,7 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
{
|
||||
loader: "babel-loader",
|
||||
options: {
|
||||
configFile: "../../babel.config.json",
|
||||
configFile: path.resolve(__dirname, "../../babel.config.json"),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -293,7 +306,7 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
path.resolve(__dirname, "./src"),
|
||||
),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/index.html",
|
||||
template: path.resolve(__dirname, "src/index.html"),
|
||||
filename: "index.html",
|
||||
chunks: ["app/vendor", "app/main"],
|
||||
}),
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
const path = require("path");
|
||||
const { buildConfig } = require("./webpack.base");
|
||||
|
||||
module.exports = buildConfig({
|
||||
configName: "OSS",
|
||||
renderer: {
|
||||
entry: "./src/app/main.ts",
|
||||
entryModule: "src/app/app.module#AppModule",
|
||||
tsConfig: "./tsconfig.renderer.json",
|
||||
},
|
||||
main: {
|
||||
entry: "./src/entry.ts",
|
||||
tsConfig: "./tsconfig.json",
|
||||
},
|
||||
preload: {
|
||||
entry: "./src/preload.ts",
|
||||
tsConfig: "./tsconfig.json",
|
||||
},
|
||||
});
|
||||
module.exports = (webpackConfig, context) => {
|
||||
const isNxBuild = context && context.options;
|
||||
|
||||
if (isNxBuild) {
|
||||
return buildConfig({
|
||||
configName: "OSS",
|
||||
renderer: {
|
||||
entry: path.resolve(__dirname, "src/app/main.ts"),
|
||||
entryModule: "src/app/app.module#AppModule",
|
||||
tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.renderer.json"),
|
||||
},
|
||||
main: {
|
||||
entry: path.resolve(__dirname, "src/entry.ts"),
|
||||
tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.json"),
|
||||
},
|
||||
preload: {
|
||||
entry: path.resolve(__dirname, "src/preload.ts"),
|
||||
tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.json"),
|
||||
},
|
||||
outputPath: path.resolve(context.context.root, context.options.outputPath),
|
||||
});
|
||||
} else {
|
||||
return buildConfig({
|
||||
configName: "OSS",
|
||||
renderer: {
|
||||
entry: path.resolve(__dirname, "src/app/main.ts"),
|
||||
entryModule: "src/app/app.module#AppModule",
|
||||
tsConfig: path.resolve(__dirname, "tsconfig.renderer.json"),
|
||||
},
|
||||
main: {
|
||||
entry: path.resolve(__dirname, "src/entry.ts"),
|
||||
tsConfig: path.resolve(__dirname, "tsconfig.json"),
|
||||
},
|
||||
preload: {
|
||||
entry: path.resolve(__dirname, "src/preload.ts"),
|
||||
tsConfig: path.resolve(__dirname, "tsconfig.json"),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const path = require("path");
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require("postcss-import"),
|
||||
require("postcss-import")({
|
||||
path: [path.resolve(__dirname, "../../libs"), path.resolve(__dirname, "src/scss")],
|
||||
}),
|
||||
require("postcss-nested"),
|
||||
require("tailwindcss"),
|
||||
require("tailwindcss")({ config: path.resolve(__dirname, "tailwind.config.js") }),
|
||||
require("autoprefixer"),
|
||||
],
|
||||
};
|
||||
|
||||
215
apps/web/project.json
Normal file
215
apps/web/project.json
Normal file
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"name": "web",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "apps/web/src",
|
||||
"tags": ["scope:web", "type:app"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/webpack:webpack",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": "oss",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/web",
|
||||
"webpackConfig": "apps/web/webpack.config.js",
|
||||
"tsConfig": "apps/web/tsconfig.json",
|
||||
"main": "apps/web/src/main.ts",
|
||||
"target": "web",
|
||||
"compiler": "tsc"
|
||||
},
|
||||
"configurations": {
|
||||
"oss": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/web/oss"
|
||||
},
|
||||
"oss-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/web/oss-dev",
|
||||
"env": {
|
||||
"NODE_ENV": "development",
|
||||
"ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/web/commercial",
|
||||
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-web/src/main.ts"
|
||||
},
|
||||
"commercial-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/web/commercial-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-web/src/main.ts",
|
||||
"env": {
|
||||
"NODE_ENV": "development",
|
||||
"ENV": "development"
|
||||
}
|
||||
},
|
||||
"commercial-qa": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/web/commercial-qa",
|
||||
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-web/src/main.ts",
|
||||
"env": {
|
||||
"NODE_ENV": "production",
|
||||
"ENV": "qa"
|
||||
}
|
||||
},
|
||||
"commercial-cloud": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/web/commercial-cloud",
|
||||
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-web/src/main.ts",
|
||||
"env": {
|
||||
"NODE_ENV": "production",
|
||||
"ENV": "cloud"
|
||||
}
|
||||
},
|
||||
"commercial-euprd": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/web/commercial-euprd",
|
||||
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-web/src/main.ts",
|
||||
"env": {
|
||||
"NODE_ENV": "production",
|
||||
"ENV": "euprd"
|
||||
}
|
||||
},
|
||||
"commercial-euqa": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/web/commercial-euqa",
|
||||
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-web/src/main.ts",
|
||||
"env": {
|
||||
"NODE_ENV": "production",
|
||||
"ENV": "euqa"
|
||||
}
|
||||
},
|
||||
"commercial-usdev": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/web/commercial-usdev",
|
||||
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-web/src/main.ts",
|
||||
"env": {
|
||||
"NODE_ENV": "production",
|
||||
"ENV": "usdev"
|
||||
}
|
||||
},
|
||||
"commercial-ee": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/web/commercial-ee",
|
||||
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-web/src/main.ts",
|
||||
"env": {
|
||||
"NODE_ENV": "production",
|
||||
"ENV": "ee"
|
||||
}
|
||||
},
|
||||
"oss-selfhost": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/web/oss-selfhost",
|
||||
"env": {
|
||||
"ENV": "selfhosted",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"oss-selfhost-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/web/oss-selfhost-dev",
|
||||
"env": {
|
||||
"NODE_ENV": "development",
|
||||
"ENV": "selfhosted"
|
||||
}
|
||||
},
|
||||
"commercial-selfhost": {
|
||||
"mode": "production",
|
||||
"outputPath": "dist/apps/web/commercial-selfhost",
|
||||
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-web/src/main.ts",
|
||||
"env": {
|
||||
"ENV": "selfhosted",
|
||||
"NODE_ENV": "production"
|
||||
}
|
||||
},
|
||||
"commercial-selfhost-dev": {
|
||||
"mode": "development",
|
||||
"outputPath": "dist/apps/web/commercial-selfhost-dev",
|
||||
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
|
||||
"main": "bitwarden_license/bit-web/src/main.ts",
|
||||
"env": {
|
||||
"NODE_ENV": "development",
|
||||
"ENV": "selfhosted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/webpack:dev-server",
|
||||
"defaultConfiguration": "oss-dev",
|
||||
"options": {
|
||||
"buildTarget": "web:build",
|
||||
"host": "localhost",
|
||||
"port": 8080
|
||||
},
|
||||
"configurations": {
|
||||
"oss": {
|
||||
"buildTarget": "web:build:oss"
|
||||
},
|
||||
"oss-dev": {
|
||||
"buildTarget": "web:build:oss-dev"
|
||||
},
|
||||
"commercial": {
|
||||
"buildTarget": "web:build:commercial"
|
||||
},
|
||||
"commercial-dev": {
|
||||
"buildTarget": "web:build:commercial-dev"
|
||||
},
|
||||
"commercial-qa": {
|
||||
"buildTarget": "web:build:commercial-qa"
|
||||
},
|
||||
"commercial-cloud": {
|
||||
"buildTarget": "web:build:commercial-cloud"
|
||||
},
|
||||
"commercial-euprd": {
|
||||
"buildTarget": "web:build:commercial-euprd"
|
||||
},
|
||||
"commercial-euqa": {
|
||||
"buildTarget": "web:build:commercial-euqa"
|
||||
},
|
||||
"commercial-usdev": {
|
||||
"buildTarget": "web:build:commercial-usdev"
|
||||
},
|
||||
"commercial-ee": {
|
||||
"buildTarget": "web:build:commercial-ee"
|
||||
},
|
||||
"oss-selfhost": {
|
||||
"buildTarget": "web:build:oss-selfhost"
|
||||
},
|
||||
"oss-selfhost-dev": {
|
||||
"buildTarget": "web:build:oss-selfhost-dev"
|
||||
},
|
||||
"commercial-selfhost": {
|
||||
"buildTarget": "web:build:commercial-selfhost"
|
||||
},
|
||||
"commercial-selfhost-dev": {
|
||||
"buildTarget": "web:build:commercial-selfhost-dev"
|
||||
}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "apps/web/jest.config.js"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/web/**/*.ts", "apps/web/**/*.html"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
@@ -146,6 +147,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
private groupService: GroupApiService,
|
||||
private collectionService: CollectionService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
|
||||
private configService: ConfigService,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
@@ -257,7 +259,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
|
||||
this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe(
|
||||
switchMap(([_, organization]) =>
|
||||
this.billingApiService.getOrganizationBillingMetadata(organization.id),
|
||||
this.organizationMetadataService.getOrganizationMetadata$(organization.id),
|
||||
),
|
||||
takeUntilDestroyed(),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
|
||||
@@ -56,7 +56,9 @@ export class ProfileComponent implements OnInit, OnDestroy {
|
||||
this.profile = await this.apiService.getProfile();
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
this.fingerprintMaterial = userId;
|
||||
const publicKey = await firstValueFrom(this.keyService.userPublicKey$(userId));
|
||||
const publicKey = (await firstValueFrom(
|
||||
this.keyService.userPublicKey$(userId),
|
||||
)) as UserPublicKey;
|
||||
if (publicKey == null) {
|
||||
this.logService.error(
|
||||
"[ProfileComponent] No public key available for the user: " +
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
@if (step() == PlanSelectionStep) {
|
||||
<app-upgrade-account (planSelected)="onPlanSelected($event)" (closeClicked)="onCloseClicked()" />
|
||||
<app-upgrade-account
|
||||
[dialogTitleMessageOverride]="planSelectionStepTitleOverride()"
|
||||
[hideContinueWithoutUpgradingButton]="hideContinueWithoutUpgradingButton()"
|
||||
(planSelected)="onPlanSelected($event)"
|
||||
(closeClicked)="onCloseClicked()"
|
||||
/>
|
||||
} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) {
|
||||
<app-upgrade-payment
|
||||
[selectedPlanId]="selectedPlan()"
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Component, input, output } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
|
||||
|
||||
import {
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
} from "../../../types/subscription-pricing-tier";
|
||||
import {
|
||||
UpgradeAccountComponent,
|
||||
UpgradeAccountStatus,
|
||||
} from "../upgrade-account/upgrade-account.component";
|
||||
import {
|
||||
UpgradePaymentComponent,
|
||||
UpgradePaymentResult,
|
||||
} from "../upgrade-payment/upgrade-payment.component";
|
||||
|
||||
import {
|
||||
UnifiedUpgradeDialogComponent,
|
||||
UnifiedUpgradeDialogParams,
|
||||
UnifiedUpgradeDialogStep,
|
||||
} from "./unified-upgrade-dialog.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-upgrade-account",
|
||||
template: "",
|
||||
standalone: true,
|
||||
})
|
||||
class MockUpgradeAccountComponent {
|
||||
dialogTitleMessageOverride = input<string | null>(null);
|
||||
hideContinueWithoutUpgradingButton = input<boolean>(false);
|
||||
planSelected = output<PersonalSubscriptionPricingTierId>();
|
||||
closeClicked = output<UpgradeAccountStatus>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-upgrade-payment",
|
||||
template: "",
|
||||
standalone: true,
|
||||
})
|
||||
class MockUpgradePaymentComponent {
|
||||
selectedPlanId = input<PersonalSubscriptionPricingTierId | null>(null);
|
||||
account = input<Account | null>(null);
|
||||
goBack = output<void>();
|
||||
complete = output<UpgradePaymentResult>();
|
||||
}
|
||||
|
||||
describe("UnifiedUpgradeDialogComponent", () => {
|
||||
let component: UnifiedUpgradeDialogComponent;
|
||||
let fixture: ComponentFixture<UnifiedUpgradeDialogComponent>;
|
||||
const mockDialogRef = mock<DialogRef>();
|
||||
|
||||
const mockAccount: Account = {
|
||||
id: "user-id" as UserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
const defaultDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: null,
|
||||
selectedPlan: null,
|
||||
planSelectionStepTitleOverride: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: defaultDialogData },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should initialize with default values", () => {
|
||||
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
|
||||
expect(component["selectedPlan"]()).toBeNull();
|
||||
expect(component["account"]()).toEqual(mockAccount);
|
||||
expect(component["planSelectionStepTitleOverride"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("should initialize with custom initial step", async () => {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
const customDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: UnifiedUpgradeDialogStep.Payment,
|
||||
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: customDialogData },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
|
||||
const customComponent = customFixture.componentInstance;
|
||||
customFixture.detectChanges();
|
||||
|
||||
expect(customComponent["step"]()).toBe(UnifiedUpgradeDialogStep.Payment);
|
||||
expect(customComponent["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium);
|
||||
});
|
||||
|
||||
describe("custom dialog title", () => {
|
||||
it("should use null as default when no override is provided", () => {
|
||||
expect(component["planSelectionStepTitleOverride"]()).toBeNull();
|
||||
});
|
||||
|
||||
it("should use custom title when provided in dialog config", async () => {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
const customDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: UnifiedUpgradeDialogStep.PlanSelection,
|
||||
selectedPlan: null,
|
||||
planSelectionStepTitleOverride: "upgradeYourPlan",
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: customDialogData },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
|
||||
const customComponent = customFixture.componentInstance;
|
||||
customFixture.detectChanges();
|
||||
|
||||
expect(customComponent["planSelectionStepTitleOverride"]()).toBe("upgradeYourPlan");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onPlanSelected", () => {
|
||||
it("should set selected plan and move to payment step", () => {
|
||||
component["onPlanSelected"](PersonalSubscriptionPricingTierIds.Premium);
|
||||
|
||||
expect(component["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium);
|
||||
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.Payment);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previousStep", () => {
|
||||
it("should go back to plan selection and clear selected plan", () => {
|
||||
component["step"].set(UnifiedUpgradeDialogStep.Payment);
|
||||
component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium);
|
||||
|
||||
component["previousStep"]();
|
||||
|
||||
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
|
||||
expect(component["selectedPlan"]()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hideContinueWithoutUpgradingButton", () => {
|
||||
it("should default to false when not provided", () => {
|
||||
expect(component["hideContinueWithoutUpgradingButton"]()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be set to true when provided in dialog config", async () => {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
const customDialogData: UnifiedUpgradeDialogParams = {
|
||||
account: mockAccount,
|
||||
initialStep: null,
|
||||
selectedPlan: null,
|
||||
hideContinueWithoutUpgradingButton: true,
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: customDialogData },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UnifiedUpgradeDialogComponent, {
|
||||
remove: {
|
||||
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
|
||||
const customComponent = customFixture.componentInstance;
|
||||
customFixture.detectChanges();
|
||||
|
||||
expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -48,11 +48,15 @@ export type UnifiedUpgradeDialogResult = {
|
||||
* @property {Account} account - The user account information.
|
||||
* @property {UnifiedUpgradeDialogStep | null} [initialStep] - The initial step to show in the dialog, if any.
|
||||
* @property {PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any.
|
||||
* @property {string | null} [dialogTitleMessageOverride] - Optional custom i18n key to override the default dialog title.
|
||||
* @property {boolean} [hideContinueWithoutUpgradingButton] - Whether to hide the "Continue without upgrading" button.
|
||||
*/
|
||||
export type UnifiedUpgradeDialogParams = {
|
||||
account: Account;
|
||||
initialStep?: UnifiedUpgradeDialogStep | null;
|
||||
selectedPlan?: PersonalSubscriptionPricingTierId | null;
|
||||
planSelectionStepTitleOverride?: string | null;
|
||||
hideContinueWithoutUpgradingButton?: boolean;
|
||||
};
|
||||
|
||||
@Component({
|
||||
@@ -73,6 +77,8 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
|
||||
protected step = signal<UnifiedUpgradeDialogStep>(UnifiedUpgradeDialogStep.PlanSelection);
|
||||
protected selectedPlan = signal<PersonalSubscriptionPricingTierId | null>(null);
|
||||
protected account = signal<Account | null>(null);
|
||||
protected planSelectionStepTitleOverride = signal<string | null>(null);
|
||||
protected hideContinueWithoutUpgradingButton = signal<boolean>(false);
|
||||
|
||||
protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment;
|
||||
protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection;
|
||||
@@ -86,6 +92,10 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
|
||||
this.account.set(this.params.account);
|
||||
this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection);
|
||||
this.selectedPlan.set(this.params.selectedPlan ?? null);
|
||||
this.planSelectionStepTitleOverride.set(this.params.planSelectionStepTitleOverride ?? null);
|
||||
this.hideContinueWithoutUpgradingButton.set(
|
||||
this.params.hideContinueWithoutUpgradingButton ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="tw-px-14 tw-pb-8">
|
||||
<div class="tw-flex tw-text-center tw-flex-col tw-pb-4">
|
||||
<h1 class="tw-font-semibold tw-text-[32px]">
|
||||
{{ "individualUpgradeWelcomeMessage" | i18n }}
|
||||
{{ dialogTitle() | i18n }}
|
||||
</h1>
|
||||
<p bitTypography="body1" class="tw-text-muted">
|
||||
{{ "individualUpgradeDescriptionMessage" | i18n }}
|
||||
@@ -59,9 +59,11 @@
|
||||
<p bitTypography="helper" class="tw-text-muted tw-italic">
|
||||
{{ "individualUpgradeTaxInformationMessage" | i18n }}
|
||||
</p>
|
||||
<button bitLink linkType="primary" type="button" (click)="closeClicked.emit(closeStatus)">
|
||||
{{ "continueWithoutUpgrading" | i18n }}
|
||||
</button>
|
||||
@if (!hideContinueWithoutUpgradingButton()) {
|
||||
<button bitLink linkType="primary" type="button" (click)="closeClicked.emit(closeStatus)">
|
||||
{{ "continueWithoutUpgrading" | i18n }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -146,4 +146,46 @@ describe("UpgradeAccountComponent", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hideContinueWithoutUpgradingButton", () => {
|
||||
it("should show the continue without upgrading button by default", () => {
|
||||
const button = fixture.nativeElement.querySelector('button[bitLink][linkType="primary"]');
|
||||
expect(button).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should hide the continue without upgrading button when input is true", async () => {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => key);
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
of(mockPricingTiers),
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
UpgradeAccountComponent,
|
||||
PricingCardComponent,
|
||||
CdkTrapFocus,
|
||||
],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService },
|
||||
],
|
||||
})
|
||||
.overrideComponent(UpgradeAccountComponent, {
|
||||
remove: { imports: [BillingServicesModule] },
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
const customFixture = TestBed.createComponent(UpgradeAccountComponent);
|
||||
customFixture.componentRef.setInput("hideContinueWithoutUpgradingButton", true);
|
||||
customFixture.detectChanges();
|
||||
|
||||
const button = customFixture.nativeElement.querySelector(
|
||||
'button[bitLink][linkType="primary"]',
|
||||
);
|
||||
expect(button).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, OnInit, output, signal } from "@angular/core";
|
||||
import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -52,6 +52,8 @@ type CardDetails = {
|
||||
templateUrl: "./upgrade-account.component.html",
|
||||
})
|
||||
export class UpgradeAccountComponent implements OnInit {
|
||||
dialogTitleMessageOverride = input<string | null>(null);
|
||||
hideContinueWithoutUpgradingButton = input<boolean>(false);
|
||||
planSelected = output<PersonalSubscriptionPricingTierId>();
|
||||
closeClicked = output<UpgradeAccountStatus>();
|
||||
protected loading = signal(true);
|
||||
@@ -62,6 +64,10 @@ export class UpgradeAccountComponent implements OnInit {
|
||||
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
|
||||
protected closeStatus = UpgradeAccountStatus.Closed;
|
||||
|
||||
protected dialogTitle = computed(() => {
|
||||
return this.dialogTitleMessageOverride() || "individualUpgradeWelcomeMessage";
|
||||
});
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private subscriptionPricingService: SubscriptionPricingService,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="tw-px-2 tw-mt-3 tw-mb-2 tw-h-10">
|
||||
<div class="tw-rounded-full tw-bg-primary-100 tw-size-full">
|
||||
<!-- Note that this is a custom button style for premium upgrade because the style desired
|
||||
is not supported by the button in the CL. -->
|
||||
<button
|
||||
type="button"
|
||||
class="tw-py-1.5 tw-px-4 tw-flex tw-gap-2 tw-items-center tw-size-full focus-visible:tw-ring-2 focus-visible:tw-ring-offset-0 focus:tw-outline-none focus-visible:tw-outline-none focus-visible:tw-ring-text-alt2 focus-visible:tw-z-10 tw-font-semibold tw-rounded-full tw-transition tw-border tw-border-solid tw-text-left tw-bg-primary-100 tw-text-primary-600 tw-border-primary-600 hover:tw-bg-hover-default hover:tw-text-primary-700 hover:tw-border-primary-700"
|
||||
(click)="openUpgradeDialog()"
|
||||
>
|
||||
<i class="bwi bwi-premium" aria-hidden="true"></i>
|
||||
{{ "upgradeYourPlan" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, inject } from "@angular/core";
|
||||
import { firstValueFrom, lastValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { UnifiedUpgradeDialogComponent } from "../../unified-upgrade-dialog/unified-upgrade-dialog.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-upgrade-nav-button",
|
||||
imports: [I18nPipe],
|
||||
templateUrl: "./upgrade-nav-button.component.html",
|
||||
standalone: true,
|
||||
})
|
||||
export class UpgradeNavButtonComponent {
|
||||
private dialogService = inject(DialogService);
|
||||
private accountService = inject(AccountService);
|
||||
|
||||
openUpgradeDialog = async () => {
|
||||
const account = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
|
||||
data: {
|
||||
account,
|
||||
planSelectionStepTitleOverride: "upgradeYourPlan",
|
||||
hideContinueWithoutUpgradingButton: true,
|
||||
},
|
||||
});
|
||||
|
||||
await lastValueFrom(dialogRef.closed);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, I18nMockService } from "@bitwarden/components";
|
||||
import { UpgradeNavButtonComponent } from "@bitwarden/web-vault/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component";
|
||||
|
||||
export default {
|
||||
title: "Billing/Upgrade Navigation Button",
|
||||
component: UpgradeNavButtonComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
upgradeYourPlan: "Upgrade your plan",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: {
|
||||
open: () => ({
|
||||
closed: of({}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: {
|
||||
activeAccount$: of({
|
||||
id: "user-id" as UserId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
emailVerified: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=858-44274&t=EiNqDGuccfhF14on-1",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<UpgradeNavButtonComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div class="tw-p-4 tw-bg-background-alt3">
|
||||
<app-upgrade-nav-button></app-upgrade-nav-button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -1,8 +1,14 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, mockReset } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
@@ -22,6 +28,8 @@ describe("UpgradePaymentService", () => {
|
||||
const mockLogService = mock<LogService>();
|
||||
const mockApiService = mock<ApiService>();
|
||||
const mockSyncService = mock<SyncService>();
|
||||
const mockOrganizationService = mock<OrganizationService>();
|
||||
const mockAccountService = mock<AccountService>();
|
||||
|
||||
mockApiService.refreshIdentityToken.mockResolvedValue({});
|
||||
mockSyncService.fullSync.mockResolvedValue(true);
|
||||
@@ -94,6 +102,11 @@ describe("UpgradePaymentService", () => {
|
||||
mockReset(mockAccountBillingClient);
|
||||
mockReset(mockTaxClient);
|
||||
mockReset(mockLogService);
|
||||
mockReset(mockOrganizationService);
|
||||
mockReset(mockAccountService);
|
||||
|
||||
mockAccountService.activeAccount$ = of(null);
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([]));
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -108,12 +121,204 @@ describe("UpgradePaymentService", () => {
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: ApiService, useValue: mockApiService },
|
||||
{ provide: SyncService, useValue: mockSyncService },
|
||||
{ provide: OrganizationService, useValue: mockOrganizationService },
|
||||
{ provide: AccountService, useValue: mockAccountService },
|
||||
],
|
||||
});
|
||||
|
||||
sut = TestBed.inject(UpgradePaymentService);
|
||||
});
|
||||
|
||||
describe("userIsOwnerOfFreeOrg$", () => {
|
||||
it("should return true when user is owner of a free organization", (done) => {
|
||||
// Arrange
|
||||
mockReset(mockAccountService);
|
||||
mockReset(mockOrganizationService);
|
||||
|
||||
const mockAccount: Account = {
|
||||
id: "user-id" as UserId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
const paidOrgData = {
|
||||
id: "org-1",
|
||||
name: "Paid Org",
|
||||
useTotp: true, // useTotp = true means NOT free
|
||||
type: OrganizationUserType.Owner,
|
||||
} as OrganizationData;
|
||||
|
||||
const freeOrgData = {
|
||||
id: "org-2",
|
||||
name: "Free Org",
|
||||
useTotp: false, // useTotp = false means IS free
|
||||
type: OrganizationUserType.Owner,
|
||||
} as OrganizationData;
|
||||
|
||||
const paidOrg = new Organization(paidOrgData);
|
||||
const freeOrg = new Organization(freeOrgData);
|
||||
const mockOrganizations = [paidOrg, freeOrg];
|
||||
|
||||
mockAccountService.activeAccount$ = of(mockAccount);
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations));
|
||||
|
||||
const service = new UpgradePaymentService(
|
||||
mockOrganizationBillingService,
|
||||
mockAccountBillingClient,
|
||||
mockTaxClient,
|
||||
mockLogService,
|
||||
mockApiService,
|
||||
mockSyncService,
|
||||
mockOrganizationService,
|
||||
mockAccountService,
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
service.userIsOwnerOfFreeOrg$.subscribe((result) => {
|
||||
expect(result).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false when user is not owner of any free organization", (done) => {
|
||||
// Arrange
|
||||
mockReset(mockAccountService);
|
||||
mockReset(mockOrganizationService);
|
||||
|
||||
const mockAccount: Account = {
|
||||
id: "user-id" as UserId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
const paidOrgData = {
|
||||
id: "org-1",
|
||||
name: "Paid Org",
|
||||
useTotp: true, // useTotp = true means NOT free
|
||||
type: OrganizationUserType.Owner,
|
||||
} as OrganizationData;
|
||||
|
||||
const freeOrgData = {
|
||||
id: "org-2",
|
||||
name: "Free Org",
|
||||
useTotp: false, // useTotp = false means IS free
|
||||
type: OrganizationUserType.User, // Not owner
|
||||
} as OrganizationData;
|
||||
|
||||
const paidOrg = new Organization(paidOrgData);
|
||||
const freeOrg = new Organization(freeOrgData);
|
||||
const mockOrganizations = [paidOrg, freeOrg];
|
||||
|
||||
mockAccountService.activeAccount$ = of(mockAccount);
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations));
|
||||
|
||||
const service = new UpgradePaymentService(
|
||||
mockOrganizationBillingService,
|
||||
mockAccountBillingClient,
|
||||
mockTaxClient,
|
||||
mockLogService,
|
||||
mockApiService,
|
||||
mockSyncService,
|
||||
mockOrganizationService,
|
||||
mockAccountService,
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
service.userIsOwnerOfFreeOrg$.subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false when user has no organizations", (done) => {
|
||||
// Arrange
|
||||
mockReset(mockAccountService);
|
||||
mockReset(mockOrganizationService);
|
||||
|
||||
const mockAccount: Account = {
|
||||
id: "user-id" as UserId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
mockAccountService.activeAccount$ = of(mockAccount);
|
||||
mockOrganizationService.organizations$.mockReturnValue(of([]));
|
||||
|
||||
const service = new UpgradePaymentService(
|
||||
mockOrganizationBillingService,
|
||||
mockAccountBillingClient,
|
||||
mockTaxClient,
|
||||
mockLogService,
|
||||
mockApiService,
|
||||
mockSyncService,
|
||||
mockOrganizationService,
|
||||
mockAccountService,
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
service.userIsOwnerOfFreeOrg$.subscribe((result) => {
|
||||
expect(result).toBe(false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("adminConsoleRouteForOwnedOrganization$", () => {
|
||||
it("should return the admin console route for the first free organization the user owns", (done) => {
|
||||
// Arrange
|
||||
mockReset(mockAccountService);
|
||||
mockReset(mockOrganizationService);
|
||||
|
||||
const mockAccount: Account = {
|
||||
id: "user-id" as UserId,
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
emailVerified: true,
|
||||
};
|
||||
|
||||
const paidOrgData = {
|
||||
id: "org-1",
|
||||
name: "Paid Org",
|
||||
useTotp: true, // useTotp = true means NOT free
|
||||
type: OrganizationUserType.Owner,
|
||||
} as OrganizationData;
|
||||
|
||||
const freeOrgData = {
|
||||
id: "org-2",
|
||||
name: "Free Org",
|
||||
useTotp: false, // useTotp = false means IS free
|
||||
type: OrganizationUserType.Owner,
|
||||
} as OrganizationData;
|
||||
|
||||
const paidOrg = new Organization(paidOrgData);
|
||||
const freeOrg = new Organization(freeOrgData);
|
||||
const mockOrganizations = [paidOrg, freeOrg];
|
||||
|
||||
mockAccountService.activeAccount$ = of(mockAccount);
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations));
|
||||
|
||||
const service = new UpgradePaymentService(
|
||||
mockOrganizationBillingService,
|
||||
mockAccountBillingClient,
|
||||
mockTaxClient,
|
||||
mockLogService,
|
||||
mockApiService,
|
||||
mockSyncService,
|
||||
mockOrganizationService,
|
||||
mockAccountService,
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
service.adminConsoleRouteForOwnedOrganization$.subscribe((result) => {
|
||||
expect(result).toBe("/organizations/org-2/billing/subscription");
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEstimatedTax", () => {
|
||||
it("should calculate tax for premium plan", async () => {
|
||||
// Arrange
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { defaultIfEmpty, find, map, mergeMap, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import {
|
||||
OrganizationBillingServiceAbstraction,
|
||||
SubscriptionInformation,
|
||||
@@ -53,8 +57,28 @@ export class UpgradePaymentService {
|
||||
private logService: LogService,
|
||||
private apiService: ApiService,
|
||||
private syncService: SyncService,
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
userIsOwnerOfFreeOrg$: Observable<boolean> = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((id) => this.organizationService.organizations$(id)),
|
||||
mergeMap((userOrganizations) => userOrganizations),
|
||||
find((org) => org.isFreeOrg && org.isOwner),
|
||||
defaultIfEmpty(false),
|
||||
map((value) => value instanceof Organization),
|
||||
);
|
||||
|
||||
adminConsoleRouteForOwnedOrganization$: Observable<string> =
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((id) => this.organizationService.organizations$(id)),
|
||||
mergeMap((userOrganizations) => userOrganizations),
|
||||
find((org) => org.isFreeOrg && org.isOwner),
|
||||
map((org) => `/organizations/${org!.id}/billing/subscription`),
|
||||
);
|
||||
|
||||
/**
|
||||
* Calculate estimated tax for the selected plan
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,22 @@
|
||||
<ng-container bitDialogContent>
|
||||
<section>
|
||||
@if (isFamiliesPlan) {
|
||||
@if (userIsOwnerOfFreeOrg$ | async) {
|
||||
<div class="tw-pb-2">
|
||||
<bit-callout type="info">
|
||||
{{ "formWillCreateNewFamiliesOrgMessage" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
bitDialogClose
|
||||
linkType="primary"
|
||||
[routerLink]="adminConsoleRouteForOwnedOrganization$ | async"
|
||||
>
|
||||
{{ "upgradeNow" | i18n }}
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-callout>
|
||||
</div>
|
||||
}
|
||||
<div class="tw-pb-4">
|
||||
<bit-form-field class="!tw-mb-0">
|
||||
<bit-label>{{ "organizationName" | i18n }}</bit-label>
|
||||
|
||||
@@ -104,6 +104,10 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
||||
private upgradePaymentService: UpgradePaymentService,
|
||||
) {}
|
||||
|
||||
protected userIsOwnerOfFreeOrg$ = this.upgradePaymentService.userIsOwnerOfFreeOrg$;
|
||||
protected adminConsoleRouteForOwnedOrganization$ =
|
||||
this.upgradePaymentService.adminConsoleRouteForOwnedOrganization$;
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
if (!this.isFamiliesPlan) {
|
||||
this.formGroup.controls.organizationName.disable();
|
||||
|
||||
@@ -148,19 +148,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
|
||||
const isResoldOrganizationOwner = this.userOrg.hasReseller && this.userOrg.isOwner;
|
||||
const isMSPUser = this.userOrg.hasProvider && this.userOrg.isProviderUser;
|
||||
|
||||
const metadata = await this.billingApiService.getOrganizationBillingMetadata(
|
||||
this.organizationId,
|
||||
);
|
||||
|
||||
this.organizationIsManagedByConsolidatedBillingMSP =
|
||||
this.userOrg.hasProvider && metadata.isManaged;
|
||||
this.userOrg.hasProvider && this.userOrg.hasBillableProvider;
|
||||
|
||||
this.showSubscription =
|
||||
isIndependentOrganizationOwner ||
|
||||
isResoldOrganizationOwner ||
|
||||
(isMSPUser && !this.organizationIsManagedByConsolidatedBillingMSP);
|
||||
|
||||
this.showSelfHost = metadata.isEligibleForSelfHost;
|
||||
this.showSelfHost =
|
||||
this.userOrg.productTierType === ProductTierType.Families ||
|
||||
this.userOrg.productTierType === ProductTierType.Enterprise;
|
||||
|
||||
if (this.showSubscription) {
|
||||
this.sub = await this.organizationApiService.getSubscription(this.organizationId);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { UnsignedPublicKey, WrappedPrivateKey } from "@bitwarden/common/key-management/types";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SignedPublicKey } from "@bitwarden/sdk-internal";
|
||||
|
||||
export class PublicKeyEncryptionKeyPairRequestModel {
|
||||
wrappedPrivateKey: WrappedPrivateKey;
|
||||
publicKey: string;
|
||||
signedPublicKey: SignedPublicKey | null;
|
||||
|
||||
constructor(
|
||||
wrappedPrivateKey: WrappedPrivateKey,
|
||||
publicKey: UnsignedPublicKey,
|
||||
signedPublicKey: SignedPublicKey | null,
|
||||
) {
|
||||
this.wrappedPrivateKey = wrappedPrivateKey;
|
||||
this.publicKey = Utils.fromBufferToB64(publicKey);
|
||||
this.signedPublicKey = signedPublicKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { VerifyingKey, WrappedSigningKey } from "@bitwarden/common/key-management/types";
|
||||
import { SignatureAlgorithm } from "@bitwarden/sdk-internal";
|
||||
|
||||
export class SignatureKeyPairRequestModel {
|
||||
signatureAlgorithm: SignatureAlgorithm;
|
||||
wrappedSigningKey: WrappedSigningKey;
|
||||
verifyingKey: VerifyingKey;
|
||||
|
||||
constructor(
|
||||
signingKey: WrappedSigningKey,
|
||||
verifyingKey: VerifyingKey,
|
||||
signingKeyAlgorithm: SignatureAlgorithm,
|
||||
) {
|
||||
this.signatureAlgorithm = signingKeyAlgorithm;
|
||||
this.wrappedSigningKey = signingKey;
|
||||
this.verifyingKey = verifyingKey;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,70 @@
|
||||
export class AccountKeysRequest {
|
||||
// Other keys encrypted by the userkey
|
||||
userKeyEncryptedAccountPrivateKey: string;
|
||||
accountPublicKey: string;
|
||||
import { SecurityStateRequest } from "@bitwarden/common/key-management/security-state/request/security-state.request";
|
||||
import { WrappedPrivateKey } from "@bitwarden/common/key-management/types";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
constructor(userKeyEncryptedAccountPrivateKey: string, accountPublicKey: string) {
|
||||
this.userKeyEncryptedAccountPrivateKey = userKeyEncryptedAccountPrivateKey;
|
||||
this.accountPublicKey = accountPublicKey;
|
||||
import { PublicKeyEncryptionKeyPairRequestModel } from "../model/public-key-encryption-key-pair-request.model";
|
||||
import { SignatureKeyPairRequestModel } from "../model/signature-key-pair-request-request.model";
|
||||
import { V1UserCryptographicState } from "../types/v1-cryptographic-state";
|
||||
import { V2UserCryptographicState } from "../types/v2-cryptographic-state";
|
||||
|
||||
// This request contains other account-owned keys that are encrypted with the user key.
|
||||
export class AccountKeysRequest {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
userKeyEncryptedAccountPrivateKey: WrappedPrivateKey | null = null;
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
accountPublicKey: string | null = null;
|
||||
|
||||
publicKeyEncryptionKeyPair: PublicKeyEncryptionKeyPairRequestModel | null = null;
|
||||
signatureKeyPair: SignatureKeyPairRequestModel | null = null;
|
||||
securityState: SecurityStateRequest | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
static fromV1CryptographicState(state: V1UserCryptographicState): AccountKeysRequest {
|
||||
const request = new AccountKeysRequest();
|
||||
request.userKeyEncryptedAccountPrivateKey = state.publicKeyEncryptionKeyPair.wrappedPrivateKey;
|
||||
request.accountPublicKey = Utils.fromBufferToB64(state.publicKeyEncryptionKeyPair.publicKey);
|
||||
request.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel(
|
||||
state.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
state.publicKeyEncryptionKeyPair.publicKey,
|
||||
null,
|
||||
);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
static async fromV2CryptographicState(
|
||||
state: V2UserCryptographicState,
|
||||
): Promise<AccountKeysRequest> {
|
||||
// Ensure the SDK is loaded, since it is used to derive the signature algorithm.
|
||||
await SdkLoadService.Ready;
|
||||
|
||||
const request = new AccountKeysRequest();
|
||||
request.userKeyEncryptedAccountPrivateKey = state.publicKeyEncryptionKeyPair.wrappedPrivateKey!;
|
||||
request.accountPublicKey = Utils.fromBufferToB64(state.publicKeyEncryptionKeyPair.publicKey);
|
||||
request.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel(
|
||||
state.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
state.publicKeyEncryptionKeyPair.publicKey,
|
||||
state.publicKeyEncryptionKeyPair.signedPublicKey,
|
||||
);
|
||||
request.signatureKeyPair = new SignatureKeyPairRequestModel(
|
||||
state.signatureKeyPair.wrappedSigningKey,
|
||||
state.signatureKeyPair.verifyingKey,
|
||||
PureCrypto.key_algorithm_for_verifying_key(
|
||||
Utils.fromB64ToArray(state.signatureKeyPair.verifyingKey),
|
||||
),
|
||||
);
|
||||
request.securityState = new SecurityStateRequest(
|
||||
state.securityState.securityState,
|
||||
state.securityState.securityStateVersion,
|
||||
);
|
||||
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { UnsignedPublicKey, WrappedPrivateKey } from "@bitwarden/common/key-management/types";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
export type V1UserCryptographicState = {
|
||||
userKey: UserKey;
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: WrappedPrivateKey;
|
||||
publicKey: UnsignedPublicKey;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
SignedSecurityState,
|
||||
UnsignedPublicKey,
|
||||
VerifyingKey,
|
||||
WrappedPrivateKey,
|
||||
WrappedSigningKey,
|
||||
} from "@bitwarden/common/key-management/types";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { SignedPublicKey, UserCryptoV2KeysResponse } from "@bitwarden/sdk-internal";
|
||||
|
||||
export type V2UserCryptographicState = {
|
||||
userKey: UserKey;
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: WrappedPrivateKey;
|
||||
publicKey: UnsignedPublicKey;
|
||||
signedPublicKey: SignedPublicKey;
|
||||
};
|
||||
signatureKeyPair: {
|
||||
wrappedSigningKey: WrappedSigningKey;
|
||||
verifyingKey: VerifyingKey;
|
||||
};
|
||||
securityState: {
|
||||
securityState: SignedSecurityState;
|
||||
securityStateVersion: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function fromSdkV2KeysToV2UserCryptographicState(
|
||||
response: UserCryptoV2KeysResponse,
|
||||
): V2UserCryptographicState {
|
||||
return {
|
||||
userKey: SymmetricCryptoKey.fromString(response.userKey) as UserKey,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: response.privateKey as WrappedPrivateKey,
|
||||
publicKey: Utils.fromB64ToArray(response.publicKey) as UnsignedPublicKey,
|
||||
signedPublicKey: response.signedPublicKey,
|
||||
},
|
||||
signatureKeyPair: {
|
||||
wrappedSigningKey: response.signingKey as WrappedSigningKey,
|
||||
verifyingKey: response.verifyingKey as VerifyingKey,
|
||||
},
|
||||
securityState: {
|
||||
securityState: response.securityState as SignedSecurityState,
|
||||
securityStateVersion: response.securityVersion,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { BehaviorSubject } from "rxjs";
|
||||
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common";
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import {
|
||||
@@ -11,10 +12,22 @@ import {
|
||||
EncString,
|
||||
} from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
import {
|
||||
SignedPublicKey,
|
||||
SignedSecurityState,
|
||||
UnsignedPublicKey,
|
||||
VerifyingKey,
|
||||
WrappedPrivateKey,
|
||||
WrappedSigningKey,
|
||||
} from "@bitwarden/common/key-management/types";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { HashPurpose } 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 { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
|
||||
@@ -33,13 +46,14 @@ import {
|
||||
PBKDF2KdfConfig,
|
||||
KdfConfigService,
|
||||
KdfConfig,
|
||||
KdfType,
|
||||
} from "@bitwarden/key-management";
|
||||
import {
|
||||
AccountRecoveryTrustComponent,
|
||||
EmergencyAccessTrustComponent,
|
||||
KeyRotationTrustInfoComponent,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
import { BitwardenClient, PureCrypto } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
|
||||
import { WebauthnLoginAdminService } from "../../auth";
|
||||
@@ -48,11 +62,18 @@ import { EmergencyAccessStatusType } from "../../auth/emergency-access/enums/eme
|
||||
import { EmergencyAccessType } from "../../auth/emergency-access/enums/emergency-access-type";
|
||||
import { EmergencyAccessWithIdRequest } from "../../auth/emergency-access/request/emergency-access-update.request";
|
||||
|
||||
import { AccountKeysRequest } from "./request/account-keys.request";
|
||||
import { MasterPasswordUnlockDataRequest } from "./request/master-password-unlock-data.request";
|
||||
import { UnlockDataRequest } from "./request/unlock-data.request";
|
||||
import { UserDataRequest } from "./request/userdata.request";
|
||||
import { V1UserCryptographicState } from "./types/v1-cryptographic-state";
|
||||
import { V2UserCryptographicState } from "./types/v2-cryptographic-state";
|
||||
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
|
||||
import { UserKeyRotationService } from "./user-key-rotation.service";
|
||||
import {
|
||||
UserKeyRotationService,
|
||||
V1CryptographicStateParameters,
|
||||
V2CryptographicStateParameters,
|
||||
} from "./user-key-rotation.service";
|
||||
|
||||
const initialPromptedOpenTrue = jest.fn();
|
||||
initialPromptedOpenTrue.mockReturnValue({ closed: new BehaviorSubject(true) });
|
||||
@@ -120,6 +141,21 @@ function createMockWebauthn(id: string): any {
|
||||
} as WebauthnRotateCredentialRequest;
|
||||
}
|
||||
|
||||
const TEST_VECTOR_USER_KEY_V1 = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const TEST_VECTOR_PRIVATE_KEY_V1 =
|
||||
"2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|YvFBff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=" as WrappedPrivateKey;
|
||||
const TEST_VECTOR_PUBLIC_KEY_V1 = Utils.fromBufferToB64(new Uint8Array(400));
|
||||
const TEST_VECTOR_PRIVATE_KEY_V1_ROTATED =
|
||||
"2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|AAAAff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=" as WrappedPrivateKey;
|
||||
|
||||
const TEST_VECTOR_USER_KEY_V2 = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey;
|
||||
const TEST_VECTOR_PRIVATE_KEY_V2 = "7.AAAw2vTUePO+CCyokcIfVw==" as WrappedPrivateKey;
|
||||
const TEST_VECTOR_SIGNING_KEY_V2 = "7.AAAw2vTUePO+CCyokcIfVw==" as WrappedSigningKey;
|
||||
const TEST_VECTOR_VERIFYING_KEY_V2 = "AAAw2vTUePO+CCyokcIfVw==" as VerifyingKey;
|
||||
const TEST_VECTOR_SECURITY_STATE_V2 = "AAAw2vTUePO+CCyokcIfVw==" as SignedSecurityState;
|
||||
const TEST_VECTOR_PUBLIC_KEY_V2 = Utils.fromBufferToB64(new Uint8Array(400));
|
||||
const TEST_VECTOR_SIGNED_PUBLIC_KEY_V2 = "AAAw2vTUePO+CCyokcIfVw==" as SignedPublicKey;
|
||||
|
||||
class TestUserKeyRotationService extends UserKeyRotationService {
|
||||
override rotateUserKeyMasterPasswordAndEncryptedData(
|
||||
currentMasterPassword: string,
|
||||
@@ -138,22 +174,17 @@ class TestUserKeyRotationService extends UserKeyRotationService {
|
||||
return super.ensureIsAllowedToRotateUserKey();
|
||||
}
|
||||
override getNewAccountKeysV1(
|
||||
currentUserKey: UserKey,
|
||||
currentUserKeyWrappedPrivateKey: EncString,
|
||||
): Promise<{
|
||||
userKey: UserKey;
|
||||
asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string };
|
||||
}> {
|
||||
return super.getNewAccountKeysV1(currentUserKey, currentUserKeyWrappedPrivateKey);
|
||||
cryptographicStateParameters: V1CryptographicStateParameters,
|
||||
): Promise<V1UserCryptographicState> {
|
||||
return super.getNewAccountKeysV1(cryptographicStateParameters);
|
||||
}
|
||||
override getNewAccountKeysV2(
|
||||
currentUserKey: UserKey,
|
||||
currentUserKeyWrappedPrivateKey: EncString,
|
||||
): Promise<{
|
||||
userKey: UserKey;
|
||||
asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string };
|
||||
}> {
|
||||
return super.getNewAccountKeysV2(currentUserKey, currentUserKeyWrappedPrivateKey);
|
||||
userId: UserId,
|
||||
kdfConfig: KdfConfig,
|
||||
email: string,
|
||||
cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters,
|
||||
): Promise<V2UserCryptographicState> {
|
||||
return super.getNewAccountKeysV2(userId, kdfConfig, email, cryptographicStateParameters);
|
||||
}
|
||||
override createMasterPasswordUnlockDataRequest(
|
||||
userKey: UserKey,
|
||||
@@ -176,8 +207,8 @@ class TestUserKeyRotationService extends UserKeyRotationService {
|
||||
masterKeyKdfConfig: KdfConfig;
|
||||
masterPasswordHint: string;
|
||||
},
|
||||
trustedEmergencyAccessGranteesPublicKeys: Uint8Array[],
|
||||
trustedOrganizationPublicKeys: Uint8Array[],
|
||||
trustedEmergencyAccessGranteesPublicKeys: UnsignedPublicKey[],
|
||||
trustedOrganizationPublicKeys: UnsignedPublicKey[],
|
||||
): Promise<UnlockDataRequest> {
|
||||
return super.getAccountUnlockDataRequest(
|
||||
userId,
|
||||
@@ -190,8 +221,8 @@ class TestUserKeyRotationService extends UserKeyRotationService {
|
||||
}
|
||||
override verifyTrust(user: Account): Promise<{
|
||||
wasTrustDenied: boolean;
|
||||
trustedOrganizationPublicKeys: Uint8Array[];
|
||||
trustedEmergencyAccessUserPublicKeys: Uint8Array[];
|
||||
trustedOrganizationPublicKeys: UnsignedPublicKey[];
|
||||
trustedEmergencyAccessUserPublicKeys: UnsignedPublicKey[];
|
||||
}> {
|
||||
return super.verifyTrust(user);
|
||||
}
|
||||
@@ -202,14 +233,6 @@ class TestUserKeyRotationService extends UserKeyRotationService {
|
||||
): Promise<UserDataRequest> {
|
||||
return super.getAccountDataRequest(originalUserKey, newUnencryptedUserKey, user);
|
||||
}
|
||||
override makeNewUserKeyV1(oldUserKey: UserKey): Promise<UserKey> {
|
||||
return super.makeNewUserKeyV1(oldUserKey);
|
||||
}
|
||||
override makeNewUserKeyV2(
|
||||
oldUserKey: UserKey,
|
||||
): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> {
|
||||
return super.makeNewUserKeyV2(oldUserKey);
|
||||
}
|
||||
override isV1User(userKey: UserKey): boolean {
|
||||
return super.isV1User(userKey);
|
||||
}
|
||||
@@ -227,6 +250,13 @@ class TestUserKeyRotationService extends UserKeyRotationService {
|
||||
masterKeySalt,
|
||||
);
|
||||
}
|
||||
override getCryptographicStateForUser(user: Account): Promise<{
|
||||
masterKeyKdfConfig: KdfConfig;
|
||||
masterKeySalt: string;
|
||||
cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters;
|
||||
}> {
|
||||
return super.getCryptographicStateForUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
describe("KeyRotationService", () => {
|
||||
@@ -251,6 +281,8 @@ describe("KeyRotationService", () => {
|
||||
let mockI18nService: MockProxy<I18nService>;
|
||||
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||
let mockKdfConfigService: MockProxy<KdfConfigService>;
|
||||
let mockSdkClientFactory: MockProxy<SdkClientFactory>;
|
||||
let mockSecurityStateService: MockProxy<SecurityStateService>;
|
||||
|
||||
const mockUser = {
|
||||
id: "mockUserId" as UserId,
|
||||
@@ -261,6 +293,9 @@ describe("KeyRotationService", () => {
|
||||
|
||||
const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")];
|
||||
|
||||
const mockMakeKeysForUserCryptoV2 = jest.fn();
|
||||
const mockGetV2RotatedAccountKeys = jest.fn();
|
||||
|
||||
beforeAll(() => {
|
||||
mockApiService = mock<UserKeyRotationApiService>();
|
||||
mockCipherService = mock<CipherService>();
|
||||
@@ -271,7 +306,7 @@ describe("KeyRotationService", () => {
|
||||
mockTrustedPublicKeys.map((key) => {
|
||||
return {
|
||||
publicKey: key,
|
||||
id: "mockId",
|
||||
id: "00000000-0000-0000-0000-000000000000" as UserId,
|
||||
granteeId: "mockGranteeId",
|
||||
name: "mockName",
|
||||
email: "mockEmail",
|
||||
@@ -306,6 +341,17 @@ describe("KeyRotationService", () => {
|
||||
mockDialogService = mock<DialogService>();
|
||||
mockCryptoFunctionService = mock<CryptoFunctionService>();
|
||||
mockKdfConfigService = mock<KdfConfigService>();
|
||||
mockSdkClientFactory = mock<SdkClientFactory>();
|
||||
mockSdkClientFactory.createSdkClient.mockResolvedValue({
|
||||
crypto: () => {
|
||||
return {
|
||||
initialize_user_crypto: jest.fn(),
|
||||
make_keys_for_user_crypto_v2: mockMakeKeysForUserCryptoV2,
|
||||
get_v2_rotated_account_keys: mockGetV2RotatedAccountKeys,
|
||||
} as any;
|
||||
},
|
||||
} as BitwardenClient);
|
||||
mockSecurityStateService = mock<SecurityStateService>();
|
||||
|
||||
keyRotationService = new TestUserKeyRotationService(
|
||||
mockApiService,
|
||||
@@ -327,6 +373,8 @@ describe("KeyRotationService", () => {
|
||||
mockConfigService,
|
||||
mockCryptoFunctionService,
|
||||
mockKdfConfigService,
|
||||
mockSdkClientFactory,
|
||||
mockSecurityStateService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -334,13 +382,16 @@ describe("KeyRotationService", () => {
|
||||
jest.clearAllMocks();
|
||||
jest.mock("@bitwarden/key-management-ui");
|
||||
jest.spyOn(PureCrypto, "make_user_key_aes256_cbc_hmac").mockReturnValue(new Uint8Array(64));
|
||||
jest.spyOn(PureCrypto, "make_user_key_xchacha20_poly1305").mockReturnValue(new Uint8Array(70));
|
||||
jest
|
||||
.spyOn(PureCrypto, "encrypt_user_key_with_master_password")
|
||||
.mockReturnValue("mockNewUserKey");
|
||||
Object.defineProperty(SdkLoadService, "Ready", {
|
||||
value: Promise.resolve(),
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotateUserKeyAndEncryptedData", () => {
|
||||
describe("rotateUserKeyMasterPasswordAndEncryptedData", () => {
|
||||
let privateKey: BehaviorSubject<UserPrivateKey | null>;
|
||||
let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>;
|
||||
|
||||
@@ -438,6 +489,64 @@ describe("KeyRotationService", () => {
|
||||
expect(arg.accountUnlockData.passkeyUnlockData.length).toBe(2);
|
||||
});
|
||||
|
||||
it("passes the EnrollAeadOnKeyRotation feature flag to getRotatedAccountKeysFlagged", async () => {
|
||||
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
|
||||
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
|
||||
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
|
||||
mockKdfConfigService.getKdfConfig$.mockReturnValue(
|
||||
new BehaviorSubject(new PBKDF2KdfConfig(100000)),
|
||||
);
|
||||
mockKeyService.userKey$.mockReturnValue(
|
||||
new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey),
|
||||
);
|
||||
mockKeyService.userEncryptedPrivateKey$.mockReturnValue(
|
||||
new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V1 as string as EncryptedString),
|
||||
);
|
||||
mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null));
|
||||
mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null));
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const spy = jest.spyOn(keyRotationService, "getRotatedAccountKeysFlagged").mockResolvedValue({
|
||||
userKey: TEST_VECTOR_USER_KEY_V2,
|
||||
accountKeysRequest: {
|
||||
userKeyEncryptedAccountPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
|
||||
accountPublicKey: TEST_VECTOR_PUBLIC_KEY_V2,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
|
||||
publicKey: TEST_VECTOR_PUBLIC_KEY_V2,
|
||||
signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
|
||||
},
|
||||
signatureKeyPair: {
|
||||
wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2,
|
||||
verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2,
|
||||
signatureAlgorithm: "ed25519",
|
||||
},
|
||||
securityState: {
|
||||
securityState: TEST_VECTOR_SECURITY_STATE_V2,
|
||||
securityVersion: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
|
||||
"mockMasterPassword",
|
||||
"mockMasterPassword1",
|
||||
mockUser,
|
||||
"masterPasswordHint",
|
||||
);
|
||||
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.EnrollAeadOnKeyRotation,
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
mockUser.email,
|
||||
expect.objectContaining({ version: 1 }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if kdf config is null", async () => {
|
||||
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
|
||||
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
|
||||
@@ -511,17 +620,17 @@ describe("KeyRotationService", () => {
|
||||
});
|
||||
|
||||
describe("getNewAccountKeysV1", () => {
|
||||
const currentUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const mockEncryptedPrivateKey = new EncString(
|
||||
"2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=",
|
||||
);
|
||||
const mockNewEncryptedPrivateKey = new EncString(
|
||||
"2.ab465OrUcluL9UpnCOUTAg==|4HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=",
|
||||
);
|
||||
const currentUserKey = TEST_VECTOR_USER_KEY_V1;
|
||||
const mockEncryptedPrivateKey = TEST_VECTOR_PRIVATE_KEY_V1 as WrappedPrivateKey;
|
||||
const mockNewEncryptedPrivateKey = TEST_VECTOR_PRIVATE_KEY_V1_ROTATED as WrappedPrivateKey;
|
||||
beforeAll(() => {
|
||||
mockEncryptService.unwrapDecapsulationKey.mockResolvedValue(new Uint8Array(200));
|
||||
mockEncryptService.wrapDecapsulationKey.mockResolvedValue(mockNewEncryptedPrivateKey);
|
||||
mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(new Uint8Array(400));
|
||||
mockEncryptService.wrapDecapsulationKey.mockResolvedValue(
|
||||
new EncString(mockNewEncryptedPrivateKey),
|
||||
);
|
||||
mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(
|
||||
new Uint8Array(400) as UnsignedPublicKey,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -529,28 +638,110 @@ describe("KeyRotationService", () => {
|
||||
});
|
||||
|
||||
it("returns new account keys", async () => {
|
||||
const result = await keyRotationService.getNewAccountKeysV1(
|
||||
currentUserKey,
|
||||
mockEncryptedPrivateKey,
|
||||
);
|
||||
const result = await keyRotationService.getNewAccountKeysV1({
|
||||
version: 1,
|
||||
userKey: currentUserKey,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: mockEncryptedPrivateKey,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
userKey: expect.any(SymmetricCryptoKey),
|
||||
asymmetricEncryptionKeys: {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: mockNewEncryptedPrivateKey,
|
||||
publicKey: Utils.fromBufferToB64(new Uint8Array(400)),
|
||||
publicKey: new Uint8Array(400) as UserPublicKey,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNewAccountKeysV2", () => {
|
||||
it("throws not supported", async () => {
|
||||
await expect(
|
||||
keyRotationService.getNewAccountKeysV2(
|
||||
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
|
||||
null,
|
||||
),
|
||||
).rejects.toThrow("User encryption v2 upgrade is not supported yet");
|
||||
it("rotates a v2 user", async () => {
|
||||
mockGetV2RotatedAccountKeys.mockReturnValue({
|
||||
userKey: TEST_VECTOR_USER_KEY_V2.toBase64(),
|
||||
privateKey: TEST_VECTOR_PRIVATE_KEY_V2,
|
||||
publicKey: TEST_VECTOR_PUBLIC_KEY_V2,
|
||||
signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
|
||||
signingKey: TEST_VECTOR_SIGNING_KEY_V2,
|
||||
verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2,
|
||||
securityState: TEST_VECTOR_SECURITY_STATE_V2,
|
||||
securityVersion: 2,
|
||||
});
|
||||
const result = await keyRotationService.getNewAccountKeysV2(
|
||||
"00000000-0000-0000-0000-000000000000" as UserId,
|
||||
new PBKDF2KdfConfig(600_000),
|
||||
"mockuseremail",
|
||||
{
|
||||
version: 2 as const,
|
||||
userKey: TEST_VECTOR_USER_KEY_V2,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
|
||||
},
|
||||
signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
|
||||
securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,
|
||||
},
|
||||
);
|
||||
expect(mockGetV2RotatedAccountKeys).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
userKey: TEST_VECTOR_USER_KEY_V2,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
|
||||
signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
|
||||
},
|
||||
signatureKeyPair: {
|
||||
wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
|
||||
verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey,
|
||||
},
|
||||
securityState: {
|
||||
securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,
|
||||
securityStateVersion: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
it("upgrades v1 user to v2 user", async () => {
|
||||
mockMakeKeysForUserCryptoV2.mockReturnValue({
|
||||
userKey: TEST_VECTOR_USER_KEY_V2.toBase64(),
|
||||
privateKey: TEST_VECTOR_PRIVATE_KEY_V2,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
|
||||
signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
|
||||
signingKey: TEST_VECTOR_SIGNING_KEY_V2,
|
||||
verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2,
|
||||
securityState: TEST_VECTOR_SECURITY_STATE_V2,
|
||||
securityVersion: 2,
|
||||
});
|
||||
const result = await keyRotationService.getNewAccountKeysV2(
|
||||
"00000000-0000-0000-0000-000000000000" as UserId,
|
||||
new PBKDF2KdfConfig(600_000),
|
||||
"mockuseremail",
|
||||
{
|
||||
version: 1,
|
||||
userKey: TEST_VECTOR_USER_KEY_V1,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1 as WrappedPrivateKey,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(mockMakeKeysForUserCryptoV2).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
userKey: TEST_VECTOR_USER_KEY_V2,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2),
|
||||
signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
|
||||
},
|
||||
signatureKeyPair: {
|
||||
wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
|
||||
verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey,
|
||||
},
|
||||
securityState: {
|
||||
securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,
|
||||
securityStateVersion: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -560,7 +751,7 @@ describe("KeyRotationService", () => {
|
||||
new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
|
||||
);
|
||||
mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
|
||||
const newKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const newKey = TEST_VECTOR_USER_KEY_V1;
|
||||
const userAccount = mockUser;
|
||||
const masterPasswordUnlockData =
|
||||
await keyRotationService.createMasterPasswordUnlockDataRequest(newKey, {
|
||||
@@ -572,13 +763,13 @@ describe("KeyRotationService", () => {
|
||||
expect(masterPasswordUnlockData).toEqual({
|
||||
masterKeyEncryptedUserKey: "mockNewUserKey",
|
||||
email: "mockEmail",
|
||||
kdfType: 0,
|
||||
kdfType: KdfType.PBKDF2_SHA256,
|
||||
kdfIterations: 600_000,
|
||||
masterKeyAuthenticationHash: "mockMasterPasswordHash",
|
||||
masterPasswordHint: "mockMasterPasswordHint",
|
||||
});
|
||||
expect(PureCrypto.encrypt_user_key_with_master_password).toHaveBeenCalledWith(
|
||||
new SymmetricCryptoKey(new Uint8Array(64)).toEncoded(),
|
||||
TEST_VECTOR_USER_KEY_V1.toEncoded(),
|
||||
"mockMasterPassword",
|
||||
userAccount.email,
|
||||
new PBKDF2KdfConfig(600_000).toSdkConfig(),
|
||||
@@ -637,8 +828,8 @@ describe("KeyRotationService", () => {
|
||||
masterKeyKdfConfig: new PBKDF2KdfConfig(600_000),
|
||||
masterPasswordHint: "mockMasterPasswordHint",
|
||||
},
|
||||
[new Uint8Array(1)], // emergency access public key
|
||||
[new Uint8Array(2)], // account recovery public key
|
||||
[new Uint8Array(1) as UnsignedPublicKey], // emergency access public key
|
||||
[new Uint8Array(2) as UnsignedPublicKey], // account recovery public key
|
||||
);
|
||||
expect(accountUnlockDataRequest.passkeyUnlockData).toEqual([
|
||||
{
|
||||
@@ -758,66 +949,29 @@ describe("KeyRotationService", () => {
|
||||
expect(wasTrustDenied).toBe(true);
|
||||
});
|
||||
|
||||
it("returns trusted keys if all dialogs are accepted", async () => {
|
||||
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
|
||||
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
|
||||
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
|
||||
mockEmergencyAccessService.getPublicKeys.mockResolvedValue([
|
||||
mockGranteeEmergencyAccessWithPublicKey,
|
||||
]);
|
||||
mockResetPasswordService.getPublicKeys.mockResolvedValue([
|
||||
mockOrganizationUserResetPasswordEntry,
|
||||
]);
|
||||
const {
|
||||
wasTrustDenied,
|
||||
trustedOrganizationPublicKeys: trustedOrgs,
|
||||
trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
|
||||
} = await keyRotationService.verifyTrust(mockUser);
|
||||
expect(wasTrustDenied).toBe(false);
|
||||
expect(trustedEmergencyAccessUsers).toEqual([
|
||||
mockGranteeEmergencyAccessWithPublicKey.publicKey,
|
||||
]);
|
||||
expect(trustedOrgs).toEqual([mockOrganizationUserResetPasswordEntry.publicKey]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeNewUserKeyV1", () => {
|
||||
it("throws if old keys is xchacha20poly1305 key", async () => {
|
||||
await expect(
|
||||
keyRotationService.makeNewUserKeyV1(new SymmetricCryptoKey(new Uint8Array(70)) as UserKey),
|
||||
).rejects.toThrow(
|
||||
"User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.",
|
||||
);
|
||||
});
|
||||
it("returns new user key", async () => {
|
||||
const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const newKey = await keyRotationService.makeNewUserKeyV1(oldKey);
|
||||
expect(newKey).toEqual(new SymmetricCryptoKey(new Uint8Array(64)));
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeNewUserKeyV2", () => {
|
||||
it("returns xchacha20poly1305 key", async () => {
|
||||
const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey;
|
||||
const { newUserKey } = await keyRotationService.makeNewUserKeyV2(oldKey);
|
||||
expect(newUserKey).toEqual(new SymmetricCryptoKey(new Uint8Array(70)));
|
||||
});
|
||||
it("returns isUpgrading true if old key is v1", async () => {
|
||||
const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const newKey = await keyRotationService.makeNewUserKeyV2(oldKey);
|
||||
expect(newKey).toEqual({
|
||||
newUserKey: new SymmetricCryptoKey(new Uint8Array(70)),
|
||||
isUpgrading: true,
|
||||
});
|
||||
});
|
||||
it("returns isUpgrading false if old key is v2", async () => {
|
||||
const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey;
|
||||
const newKey = await keyRotationService.makeNewUserKeyV2(oldKey);
|
||||
expect(newKey).toEqual({
|
||||
newUserKey: new SymmetricCryptoKey(new Uint8Array(70)),
|
||||
isUpgrading: false,
|
||||
});
|
||||
});
|
||||
test.each([
|
||||
[[mockGranteeEmergencyAccessWithPublicKey], []],
|
||||
[[], [mockOrganizationUserResetPasswordEntry]],
|
||||
[[], []],
|
||||
[[mockGranteeEmergencyAccessWithPublicKey], [mockOrganizationUserResetPasswordEntry]],
|
||||
])(
|
||||
"returns trusted keys when dialogs are open and public keys are provided",
|
||||
async (emUsers, orgs) => {
|
||||
KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue;
|
||||
EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted;
|
||||
AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted;
|
||||
mockEmergencyAccessService.getPublicKeys.mockResolvedValue(emUsers);
|
||||
mockResetPasswordService.getPublicKeys.mockResolvedValue(orgs);
|
||||
const {
|
||||
wasTrustDenied,
|
||||
trustedOrganizationPublicKeys: trustedOrgs,
|
||||
trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers,
|
||||
} = await keyRotationService.verifyTrust(mockUser);
|
||||
expect(wasTrustDenied).toBe(false);
|
||||
expect(trustedEmergencyAccessUsers).toEqual(emUsers.map((e) => e.publicKey));
|
||||
expect(trustedOrgs).toEqual(orgs.map((o) => o.publicKey));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("getAccountDataRequest", () => {
|
||||
@@ -890,13 +1044,264 @@ describe("KeyRotationService", () => {
|
||||
});
|
||||
|
||||
describe("isV1UserKey", () => {
|
||||
const v1Key = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
const v2Key = new SymmetricCryptoKey(new Uint8Array(70));
|
||||
const aes256CbcHmacV1UserKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
const coseV2UserKey = new SymmetricCryptoKey(new Uint8Array(70));
|
||||
it("returns true for v1 key", () => {
|
||||
expect(keyRotationService.isV1User(v1Key as UserKey)).toBe(true);
|
||||
expect(keyRotationService.isV1User(aes256CbcHmacV1UserKey as UserKey)).toBe(true);
|
||||
});
|
||||
it("returns false for v2 key", () => {
|
||||
expect(keyRotationService.isV1User(v2Key as UserKey)).toBe(false);
|
||||
expect(keyRotationService.isV1User(coseV2UserKey as UserKey)).toBe(false);
|
||||
});
|
||||
it("returns false for 32 byte AES256-CBC key", () => {
|
||||
const aes256CbcKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
expect(keyRotationService.isV1User(aes256CbcKey)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeServerMasterKeyAuthenticationHash", () => {
|
||||
it("returns the master key authentication hash", async () => {
|
||||
mockKeyService.makeMasterKey.mockResolvedValue(
|
||||
new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
|
||||
);
|
||||
mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash");
|
||||
const masterKeyAuthenticationHash =
|
||||
await keyRotationService.makeServerMasterKeyAuthenticationHash(
|
||||
"mockMasterPassword",
|
||||
new PBKDF2KdfConfig(600_000),
|
||||
"mockEmail",
|
||||
);
|
||||
expect(masterKeyAuthenticationHash).toBe("mockMasterPasswordHash");
|
||||
expect(mockKeyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
"mockMasterPassword",
|
||||
"mockEmail",
|
||||
new PBKDF2KdfConfig(600_000),
|
||||
);
|
||||
expect(mockKeyService.hashMasterKey).toHaveBeenCalledWith(
|
||||
"mockMasterPassword",
|
||||
new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey,
|
||||
HashPurpose.ServerAuthorization,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCryptographicStateForUser", () => {
|
||||
beforeEach(() => {
|
||||
mockKdfConfigService.getKdfConfig$.mockReturnValue(
|
||||
new BehaviorSubject(new PBKDF2KdfConfig(100000)),
|
||||
);
|
||||
mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(TEST_VECTOR_USER_KEY_V2));
|
||||
mockKeyService.userEncryptedPrivateKey$.mockReturnValue(
|
||||
new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V2 as string as EncryptedString),
|
||||
);
|
||||
mockKeyService.userSigningKey$.mockReturnValue(
|
||||
new BehaviorSubject(TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey),
|
||||
);
|
||||
mockSecurityStateService.accountSecurityState$.mockReturnValue(
|
||||
new BehaviorSubject(TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState),
|
||||
);
|
||||
mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(
|
||||
new Uint8Array(400) as UnsignedPublicKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the cryptographic state for v1 user", async () => {
|
||||
mockKeyService.userKey$.mockReturnValue(
|
||||
new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey),
|
||||
);
|
||||
mockKeyService.userEncryptedPrivateKey$.mockReturnValue(
|
||||
new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V1 as string as EncryptedString),
|
||||
);
|
||||
mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null));
|
||||
mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null));
|
||||
|
||||
const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser);
|
||||
expect(cryptographicState).toEqual({
|
||||
masterKeyKdfConfig: new PBKDF2KdfConfig(100000),
|
||||
masterKeySalt: "mockemail", // the email is lowercased to become the salt
|
||||
cryptographicStateParameters: {
|
||||
version: 1,
|
||||
userKey: TEST_VECTOR_USER_KEY_V1,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the cryptographic state for v2 user", async () => {
|
||||
const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser);
|
||||
expect(cryptographicState).toEqual({
|
||||
masterKeyKdfConfig: new PBKDF2KdfConfig(100000),
|
||||
masterKeySalt: "mockemail", // the email is lowercased to become the salt
|
||||
cryptographicStateParameters: {
|
||||
version: 2,
|
||||
userKey: TEST_VECTOR_USER_KEY_V2,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
|
||||
},
|
||||
signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
|
||||
securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws if no kdf config is found", async () => {
|
||||
mockKdfConfigService.getKdfConfig$.mockReturnValue(new BehaviorSubject(null));
|
||||
await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow(
|
||||
"Failed to get KDF config",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if current user key is not found", async () => {
|
||||
mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(null));
|
||||
await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow(
|
||||
"Failed to get User key",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if private key is not found", async () => {
|
||||
mockKeyService.userEncryptedPrivateKey$.mockReturnValue(new BehaviorSubject(null));
|
||||
await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow(
|
||||
"Failed to get Private key",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if user key is not AES256-CBC-HMAC or COSE", async () => {
|
||||
const invalidKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
|
||||
mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(invalidKey));
|
||||
await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow(
|
||||
"Unsupported user key type",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRotatedAccountKeysFlagged", () => {
|
||||
const userId = "mockUserId" as UserId;
|
||||
const kdfConfig = new PBKDF2KdfConfig(100000);
|
||||
const masterKeySalt = "mockSalt";
|
||||
const v1Params = {
|
||||
version: 1,
|
||||
userKey: TEST_VECTOR_USER_KEY_V1,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey,
|
||||
},
|
||||
} as V1CryptographicStateParameters;
|
||||
const v2Params = {
|
||||
version: 2,
|
||||
userKey: TEST_VECTOR_USER_KEY_V2,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
|
||||
},
|
||||
signingKey: TEST_VECTOR_SIGNING_KEY_V2,
|
||||
securityState: TEST_VECTOR_SECURITY_STATE_V2,
|
||||
} as V2CryptographicStateParameters;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(keyRotationService, "getNewAccountKeysV1").mockResolvedValue({
|
||||
userKey: TEST_VECTOR_USER_KEY_V1,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1_ROTATED,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey,
|
||||
},
|
||||
});
|
||||
jest.spyOn(keyRotationService, "getNewAccountKeysV2").mockResolvedValue({
|
||||
userKey: TEST_VECTOR_USER_KEY_V2,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2,
|
||||
publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey,
|
||||
signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2,
|
||||
},
|
||||
signatureKeyPair: {
|
||||
wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey,
|
||||
verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey,
|
||||
},
|
||||
securityState: {
|
||||
securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState,
|
||||
securityStateVersion: 2,
|
||||
},
|
||||
});
|
||||
jest
|
||||
.spyOn(AccountKeysRequest, "fromV1CryptographicState")
|
||||
.mockReturnValue("v1Request" as any);
|
||||
jest
|
||||
.spyOn(AccountKeysRequest, "fromV2CryptographicState")
|
||||
.mockResolvedValue("v2Request" as any);
|
||||
});
|
||||
|
||||
it("returns v2 keys and request if v2UpgradeEnabled is true", async () => {
|
||||
const result = await keyRotationService.getRotatedAccountKeysFlagged(
|
||||
userId,
|
||||
kdfConfig,
|
||||
masterKeySalt,
|
||||
v1Params,
|
||||
true,
|
||||
);
|
||||
expect(keyRotationService.getNewAccountKeysV2).toHaveBeenCalledWith(
|
||||
userId,
|
||||
kdfConfig,
|
||||
masterKeySalt,
|
||||
v1Params,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
userKey: TEST_VECTOR_USER_KEY_V2,
|
||||
accountKeysRequest: "v2Request",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns v2 keys and request if params.version is 2", async () => {
|
||||
const result = await keyRotationService.getRotatedAccountKeysFlagged(
|
||||
userId,
|
||||
kdfConfig,
|
||||
masterKeySalt,
|
||||
v2Params,
|
||||
false,
|
||||
);
|
||||
expect(keyRotationService.getNewAccountKeysV2).toHaveBeenCalledWith(
|
||||
userId,
|
||||
kdfConfig,
|
||||
masterKeySalt,
|
||||
v2Params,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
userKey: TEST_VECTOR_USER_KEY_V2,
|
||||
accountKeysRequest: "v2Request",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns v1 keys and request if v2UpgradeEnabled is false and params.version is 1", async () => {
|
||||
const result = await keyRotationService.getRotatedAccountKeysFlagged(
|
||||
userId,
|
||||
kdfConfig,
|
||||
masterKeySalt,
|
||||
v1Params,
|
||||
false,
|
||||
);
|
||||
expect(keyRotationService.getNewAccountKeysV1).toHaveBeenCalledWith(v1Params);
|
||||
expect(result).toEqual({
|
||||
userKey: TEST_VECTOR_USER_KEY_V1,
|
||||
accountKeysRequest: "v1Request",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureIsAllowedToRotateUserKey", () => {
|
||||
it("resolves if last sync exists", async () => {
|
||||
mockSyncService.getLastSync.mockResolvedValue(new Date());
|
||||
await expect(keyRotationService.ensureIsAllowedToRotateUserKey()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws if last sync is null", async () => {
|
||||
mockSyncService.getLastSync.mockResolvedValue(null);
|
||||
await expect(keyRotationService.ensureIsAllowedToRotateUserKey()).rejects.toThrow(
|
||||
/de-synced|log out and log back in/i,
|
||||
);
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(
|
||||
"[Userkey rotation] Client was never synced. Aborting!",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, Observable } from "rxjs";
|
||||
|
||||
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
@@ -7,13 +7,21 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { firstValueFromOrThrow } from "@bitwarden/common/key-management/utils";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
import {
|
||||
SignedSecurityState,
|
||||
UnsignedPublicKey,
|
||||
WrappedPrivateKey,
|
||||
WrappedSigningKey,
|
||||
} from "@bitwarden/common/key-management/types";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { EncryptionType, HashPurpose } 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 { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -28,7 +36,7 @@ import {
|
||||
EmergencyAccessTrustComponent,
|
||||
KeyRotationTrustInfoComponent,
|
||||
} from "@bitwarden/key-management-ui";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
import { PureCrypto, TokenProvider } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
|
||||
import { WebauthnLoginAdminService } from "../../auth/core";
|
||||
@@ -39,6 +47,11 @@ import { MasterPasswordUnlockDataRequest } from "./request/master-password-unloc
|
||||
import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request";
|
||||
import { UnlockDataRequest } from "./request/unlock-data.request";
|
||||
import { UserDataRequest } from "./request/userdata.request";
|
||||
import { V1UserCryptographicState } from "./types/v1-cryptographic-state";
|
||||
import {
|
||||
fromSdkV2KeysToV2UserCryptographicState,
|
||||
V2UserCryptographicState,
|
||||
} from "./types/v2-cryptographic-state";
|
||||
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
|
||||
|
||||
type MasterPasswordAuthenticationAndUnlockData = {
|
||||
@@ -48,6 +61,19 @@ type MasterPasswordAuthenticationAndUnlockData = {
|
||||
masterPasswordHint: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A token provider that exposes a null access token to the SDK.
|
||||
*/
|
||||
class NoopTokenProvider implements TokenProvider {
|
||||
constructor() {}
|
||||
|
||||
async get_access_token(): Promise<string | undefined> {
|
||||
// Ignore from the test coverage, since this is called by the SDK
|
||||
/* istanbul ignore next */
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class UserKeyRotationService {
|
||||
constructor(
|
||||
@@ -70,6 +96,8 @@ export class UserKeyRotationService {
|
||||
private configService: ConfigService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private sdkClientFactory: SdkClientFactory,
|
||||
private securityStateService: SecurityStateService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -85,12 +113,15 @@ export class UserKeyRotationService {
|
||||
user: Account,
|
||||
newMasterPasswordHint?: string,
|
||||
): Promise<void> {
|
||||
this.logService.info("[UserKey Rotation] Starting user key rotation...");
|
||||
// Key-rotation uses the SDK, so we need to ensure that the SDK is loaded / the WASM initialized.
|
||||
await SdkLoadService.Ready;
|
||||
|
||||
const upgradeToV2FeatureFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.EnrollAeadOnKeyRotation,
|
||||
);
|
||||
|
||||
this.logService.info("[UserKey Rotation] Starting user key rotation...");
|
||||
|
||||
// Make sure all conditions match - e.g. account state is up to date
|
||||
await this.ensureIsAllowedToRotateUserKey();
|
||||
|
||||
@@ -104,53 +135,26 @@ export class UserKeyRotationService {
|
||||
}
|
||||
|
||||
// Read current cryptographic state / settings
|
||||
const masterKeyKdfConfig: KdfConfig = (await firstValueFromOrThrow(
|
||||
this.kdfConfigService.getKdfConfig$(user.id),
|
||||
"KDF config",
|
||||
))!;
|
||||
// The masterkey salt used for deriving the masterkey always needs to be trimmed and lowercased.
|
||||
const masterKeySalt = user.email.trim().toLowerCase();
|
||||
const currentUserKey: UserKey = (await firstValueFromOrThrow(
|
||||
this.keyService.userKey$(user.id),
|
||||
"User key",
|
||||
))!;
|
||||
const currentUserKeyWrappedPrivateKey = new EncString(
|
||||
(await firstValueFromOrThrow(
|
||||
this.keyService.userEncryptedPrivateKey$(user.id),
|
||||
"User encrypted private key",
|
||||
))!,
|
||||
);
|
||||
const {
|
||||
masterKeyKdfConfig,
|
||||
masterKeySalt,
|
||||
cryptographicStateParameters: currentCryptographicStateParameters,
|
||||
} = await this.getCryptographicStateForUser(user);
|
||||
|
||||
// Update account keys
|
||||
// This creates at least a new user key, and possibly upgrades user encryption formats
|
||||
let newUserKey: UserKey;
|
||||
let wrappedPrivateKey: EncString;
|
||||
let publicKey: string;
|
||||
if (upgradeToV2FeatureFlagEnabled) {
|
||||
this.logService.info("[Userkey rotation] Using v2 account keys");
|
||||
const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV2(
|
||||
currentUserKey,
|
||||
currentUserKeyWrappedPrivateKey,
|
||||
);
|
||||
newUserKey = userKey;
|
||||
wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey;
|
||||
publicKey = asymmetricEncryptionKeys.publicKey;
|
||||
} else {
|
||||
this.logService.info("[Userkey rotation] Using v1 account keys");
|
||||
const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV1(
|
||||
currentUserKey,
|
||||
currentUserKeyWrappedPrivateKey,
|
||||
);
|
||||
newUserKey = userKey;
|
||||
wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey;
|
||||
publicKey = asymmetricEncryptionKeys.publicKey;
|
||||
}
|
||||
// Get new set of keys for the account.
|
||||
const { userKey: newUserKey, accountKeysRequest } = await this.getRotatedAccountKeysFlagged(
|
||||
user.id,
|
||||
masterKeyKdfConfig,
|
||||
user.email,
|
||||
currentCryptographicStateParameters,
|
||||
upgradeToV2FeatureFlagEnabled,
|
||||
);
|
||||
|
||||
// Assemble the key rotation request
|
||||
const request = new RotateUserAccountKeysRequest(
|
||||
await this.getAccountUnlockDataRequest(
|
||||
user.id,
|
||||
currentUserKey,
|
||||
currentCryptographicStateParameters.userKey,
|
||||
newUserKey,
|
||||
{
|
||||
masterPassword: newMasterPassword,
|
||||
@@ -161,8 +165,12 @@ export class UserKeyRotationService {
|
||||
trustedEmergencyAccessUserPublicKeys,
|
||||
trustedOrganizationPublicKeys,
|
||||
),
|
||||
new AccountKeysRequest(wrappedPrivateKey.encryptedString!, publicKey),
|
||||
await this.getAccountDataRequest(currentUserKey, newUserKey, user),
|
||||
accountKeysRequest,
|
||||
await this.getAccountDataRequest(
|
||||
currentCryptographicStateParameters.userKey,
|
||||
newUserKey,
|
||||
user,
|
||||
),
|
||||
await this.makeServerMasterKeyAuthenticationHash(
|
||||
currentMasterPassword,
|
||||
masterKeyKdfConfig,
|
||||
@@ -194,55 +202,153 @@ export class UserKeyRotationService {
|
||||
}
|
||||
}
|
||||
|
||||
async getRotatedAccountKeysFlagged(
|
||||
userId: UserId,
|
||||
kdfConfig: KdfConfig,
|
||||
masterKeySalt: string,
|
||||
cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters,
|
||||
v2UpgradeEnabled: boolean,
|
||||
): Promise<{ userKey: UserKey; accountKeysRequest: AccountKeysRequest }> {
|
||||
if (v2UpgradeEnabled || cryptographicStateParameters.version === 2) {
|
||||
const keys = await this.getNewAccountKeysV2(
|
||||
userId,
|
||||
kdfConfig,
|
||||
masterKeySalt,
|
||||
cryptographicStateParameters,
|
||||
);
|
||||
return {
|
||||
userKey: keys.userKey,
|
||||
accountKeysRequest: await AccountKeysRequest.fromV2CryptographicState(keys),
|
||||
};
|
||||
} else {
|
||||
const keys = await this.getNewAccountKeysV1(
|
||||
cryptographicStateParameters as V1CryptographicStateParameters,
|
||||
);
|
||||
return {
|
||||
userKey: keys.userKey,
|
||||
accountKeysRequest: AccountKeysRequest.fromV1CryptographicState(keys),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method rotates the user key of a V1 user and re-encrypts the private key.
|
||||
* @deprecated Removed after roll-out of V2 encryption.
|
||||
*/
|
||||
protected async getNewAccountKeysV1(
|
||||
currentUserKey: UserKey,
|
||||
currentUserKeyWrappedPrivateKey: EncString,
|
||||
): Promise<{
|
||||
userKey: UserKey;
|
||||
asymmetricEncryptionKeys: {
|
||||
wrappedPrivateKey: EncString;
|
||||
publicKey: string;
|
||||
};
|
||||
}> {
|
||||
// Account key rotation creates a new userkey. All downstream data and keys need to be re-encrypted under this key.
|
||||
cryptographicStateParameters: V1CryptographicStateParameters,
|
||||
): Promise<V1UserCryptographicState> {
|
||||
// Account key rotation creates a new user key. All downstream data and keys need to be re-encrypted under this key.
|
||||
// Further, this method is used to create new keys in the event that the key hierarchy changes, such as for the
|
||||
// creation of a new signing key pair.
|
||||
const newUserKey = await this.makeNewUserKeyV1(currentUserKey);
|
||||
const newUserKey = new SymmetricCryptoKey(
|
||||
PureCrypto.make_user_key_aes256_cbc_hmac(),
|
||||
) as UserKey;
|
||||
|
||||
// Re-encrypt the private key with the new user key
|
||||
// Rotation of the private key is not supported yet
|
||||
const privateKey = await this.encryptService.unwrapDecapsulationKey(
|
||||
currentUserKeyWrappedPrivateKey,
|
||||
currentUserKey,
|
||||
new EncString(cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey),
|
||||
cryptographicStateParameters.userKey,
|
||||
);
|
||||
const newUserKeyWrappedPrivateKey = await this.encryptService.wrapDecapsulationKey(
|
||||
const newUserKeyWrappedPrivateKey = (
|
||||
await this.encryptService.wrapDecapsulationKey(privateKey, newUserKey)
|
||||
).encryptedString! as string as WrappedPrivateKey;
|
||||
const publicKey = (await this.cryptoFunctionService.rsaExtractPublicKey(
|
||||
privateKey,
|
||||
newUserKey,
|
||||
);
|
||||
const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
|
||||
)) as UnsignedPublicKey;
|
||||
|
||||
return {
|
||||
userKey: newUserKey,
|
||||
asymmetricEncryptionKeys: {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: newUserKeyWrappedPrivateKey,
|
||||
publicKey: Utils.fromBufferToB64(publicKey),
|
||||
publicKey: publicKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This method either enrolls a user from v1 encryption to v2 encryption, rotating the user key, or rotates the keys of a v2 user, staying on v2.
|
||||
*/
|
||||
protected async getNewAccountKeysV2(
|
||||
currentUserKey: UserKey,
|
||||
currentUserKeyWrappedPrivateKey: EncString,
|
||||
): Promise<{
|
||||
userKey: UserKey;
|
||||
asymmetricEncryptionKeys: {
|
||||
wrappedPrivateKey: EncString;
|
||||
publicKey: string;
|
||||
};
|
||||
}> {
|
||||
throw new Error("User encryption v2 upgrade is not supported yet");
|
||||
userId: UserId,
|
||||
masterKeyKdfConfig: KdfConfig,
|
||||
masterKeySalt: string,
|
||||
cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters,
|
||||
): Promise<V2UserCryptographicState> {
|
||||
if (cryptographicStateParameters.version === 1) {
|
||||
return this.upgradeV1UserToV2UserAccountKeys(
|
||||
userId,
|
||||
masterKeyKdfConfig,
|
||||
masterKeySalt,
|
||||
cryptographicStateParameters as V1CryptographicStateParameters,
|
||||
);
|
||||
} else {
|
||||
return this.rotateV2UserAccountKeys(
|
||||
userId,
|
||||
masterKeyKdfConfig,
|
||||
masterKeySalt,
|
||||
cryptographicStateParameters as V2CryptographicStateParameters,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrades a V1 user to a V2 user by creating a new user key, re-encrypting the private key, generating a signature key-pair, and
|
||||
* finally creating a signed security state.
|
||||
*/
|
||||
protected async upgradeV1UserToV2UserAccountKeys(
|
||||
userId: UserId,
|
||||
kdfConfig: KdfConfig,
|
||||
email: string,
|
||||
cryptographicStateParameters: V1CryptographicStateParameters,
|
||||
): Promise<V2UserCryptographicState> {
|
||||
// Initialize an SDK with the current cryptographic state
|
||||
const sdk = await this.sdkClientFactory.createSdkClient(new NoopTokenProvider());
|
||||
await sdk.crypto().initialize_user_crypto({
|
||||
userId: asUuid(userId),
|
||||
kdfParams: kdfConfig.toSdkConfig(),
|
||||
email: email,
|
||||
privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
signingKey: undefined,
|
||||
securityState: undefined,
|
||||
method: {
|
||||
decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() },
|
||||
},
|
||||
});
|
||||
|
||||
return fromSdkV2KeysToV2UserCryptographicState(sdk.crypto().make_keys_for_user_crypto_v2());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new user key for a v2 user, and re-encrypts the private key, signing key.
|
||||
*/
|
||||
protected async rotateV2UserAccountKeys(
|
||||
userId: UserId,
|
||||
kdfConfig: KdfConfig,
|
||||
email: string,
|
||||
cryptographicStateParameters: V2CryptographicStateParameters,
|
||||
): Promise<V2UserCryptographicState> {
|
||||
// Initialize an SDK with the current cryptographic state
|
||||
const sdk = await this.sdkClientFactory.createSdkClient(new NoopTokenProvider());
|
||||
await sdk.crypto().initialize_user_crypto({
|
||||
userId: asUuid(userId),
|
||||
kdfParams: kdfConfig.toSdkConfig(),
|
||||
email: email,
|
||||
privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey,
|
||||
signingKey: cryptographicStateParameters.signingKey,
|
||||
securityState: cryptographicStateParameters.securityState,
|
||||
method: {
|
||||
decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() },
|
||||
},
|
||||
});
|
||||
|
||||
return fromSdkV2KeysToV2UserCryptographicState(sdk.crypto().get_v2_rotated_account_keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new request for updating the master-password unlock/authentication data.
|
||||
*/
|
||||
protected async createMasterPasswordUnlockDataRequest(
|
||||
userKey: UserKey,
|
||||
newUnlockData: MasterPasswordAuthenticationAndUnlockData,
|
||||
@@ -272,13 +378,17 @@ export class UserKeyRotationService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-generates the accounts unlock methods, including master-password, passkey, trusted device, emergency access, and organization account recovery
|
||||
* for the new user key.
|
||||
*/
|
||||
protected async getAccountUnlockDataRequest(
|
||||
userId: UserId,
|
||||
currentUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
masterPasswordAuthenticationAndUnlockData: MasterPasswordAuthenticationAndUnlockData,
|
||||
trustedEmergencyAccessGranteesPublicKeys: Uint8Array[],
|
||||
trustedOrganizationPublicKeys: Uint8Array[],
|
||||
trustedEmergencyAccessGranteesPublicKeys: UnsignedPublicKey[],
|
||||
trustedOrganizationPublicKeys: UnsignedPublicKey[],
|
||||
): Promise<UnlockDataRequest> {
|
||||
// To ensure access; all unlock methods need to be updated and provided the new user key.
|
||||
// User unlock methods
|
||||
@@ -321,10 +431,13 @@ export class UserKeyRotationService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the trust of the organizations and emergency access users by prompting the user. Denying any of these will return early.
|
||||
*/
|
||||
protected async verifyTrust(user: Account): Promise<{
|
||||
wasTrustDenied: boolean;
|
||||
trustedOrganizationPublicKeys: Uint8Array[];
|
||||
trustedEmergencyAccessUserPublicKeys: Uint8Array[];
|
||||
trustedOrganizationPublicKeys: UnsignedPublicKey[];
|
||||
trustedEmergencyAccessUserPublicKeys: UnsignedPublicKey[];
|
||||
}> {
|
||||
// Since currently the joined organizations and emergency access grantees are
|
||||
// not signed, manual trust prompts are required, to verify that the server
|
||||
@@ -392,11 +505,16 @@ export class UserKeyRotationService {
|
||||
);
|
||||
return {
|
||||
wasTrustDenied: false,
|
||||
trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey),
|
||||
trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map((d) => d.publicKey),
|
||||
trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey as UnsignedPublicKey),
|
||||
trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map(
|
||||
(d) => d.publicKey as UnsignedPublicKey,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encrypts the account data owned by the user, such as ciphers, folders, and sends with the new user key.
|
||||
*/
|
||||
protected async getAccountDataRequest(
|
||||
originalUserKey: UserKey,
|
||||
newUnencryptedUserKey: UserKey,
|
||||
@@ -429,64 +547,6 @@ export class UserKeyRotationService {
|
||||
return new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends);
|
||||
}
|
||||
|
||||
protected async makeNewUserKeyV1(oldUserKey: UserKey): Promise<UserKey> {
|
||||
// The user's account format is determined by the user key.
|
||||
// Being tied to the userkey ensures an all-or-nothing approach. A compromised
|
||||
// server cannot downgrade to a previous format (no signing keys) without
|
||||
// completely making the account unusable.
|
||||
//
|
||||
// V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts)
|
||||
// This format is unsupported, and not secure; It is being forced migrated, and being removed
|
||||
// V1: AES256-CBC-HMAC userkey, no signing key (2019-2025)
|
||||
// This format is still supported, but may be migrated in the future
|
||||
// V2: XChaCha20-Poly1305 userkey, signing key, account security version
|
||||
// This is the new, modern format.
|
||||
if (this.isV1User(oldUserKey)) {
|
||||
this.logService.info(
|
||||
"[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; not upgrading",
|
||||
);
|
||||
return new SymmetricCryptoKey(PureCrypto.make_user_key_aes256_cbc_hmac()) as UserKey;
|
||||
} else {
|
||||
// If the feature flag is rolled back, we want to block rotation in order to be as safe as possible with the user's account.
|
||||
this.logService.info(
|
||||
"[Userkey rotation] Existing userkey key is XChaCha20-Poly1305, but feature flag is not enabled; aborting..",
|
||||
);
|
||||
throw new Error(
|
||||
"User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected async makeNewUserKeyV2(
|
||||
oldUserKey: UserKey,
|
||||
): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> {
|
||||
// The user's account format is determined by the user key.
|
||||
// Being tied to the userkey ensures an all-or-nothing approach. A compromised
|
||||
// server cannot downgrade to a previous format (no signing keys) without
|
||||
// completely making the account unusable.
|
||||
//
|
||||
// V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts)
|
||||
// This format is unsupported, and not secure; It is being forced migrated, and being removed
|
||||
// V1: AES256-CBC-HMAC userkey, no signing key (2019-2025)
|
||||
// This format is still supported, but may be migrated in the future
|
||||
// V2: XChaCha20-Poly1305 userkey, signing key, account security version
|
||||
// This is the new, modern format.
|
||||
const newUserKey: UserKey = new SymmetricCryptoKey(
|
||||
PureCrypto.make_user_key_xchacha20_poly1305(),
|
||||
) as UserKey;
|
||||
const isUpgrading = this.isV1User(oldUserKey);
|
||||
if (isUpgrading) {
|
||||
this.logService.info(
|
||||
"[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; upgrading to XChaCha20-Poly1305",
|
||||
);
|
||||
} else {
|
||||
this.logService.info(
|
||||
"[Userkey rotation] Existing userkey key is XChaCha20-Poly1305; no upgrade needed",
|
||||
);
|
||||
}
|
||||
return { isUpgrading, newUserKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* A V1 user has no signing key, and uses AES256-CBC-HMAC.
|
||||
* A V2 user has a signing key, and uses XChaCha20-Poly1305.
|
||||
@@ -516,4 +576,111 @@ export class UserKeyRotationService {
|
||||
HashPurpose.ServerAuthorization,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cryptographic state for a user. This can be a V1 user or a V2 user.
|
||||
*/
|
||||
protected async getCryptographicStateForUser(user: Account): Promise<{
|
||||
masterKeyKdfConfig: KdfConfig;
|
||||
masterKeySalt: string;
|
||||
cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters;
|
||||
}> {
|
||||
// Master password unlock
|
||||
const masterKeyKdfConfig: KdfConfig = (await this.firstValueFromOrThrow(
|
||||
this.kdfConfigService.getKdfConfig$(user.id),
|
||||
"KDF config",
|
||||
))!;
|
||||
// The master key salt used for deriving the masterkey always needs to be trimmed and lowercased.
|
||||
const masterKeySalt = user.email.trim().toLowerCase();
|
||||
|
||||
// V1 and V2 users both have a user key and a private key
|
||||
const currentUserKey: UserKey = (await this.firstValueFromOrThrow(
|
||||
this.keyService.userKey$(user.id),
|
||||
"User key",
|
||||
))!;
|
||||
const currentUserKeyWrappedPrivateKey: WrappedPrivateKey = new EncString(
|
||||
(await this.firstValueFromOrThrow(
|
||||
this.keyService.userEncryptedPrivateKey$(user.id),
|
||||
"Private key",
|
||||
))!,
|
||||
).encryptedString! as string as WrappedPrivateKey;
|
||||
const publicKey = (await this.cryptoFunctionService.rsaExtractPublicKey(
|
||||
await this.encryptService.unwrapDecapsulationKey(
|
||||
new EncString(currentUserKeyWrappedPrivateKey),
|
||||
currentUserKey,
|
||||
),
|
||||
)) as UnsignedPublicKey;
|
||||
|
||||
if (this.isV1User(currentUserKey)) {
|
||||
return {
|
||||
masterKeyKdfConfig,
|
||||
masterKeySalt,
|
||||
cryptographicStateParameters: {
|
||||
version: 1,
|
||||
userKey: currentUserKey,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: currentUserKeyWrappedPrivateKey,
|
||||
publicKey: publicKey,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (currentUserKey.inner().type === EncryptionType.CoseEncrypt0) {
|
||||
const signingKey = await this.firstValueFromOrThrow(
|
||||
this.keyService.userSigningKey$(user.id),
|
||||
"User signing key",
|
||||
);
|
||||
const securityState = await this.firstValueFromOrThrow(
|
||||
this.securityStateService.accountSecurityState$(user.id),
|
||||
"User security state",
|
||||
);
|
||||
|
||||
return {
|
||||
masterKeyKdfConfig,
|
||||
masterKeySalt,
|
||||
cryptographicStateParameters: {
|
||||
version: 2,
|
||||
userKey: currentUserKey,
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: currentUserKeyWrappedPrivateKey,
|
||||
publicKey: publicKey,
|
||||
},
|
||||
signingKey: signingKey!,
|
||||
securityState: securityState!,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// AES-CBC (no-hmac) keys are not supported as user keys
|
||||
throw new Error(
|
||||
`Unsupported user key type: ${currentUserKey.inner().type}. Expected AesCbc256_HmacSha256_B64 or XChaCha20_Poly1305_B64.`,
|
||||
);
|
||||
}
|
||||
|
||||
async firstValueFromOrThrow<T>(value: Observable<T>, name: string): Promise<T> {
|
||||
const result = await firstValueFrom(value);
|
||||
if (result == null) {
|
||||
throw new Error(`Failed to get ${name}`);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
}
|
||||
|
||||
export type V1CryptographicStateParameters = {
|
||||
version: 1;
|
||||
userKey: UserKey;
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: WrappedPrivateKey;
|
||||
publicKey: UnsignedPublicKey;
|
||||
};
|
||||
};
|
||||
|
||||
export type V2CryptographicStateParameters = {
|
||||
version: 2;
|
||||
userKey: UserKey;
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: WrappedPrivateKey;
|
||||
publicKey: UnsignedPublicKey;
|
||||
};
|
||||
signingKey: WrappedSigningKey;
|
||||
securityState: SignedSecurityState;
|
||||
};
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
<div class="tw-mt-auto">
|
||||
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
|
||||
<bit-nav-item
|
||||
*ngFor="let product of accessibleProducts$ | async"
|
||||
[icon]="product.icon"
|
||||
[text]="product.name"
|
||||
[route]="product.appRoute"
|
||||
[attr.icon]="product.icon"
|
||||
[forceActiveStyles]="product.isActive"
|
||||
>
|
||||
</bit-nav-item>
|
||||
<ng-container *ngIf="moreProducts$ | async as moreProducts">
|
||||
<section
|
||||
*ngIf="moreProducts.length > 0"
|
||||
class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0"
|
||||
@let accessibleProducts = accessibleProducts$ | async;
|
||||
@if (accessibleProducts && accessibleProducts.length > 1) {
|
||||
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
|
||||
<bit-nav-item
|
||||
*ngFor="let product of accessibleProducts"
|
||||
[icon]="product.icon"
|
||||
[text]="product.name"
|
||||
[route]="product.appRoute"
|
||||
[attr.icon]="product.icon"
|
||||
[forceActiveStyles]="product.isActive"
|
||||
>
|
||||
</bit-nav-item>
|
||||
}
|
||||
|
||||
@if (shouldShowPremiumUpgradeButton$ | async) {
|
||||
<app-upgrade-nav-button></app-upgrade-nav-button>
|
||||
}
|
||||
|
||||
@let moreProducts = moreProducts$ | async;
|
||||
@if (moreProducts && moreProducts.length > 0) {
|
||||
<section class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0">
|
||||
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
|
||||
<ng-container *ngFor="let more of moreProducts">
|
||||
<div class="tw-ps-2 tw-pe-2">
|
||||
@@ -57,5 +63,5 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
</section>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||
@@ -15,6 +16,13 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s
|
||||
|
||||
import { NavigationProductSwitcherComponent } from "./navigation-switcher.component";
|
||||
|
||||
@Component({
|
||||
selector: "app-upgrade-nav-button",
|
||||
template: "<div>Upgrade Nav Button</div>",
|
||||
standalone: true,
|
||||
})
|
||||
class MockUpgradeNavButtonComponent {}
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
@@ -41,13 +49,16 @@ describe("NavigationProductSwitcherComponent", () => {
|
||||
other: [],
|
||||
});
|
||||
|
||||
const mockShouldShowPremiumUpgradeButton$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
beforeEach(async () => {
|
||||
productSwitcherService = mock<ProductSwitcherService>();
|
||||
productSwitcherService.products$ = mockProducts$;
|
||||
productSwitcherService.shouldShowPremiumUpgradeButton$ = mockShouldShowPremiumUpgradeButton$;
|
||||
mockProducts$.next({ bento: [], other: [] });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RouterModule, NavigationModule, IconButtonModule],
|
||||
imports: [RouterModule, NavigationModule, IconButtonModule, MockUpgradeNavButtonComponent],
|
||||
declarations: [NavigationProductSwitcherComponent, I18nPipe],
|
||||
providers: [
|
||||
{ provide: ProductSwitcherService, useValue: productSwitcherService },
|
||||
@@ -187,15 +198,23 @@ describe("NavigationProductSwitcherComponent", () => {
|
||||
},
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
name: "Test Product",
|
||||
icon: "bwi-lock",
|
||||
marketingRoute: {
|
||||
route: "https://www.example.com/",
|
||||
external: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
other: [],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const navItem = fixture.debugElement.query(By.directive(NavItemComponent));
|
||||
const navItem = fixture.debugElement.queryAll(By.directive(NavItemComponent));
|
||||
|
||||
expect(navItem.componentInstance.forceActiveStyles()).toBe(true);
|
||||
expect(navItem[0].componentInstance.forceActiveStyles()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,18 +237,56 @@ describe("NavigationProductSwitcherComponent", () => {
|
||||
expect(links[0].textContent).toContain("Password Manager");
|
||||
expect(links[1].textContent).toContain("Secret Manager");
|
||||
});
|
||||
|
||||
it("does not show products list when there is only one item", () => {
|
||||
mockProducts$.next({
|
||||
bento: [{ isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }],
|
||||
other: [],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const navItems = fixture.debugElement.queryAll(By.directive(NavItemComponent));
|
||||
|
||||
expect(navItems.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("links to `appRoute`", () => {
|
||||
mockProducts$.next({
|
||||
bento: [{ isActive: false, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }],
|
||||
bento: [
|
||||
{ isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" },
|
||||
{ isActive: false, name: "Secret Manager", icon: "bwi-lock", appRoute: "/sm" },
|
||||
],
|
||||
other: [],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const link = fixture.nativeElement.querySelector("a");
|
||||
const links = fixture.nativeElement.querySelectorAll("a");
|
||||
|
||||
expect(link.getAttribute("href")).toBe("/vault");
|
||||
expect(links[0].getAttribute("href")).toBe("/vault");
|
||||
});
|
||||
|
||||
describe("upgrade nav button", () => {
|
||||
it("shows upgrade nav button when shouldShowPremiumUpgradeButton$ is true", () => {
|
||||
mockShouldShowPremiumUpgradeButton$.next(true);
|
||||
mockProducts$.next({
|
||||
bento: [],
|
||||
other: [
|
||||
{
|
||||
name: "Organizations",
|
||||
icon: "bwi-lock",
|
||||
marketingRoute: { route: "https://www.example.com/", external: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const upgradeButton = fixture.nativeElement.querySelector("app-upgrade-nav-button");
|
||||
|
||||
expect(upgradeButton).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,9 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s
|
||||
export class NavigationProductSwitcherComponent {
|
||||
constructor(private productSwitcherService: ProductSwitcherService) {}
|
||||
|
||||
protected readonly shouldShowPremiumUpgradeButton$: Observable<boolean> =
|
||||
this.productSwitcherService.shouldShowPremiumUpgradeButton$;
|
||||
|
||||
protected readonly accessibleProducts$: Observable<ProductSwitcherItem[]> =
|
||||
this.productSwitcherService.products$.pipe(map((products) => products.bento ?? []));
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag, FeatureFlagValueType } 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 { SyncService } from "@bitwarden/common/platform/sync";
|
||||
@@ -78,6 +81,18 @@ class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
|
||||
}
|
||||
}
|
||||
|
||||
class MockBillingAccountProfileStateService implements Partial<BillingAccountProfileStateService> {
|
||||
hasPremiumFromAnySource$(userId: UserId): Observable<boolean> {
|
||||
return of(false);
|
||||
}
|
||||
}
|
||||
|
||||
class MockConfigService implements Partial<ConfigService> {
|
||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
|
||||
return of(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "story-layout",
|
||||
template: `<ng-content></ng-content>`,
|
||||
@@ -117,6 +132,11 @@ export default {
|
||||
{ provide: ProviderService, useClass: MockProviderService },
|
||||
{ provide: SyncService, useClass: MockSyncService },
|
||||
{ provide: PlatformUtilsService, useClass: MockPlatformUtilsService },
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useClass: MockBillingAccountProfileStateService,
|
||||
},
|
||||
{ provide: ConfigService, useClass: MockConfigService },
|
||||
ProductSwitcherService,
|
||||
{
|
||||
provide: I18nService,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RouterModule } from "@angular/router";
|
||||
import { NavigationModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { UpgradeNavButtonComponent } from "../../billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component";
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
import { NavigationProductSwitcherComponent } from "./navigation-switcher/navigation-switcher.component";
|
||||
@@ -12,7 +13,14 @@ import { ProductSwitcherContentComponent } from "./product-switcher-content.comp
|
||||
import { ProductSwitcherComponent } from "./product-switcher.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SharedModule, A11yModule, RouterModule, NavigationModule, I18nPipe],
|
||||
imports: [
|
||||
SharedModule,
|
||||
A11yModule,
|
||||
RouterModule,
|
||||
NavigationModule,
|
||||
I18nPipe,
|
||||
UpgradeNavButtonComponent,
|
||||
],
|
||||
declarations: [
|
||||
ProductSwitcherComponent,
|
||||
ProductSwitcherContentComponent,
|
||||
|
||||
@@ -10,6 +10,9 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { FeatureFlag, FeatureFlagValueType } 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 { SyncService } from "@bitwarden/common/platform/sync";
|
||||
@@ -78,6 +81,18 @@ class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
|
||||
}
|
||||
}
|
||||
|
||||
class MockBillingAccountProfileStateService implements Partial<BillingAccountProfileStateService> {
|
||||
hasPremiumFromAnySource$(userId: UserId): Observable<boolean> {
|
||||
return of(false);
|
||||
}
|
||||
}
|
||||
|
||||
class MockConfigService implements Partial<ConfigService> {
|
||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
|
||||
return of(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "story-layout",
|
||||
template: `<ng-content></ng-content>`,
|
||||
@@ -114,6 +129,11 @@ export default {
|
||||
MockProviderService,
|
||||
{ provide: SyncService, useClass: MockSyncService },
|
||||
{ provide: PlatformUtilsService, useClass: MockPlatformUtilsService },
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useClass: MockBillingAccountProfileStateService,
|
||||
},
|
||||
{ provide: ConfigService, useClass: MockConfigService },
|
||||
MockPlatformUtilsService,
|
||||
ProductSwitcherService,
|
||||
{
|
||||
|
||||
@@ -11,6 +11,8 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
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";
|
||||
@@ -27,6 +29,8 @@ describe("ProductSwitcherService", () => {
|
||||
let providerService: MockProxy<ProviderService>;
|
||||
let accountService: FakeAccountService;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let activeRouteParams = convertToParamMap({ organizationId: "1234" });
|
||||
let singleOrgPolicyEnabled = false;
|
||||
const getLastSync = jest.fn().mockResolvedValue(new Date("2024-05-14"));
|
||||
@@ -48,6 +52,8 @@ describe("ProductSwitcherService", () => {
|
||||
providerService = mock<ProviderService>();
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
router.url = "/";
|
||||
router.events = of({});
|
||||
@@ -85,6 +91,8 @@ describe("ProductSwitcherService", () => {
|
||||
policyAppliesToUser$: () => of(singleOrgPolicyEnabled),
|
||||
},
|
||||
},
|
||||
{ provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService },
|
||||
{ provide: ConfigService, useValue: configService },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -325,4 +333,57 @@ describe("ProductSwitcherService", () => {
|
||||
|
||||
expect(appRoute).toEqual(["/organizations", "111-22-33"]);
|
||||
});
|
||||
|
||||
describe("shouldShowPremiumUpgradeButton$", () => {
|
||||
it("returns false when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
|
||||
initiateService();
|
||||
|
||||
const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$);
|
||||
|
||||
expect(shouldShow).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when there is no active account", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
accountService.activeAccount$ = of(null);
|
||||
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
|
||||
initiateService();
|
||||
|
||||
const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$);
|
||||
|
||||
expect(shouldShow).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when feature flag is enabled, account exists, and user has no premium", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
|
||||
initiateService();
|
||||
|
||||
const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$);
|
||||
|
||||
expect(shouldShow).toBe(true);
|
||||
expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith(
|
||||
userId,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when feature flag is enabled, account exists, but user has premium", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
|
||||
|
||||
initiateService();
|
||||
|
||||
const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$);
|
||||
|
||||
expect(shouldShow).toBe(false);
|
||||
expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith(
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
// @ts-strict-ignore
|
||||
import { Injectable } from "@angular/core";
|
||||
import { ActivatedRoute, NavigationEnd, NavigationStart, ParamMap, Router } from "@angular/router";
|
||||
import { combineLatest, filter, map, Observable, ReplaySubject, startWith, switchMap } from "rxjs";
|
||||
import {
|
||||
combineLatest,
|
||||
filter,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
ReplaySubject,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
canAccessOrgAdmin,
|
||||
@@ -15,6 +24,9 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
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 { SyncService } from "@bitwarden/common/platform/sync";
|
||||
@@ -99,6 +111,8 @@ export class ProductSwitcherService {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private policyService: PolicyService,
|
||||
private i18nService: I18nService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.pollUntilSynced();
|
||||
}
|
||||
@@ -118,6 +132,20 @@ export class ProductSwitcherService {
|
||||
switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)),
|
||||
);
|
||||
|
||||
shouldShowPremiumUpgradeButton$: Observable<boolean> = combineLatest([
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton),
|
||||
this.accountService.activeAccount$,
|
||||
]).pipe(
|
||||
switchMap(([featureFlag, account]) => {
|
||||
if (!featureFlag || !account) {
|
||||
return of(false);
|
||||
}
|
||||
return this.billingAccountProfileStateService
|
||||
.hasPremiumFromAnySource$(account.id)
|
||||
.pipe(map((hasPremium) => !hasPremium));
|
||||
}),
|
||||
);
|
||||
|
||||
products$: Observable<{
|
||||
bento: ProductSwitcherItem[];
|
||||
other: ProductSwitcherItem[];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<bit-container>
|
||||
<p bitTypography="body1">{{ "preferencesDesc" | i18n }}</p>
|
||||
<form [formGroup]="form" [bitSubmit]="submit" class="tw-w-1/2">
|
||||
<form [formGroup]="form" [bitSubmit]="submit" class="tw-w-full tw-max-w-md">
|
||||
<bit-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy">
|
||||
<span *ngIf="policy.timeout && policy.action">
|
||||
{{
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
label="{{ 'options' | i18n }}"
|
||||
></button>
|
||||
<bit-menu #cipherOptions>
|
||||
<ng-container *ngIf="isNotDeletedLoginCipher">
|
||||
<ng-container *ngIf="isActiveLoginCipher">
|
||||
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy">
|
||||
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
|
||||
{{ "copyUsername" | i18n }}
|
||||
@@ -151,6 +151,20 @@
|
||||
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
|
||||
{{ "eventLogs" | i18n }}
|
||||
</button>
|
||||
@if (showArchiveButton) {
|
||||
<button bitMenuItem (click)="archive()" type="button">
|
||||
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
|
||||
{{ "archiveVerb" | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (showUnArchiveButton) {
|
||||
<button bitMenuItem (click)="unarchive()" type="button">
|
||||
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
|
||||
{{ "unArchive" | i18n }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button bitMenuItem (click)="restore()" type="button" *ngIf="isDeleted && canRestoreCipher">
|
||||
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
|
||||
{{ "restore" | i18n }}
|
||||
|
||||
@@ -48,6 +48,14 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
* uses new permission restore logic from PM-15493
|
||||
*/
|
||||
@Input() canRestoreCipher: boolean;
|
||||
/**
|
||||
* user has archive permissions
|
||||
*/
|
||||
@Input() userCanArchive: boolean;
|
||||
/**
|
||||
* Enforge Org Data Ownership Policy Status
|
||||
*/
|
||||
@Input() enforceOrgDataOwnershipPolicy: boolean;
|
||||
|
||||
@Output() onEvent = new EventEmitter<VaultItemEvent<C>>();
|
||||
|
||||
@@ -76,6 +84,20 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
}
|
||||
}
|
||||
|
||||
protected get showArchiveButton() {
|
||||
return (
|
||||
this.userCanArchive &&
|
||||
!CipherViewLikeUtils.isArchived(this.cipher) &&
|
||||
!CipherViewLikeUtils.isDeleted(this.cipher) &&
|
||||
!this.cipher.organizationId
|
||||
);
|
||||
}
|
||||
|
||||
// If item is archived always show unarchive button, even if user is not premium
|
||||
protected get showUnArchiveButton() {
|
||||
return CipherViewLikeUtils.isArchived(this.cipher);
|
||||
}
|
||||
|
||||
protected get clickAction() {
|
||||
if (this.decryptionFailure) {
|
||||
return "showFailedToDecrypt";
|
||||
@@ -100,7 +122,12 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
return CipherViewLikeUtils.hasAttachments(this.cipher);
|
||||
}
|
||||
|
||||
// Do not show attachments button if:
|
||||
// item is archived AND user is not premium user
|
||||
protected get showAttachments() {
|
||||
if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) {
|
||||
return false;
|
||||
}
|
||||
return this.canEditCipher || this.hasAttachments;
|
||||
}
|
||||
|
||||
@@ -124,7 +151,11 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
return CipherViewLikeUtils.decryptionFailure(this.cipher);
|
||||
}
|
||||
|
||||
// Do Not show Assign to Collections option if item is archived
|
||||
protected get showAssignToCollections() {
|
||||
if (CipherViewLikeUtils.isArchived(this.cipher)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
this.organizations?.length &&
|
||||
this.canAssignCollections &&
|
||||
@@ -132,7 +163,16 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
);
|
||||
}
|
||||
|
||||
// Do NOT show clone option if:
|
||||
// item is archived AND user is not premium user
|
||||
// item is archived AND enforce org data ownership policy is on
|
||||
protected get showClone() {
|
||||
if (
|
||||
CipherViewLikeUtils.isArchived(this.cipher) &&
|
||||
(!this.userCanArchive || this.enforceOrgDataOwnershipPolicy)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return this.cloneable && !CipherViewLikeUtils.isDeleted(this.cipher);
|
||||
}
|
||||
|
||||
@@ -140,10 +180,11 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
return this.useEvents && this.cipher.organizationId;
|
||||
}
|
||||
|
||||
protected get isNotDeletedLoginCipher() {
|
||||
protected get isActiveLoginCipher() {
|
||||
return (
|
||||
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login &&
|
||||
!CipherViewLikeUtils.isDeleted(this.cipher)
|
||||
!CipherViewLikeUtils.isDeleted(this.cipher) &&
|
||||
!CipherViewLikeUtils.isArchived(this.cipher)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,20 +232,20 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
|
||||
protected get showCopyUsername(): boolean {
|
||||
const usernameCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "username");
|
||||
return this.isNotDeletedLoginCipher && usernameCopy;
|
||||
return this.isActiveLoginCipher && usernameCopy;
|
||||
}
|
||||
|
||||
protected get showCopyPassword(): boolean {
|
||||
const passwordCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "password");
|
||||
return this.isNotDeletedLoginCipher && this.cipher.viewPassword && passwordCopy;
|
||||
return this.isActiveLoginCipher && this.cipher.viewPassword && passwordCopy;
|
||||
}
|
||||
|
||||
protected get showCopyTotp(): boolean {
|
||||
return this.isNotDeletedLoginCipher && this.showTotpCopyButton;
|
||||
return this.isActiveLoginCipher && this.showTotpCopyButton;
|
||||
}
|
||||
|
||||
protected get showLaunchUri(): boolean {
|
||||
return this.isNotDeletedLoginCipher && this.canLaunch;
|
||||
return this.isActiveLoginCipher && this.canLaunch;
|
||||
}
|
||||
|
||||
protected get isDeletedCanRestore(): boolean {
|
||||
@@ -236,6 +277,14 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
|
||||
this.onEvent.emit({ type: "viewEvents", item: this.cipher });
|
||||
}
|
||||
|
||||
protected archive() {
|
||||
this.onEvent.emit({ type: "archive", items: [this.cipher] });
|
||||
}
|
||||
|
||||
protected unarchive() {
|
||||
this.onEvent.emit({ type: "unarchive", items: [this.cipher] });
|
||||
}
|
||||
|
||||
protected restore() {
|
||||
this.onEvent.emit({ type: "restore", items: [this.cipher] });
|
||||
}
|
||||
|
||||
@@ -20,4 +20,6 @@ export type VaultItemEvent<C extends CipherViewLike> =
|
||||
| { type: "delete"; items: VaultItem<C>[] }
|
||||
| { type: "copyField"; item: C; field: "username" | "password" | "totp" }
|
||||
| { type: "moveToFolder"; items: C[] }
|
||||
| { type: "assignToCollections"; items: C[] };
|
||||
| { type: "assignToCollections"; items: C[] }
|
||||
| { type: "archive"; items: C[] }
|
||||
| { type: "unarchive"; items: C[] };
|
||||
|
||||
@@ -83,6 +83,22 @@
|
||||
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
|
||||
{{ "assignToCollections" | i18n }}
|
||||
</button>
|
||||
|
||||
<button *ngIf="bulkArchiveAllowed" type="button" bitMenuItem (click)="bulkArchive()">
|
||||
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
|
||||
{{ "archiveVerb" | i18n }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="bulkUnarchiveAllowed"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="bulkUnarchive()"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
|
||||
{{ "unArchive" | i18n }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="canRestoreSelected$ | async"
|
||||
type="button"
|
||||
@@ -161,6 +177,8 @@
|
||||
"
|
||||
(checkedToggled)="selection.toggle(item)"
|
||||
(onEvent)="event($event)"
|
||||
[userCanArchive]="userCanArchive"
|
||||
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy"
|
||||
></tr>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
@@ -64,6 +64,8 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
@Input() addAccessStatus: number;
|
||||
@Input() addAccessToggle: boolean;
|
||||
@Input() activeCollection: CollectionView | undefined;
|
||||
@Input() userCanArchive: boolean;
|
||||
@Input() enforceOrgDataOwnershipPolicy: boolean;
|
||||
|
||||
private restrictedPolicies = toSignal(this.restrictedItemTypesService.restricted$);
|
||||
|
||||
@@ -191,6 +193,30 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
);
|
||||
}
|
||||
|
||||
get bulkArchiveAllowed() {
|
||||
if (this.selection.selected.length === 0 || !this.userCanArchive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.userCanArchive &&
|
||||
!this.selection.selected.find(
|
||||
(item) => item.cipher && (item.cipher.organizationId || item.cipher.archivedDate),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Bulk Unarchive button should appear for Archive vault even if user does not have archive permissions
|
||||
get bulkUnarchiveAllowed() {
|
||||
if (this.selection.selected.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !this.selection.selected.find(
|
||||
(item) => !item.cipher.archivedDate || item.cipher.organizationId,
|
||||
);
|
||||
}
|
||||
|
||||
//@TODO: remove this function when removing the limitItemDeletion$ feature flag.
|
||||
get showDelete(): boolean {
|
||||
if (this.selection.selected.length === 0) {
|
||||
@@ -221,7 +247,17 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
}
|
||||
|
||||
get bulkAssignToCollectionsAllowed() {
|
||||
return this.showBulkAddToCollections && this.ciphers.length > 0;
|
||||
return (
|
||||
this.showBulkAddToCollections &&
|
||||
this.ciphers.length > 0 &&
|
||||
!this.anySelectedCiphersAreArchived
|
||||
);
|
||||
}
|
||||
|
||||
get anySelectedCiphersAreArchived() {
|
||||
return this.selection.selected.some(
|
||||
(item) => item.cipher && CipherViewLikeUtils.isArchived(item.cipher),
|
||||
);
|
||||
}
|
||||
|
||||
protected canEditCollection(collection: CollectionView): boolean {
|
||||
@@ -270,6 +306,24 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
});
|
||||
}
|
||||
|
||||
protected bulkArchive() {
|
||||
this.event({
|
||||
type: "archive",
|
||||
items: this.selection.selected
|
||||
.filter((item) => item.cipher !== undefined)
|
||||
.map((item) => item.cipher),
|
||||
});
|
||||
}
|
||||
|
||||
protected bulkUnarchive() {
|
||||
this.event({
|
||||
type: "unarchive",
|
||||
items: this.selection.selected
|
||||
.filter((item) => item.cipher !== undefined)
|
||||
.map((item) => item.cipher),
|
||||
});
|
||||
}
|
||||
|
||||
protected bulkRestore() {
|
||||
this.event({
|
||||
type: "restore",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
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";
|
||||
@@ -29,6 +30,7 @@ describe("VaultBannersComponent", () => {
|
||||
let messageSubject: Subject<{ command: string }>;
|
||||
const premiumBanner$ = new BehaviorSubject<boolean>(false);
|
||||
const pendingAuthRequest$ = new BehaviorSubject<boolean>(false);
|
||||
const PM24996_ImplementUpgradeFromFreeDialogFlag$ = new BehaviorSubject<boolean>(false);
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
|
||||
const bannerService = mock<VaultBannersService>({
|
||||
@@ -88,7 +90,14 @@ describe("VaultBannersComponent", () => {
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: mock<ConfigService>(),
|
||||
useValue: mock<ConfigService>({
|
||||
getFeatureFlag$: jest.fn((flag: FeatureFlag) => {
|
||||
if (flag === FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog) {
|
||||
return PM24996_ImplementUpgradeFromFreeDialogFlag$;
|
||||
}
|
||||
return new BehaviorSubject(false);
|
||||
}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -104,8 +113,14 @@ describe("VaultBannersComponent", () => {
|
||||
});
|
||||
|
||||
describe("premiumBannerVisible$", () => {
|
||||
it("shows premium banner", async () => {
|
||||
beforeEach(() => {
|
||||
// Reset feature flag to default (false) before each test
|
||||
PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false);
|
||||
});
|
||||
|
||||
it("shows premium banner when shouldShowPremiumBanner is true and feature flag is off", async () => {
|
||||
premiumBanner$.next(true);
|
||||
PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -113,8 +128,29 @@ describe("VaultBannersComponent", () => {
|
||||
expect(banner.componentInstance.bannerType()).toBe("premium");
|
||||
});
|
||||
|
||||
it("dismisses premium banner", async () => {
|
||||
it("hides premium banner when feature flag is enabled", async () => {
|
||||
premiumBanner$.next(true);
|
||||
PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.debugElement.query(By.directive(BannerComponent));
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
|
||||
it("dismisses premium banner when shouldShowPremiumBanner is false", async () => {
|
||||
premiumBanner$.next(false);
|
||||
PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.debugElement.query(By.directive(BannerComponent));
|
||||
expect(banner).toBeNull();
|
||||
});
|
||||
|
||||
it("hides premium banner when both shouldShowPremiumBanner is false and feature flag is enabled", async () => {
|
||||
premiumBanner$.next(false);
|
||||
PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Router } from "@angular/router";
|
||||
import { filter, firstValueFrom, map, Observable, switchMap } from "rxjs";
|
||||
import { combineLatest, filter, firstValueFrom, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
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 { MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BannerModule } from "@bitwarden/components";
|
||||
@@ -39,10 +41,21 @@ export class VaultBannersComponent implements OnInit {
|
||||
private router: Router,
|
||||
private accountService: AccountService,
|
||||
private messageListener: MessageListener,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.premiumBannerVisible$ = this.activeUserId$.pipe(
|
||||
filter((userId): userId is UserId => userId != null),
|
||||
switchMap((userId) => this.vaultBannerService.shouldShowPremiumBanner$(userId)),
|
||||
switchMap((userId) =>
|
||||
combineLatest([
|
||||
this.vaultBannerService.shouldShowPremiumBanner$(userId),
|
||||
this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog),
|
||||
]).pipe(
|
||||
map(
|
||||
([shouldShowBanner, PM24996_ImplementUpgradeFromFreeDialogEnabled]) =>
|
||||
shouldShowBanner && !PM24996_ImplementUpgradeFromFreeDialogEnabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Listen for auth request messages and show banner immediately
|
||||
|
||||
@@ -242,16 +242,13 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
async buildAllFilters(): Promise<VaultFilterList> {
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const hasArchiveFlag = await firstValueFrom(this.cipherArchiveService.hasArchiveFlagEnabled$());
|
||||
const builderFilter = {} as VaultFilterList;
|
||||
builderFilter.organizationFilter = await this.addOrganizationFilter();
|
||||
builderFilter.typeFilter = await this.addTypeFilter();
|
||||
builderFilter.folderFilter = await this.addFolderFilter();
|
||||
builderFilter.collectionFilter = await this.addCollectionFilter();
|
||||
if (
|
||||
(await firstValueFrom(this.cipherArchiveService.userCanArchive$(userId))) ||
|
||||
(await firstValueFrom(this.cipherArchiveService.showArchiveVault$(userId)))
|
||||
) {
|
||||
if (hasArchiveFlag) {
|
||||
builderFilter.archiveFilter = await this.addArchiveFilter();
|
||||
}
|
||||
builderFilter.trashFilter = await this.addTrashFilter();
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
[useEvents]="false"
|
||||
[showAdminActions]="false"
|
||||
[showBulkAddToCollections]="true"
|
||||
[userCanArchive]="userCanArchive$ | async"
|
||||
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy$ | async"
|
||||
(onEvent)="onVaultItemsEvent($event)"
|
||||
>
|
||||
</app-vault-items>
|
||||
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
getOrganizationById,
|
||||
OrganizationService,
|
||||
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
@@ -91,6 +93,7 @@ import {
|
||||
DefaultCipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services";
|
||||
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
|
||||
import { OrganizationWarningsService } from "@bitwarden/web-vault/app/billing/organizations/warnings/services";
|
||||
|
||||
@@ -103,7 +106,6 @@ import {
|
||||
CollectionDialogTabType,
|
||||
openCollectionDialog,
|
||||
} from "../../admin-console/organizations/shared/components/collection-dialog";
|
||||
import { UnifiedUpgradePromptService } from "../../billing/individual/upgrade/services/unified-upgrade-prompt.service";
|
||||
import { SharedModule } from "../../shared/shared.module";
|
||||
import { AssignCollectionsWebComponent } from "../components/assign-collections";
|
||||
import {
|
||||
@@ -210,7 +212,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
.pipe(map((a) => a?.id))
|
||||
.pipe(switchMap((id) => this.organizationService.organizations$(id)));
|
||||
|
||||
private userCanArchive$ = this.accountService.activeAccount$.pipe(
|
||||
protected userCanArchive$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => {
|
||||
return this.cipherArchiveService.userCanArchive$(userId);
|
||||
@@ -256,7 +258,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
},
|
||||
archive: {
|
||||
title: "noItemsInArchive",
|
||||
description: "archivedItemsDescription",
|
||||
description: "noItemsInArchiveDesc",
|
||||
icon: this.itemTypesIcon,
|
||||
},
|
||||
};
|
||||
@@ -275,6 +277,15 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
}),
|
||||
);
|
||||
|
||||
protected enforceOrgDataOwnershipPolicy$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
|
||||
),
|
||||
);
|
||||
|
||||
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
constructor(
|
||||
private syncService: SyncService,
|
||||
private route: ActivatedRoute,
|
||||
@@ -307,6 +318,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
private restrictedItemTypesService: RestrictedItemTypesService,
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
private organizationWarningsService: OrganizationWarningsService,
|
||||
private policyService: PolicyService,
|
||||
private unifiedUpgradePromptService: UnifiedUpgradePromptService,
|
||||
) {}
|
||||
|
||||
@@ -405,7 +417,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
allowedCiphers$,
|
||||
filter$,
|
||||
this.currentSearchText$,
|
||||
this.userCanArchive$,
|
||||
this.cipherArchiveService.hasArchiveFlagEnabled$(),
|
||||
]).pipe(
|
||||
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
|
||||
concatMap(async ([ciphers, filter, searchText, archiveEnabled]) => {
|
||||
@@ -653,12 +665,140 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
case "assignToCollections":
|
||||
await this.bulkAssignToCollections(event.items);
|
||||
break;
|
||||
case "archive":
|
||||
if (event.items.length === 1) {
|
||||
await this.archive(event.items[0]);
|
||||
} else {
|
||||
await this.bulkArchive(event.items);
|
||||
}
|
||||
break;
|
||||
case "unarchive":
|
||||
if (event.items.length === 1) {
|
||||
await this.unarchive(event.items[0]);
|
||||
} else {
|
||||
await this.bulkUnarchive(event.items);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
this.processingEvent = false;
|
||||
}
|
||||
}
|
||||
|
||||
async archive(cipher: C) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "archiveItem" },
|
||||
content: { key: "archiveItemConfirmDesc" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
}
|
||||
const activeUserId = await firstValueFrom(this.userId$);
|
||||
try {
|
||||
await this.cipherArchiveService.archiveWithServer(cipher.id as CipherId, activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemWasSentToArchive"),
|
||||
});
|
||||
this.refresh();
|
||||
} catch (e) {
|
||||
this.logService.error("Error archiving cipher", e);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async bulkArchive(ciphers: C[]) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "archiveBulkItems" },
|
||||
content: { key: "archiveBulkItemsConfirmDesc" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.repromptCipher(ciphers))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(this.userId$);
|
||||
const cipherIds = ciphers.map((c) => c.id as CipherId);
|
||||
try {
|
||||
await this.cipherArchiveService.archiveWithServer(cipherIds as CipherId[], activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemsWereSentToArchive"),
|
||||
});
|
||||
this.refresh();
|
||||
} catch (e) {
|
||||
this.logService.error("Error archiving ciphers", e);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async unarchive(cipher: C) {
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
}
|
||||
const activeUserId = await firstValueFrom(this.userId$);
|
||||
|
||||
try {
|
||||
await this.cipherArchiveService.unarchiveWithServer(cipher.id as CipherId, activeUserId);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemUnarchived"),
|
||||
});
|
||||
|
||||
this.refresh();
|
||||
} catch (e) {
|
||||
this.logService.error("Error unarchiving cipher", e);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async bulkUnarchive(ciphers: C[]) {
|
||||
if (!(await this.repromptCipher(ciphers))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeUserId = await firstValueFrom(this.userId$);
|
||||
const cipherIds = ciphers.map((c) => c.id as CipherId);
|
||||
try {
|
||||
await this.cipherArchiveService.unarchiveWithServer(cipherIds as CipherId[], activeUserId);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("bulkUnarchiveItems"),
|
||||
});
|
||||
|
||||
this.refresh();
|
||||
} catch (e) {
|
||||
this.logService.error("Error unarchiving ciphers", e);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("errorOccurred"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async applyOrganizationFilter(orgId: string) {
|
||||
if (orgId == null) {
|
||||
orgId = "MyVault";
|
||||
|
||||
@@ -90,8 +90,8 @@
|
||||
"assignMembersTasksToMonitorProgress": {
|
||||
"message": "Assign members tasks to monitor progress"
|
||||
},
|
||||
"onceYouReviewApplications": {
|
||||
"message": "Once you review applications and mark them as critical, they will display here."
|
||||
"onceYouReviewApps": {
|
||||
"message": "Once you review applications and mark them as critical, you can assign tasks to members to resolve at-risk items and monitor progress here"
|
||||
},
|
||||
"sendReminders": {
|
||||
"message": "Send reminders"
|
||||
@@ -11298,12 +11298,47 @@
|
||||
"message": "Archive",
|
||||
"description": "Verb"
|
||||
},
|
||||
"unArchive": {
|
||||
"message": "Unarchive"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
"message": "Items in archive"
|
||||
},
|
||||
"noItemsInArchive": {
|
||||
"message": "No items in archive"
|
||||
},
|
||||
"archivedItemsDescription": {
|
||||
"noItemsInArchiveDesc": {
|
||||
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
|
||||
},
|
||||
"itemWasSentToArchive": {
|
||||
"message": "Item was sent to archive"
|
||||
},
|
||||
"itemsWereSentToArchive": {
|
||||
"message": "Items were sent to archive"
|
||||
},
|
||||
"itemUnarchived": {
|
||||
"message": "Item was unarchived"
|
||||
},
|
||||
"bulkArchiveItems": {
|
||||
"message": "Items archived"
|
||||
},
|
||||
"bulkUnarchiveItems": {
|
||||
"message": "Items unarchived"
|
||||
},
|
||||
"archiveItem": {
|
||||
"message": "Archive item",
|
||||
"description": "Verb"
|
||||
},
|
||||
"archiveItemConfirmDesc": {
|
||||
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?"
|
||||
},
|
||||
"archiveBulkItems": {
|
||||
"message": "Archive items",
|
||||
"description": "Verb"
|
||||
},
|
||||
"archiveBulkItemsConfirmDesc": {
|
||||
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive these items?"
|
||||
},
|
||||
"businessUnit": {
|
||||
"message": "Business Unit"
|
||||
},
|
||||
@@ -11805,6 +11840,15 @@
|
||||
"continueWithoutUpgrading": {
|
||||
"message": "Continue without upgrading"
|
||||
},
|
||||
"upgradeYourPlan": {
|
||||
"message": "Upgrade your plan"
|
||||
},
|
||||
"upgradeNow": {
|
||||
"message": "Upgrade now"
|
||||
},
|
||||
"formWillCreateNewFamiliesOrgMessage": {
|
||||
"message": "Completing this form will create a new Families organization. You can upgrade your Free organization from the Admin Console."
|
||||
},
|
||||
"upgradeErrorMessage": {
|
||||
"message": "We encountered an error while processing your upgrade. Please try again."
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/* eslint-disable no-undef, @typescript-eslint/no-var-requires */
|
||||
const path = require("path");
|
||||
const config = require("../../libs/components/tailwind.config.base");
|
||||
|
||||
config.content = [
|
||||
"./src/**/*.{html,ts}",
|
||||
"../../libs/components/src/**/*.{html,ts}",
|
||||
"../../libs/assets/src/**/*.{html,ts}",
|
||||
"../../libs/auth/src/**/*.{html,ts}",
|
||||
"../../libs/key-management-ui/src/**/*.{html,ts}",
|
||||
"../../libs/vault/src/**/*.{html,ts}",
|
||||
"../../libs/angular/src/**/*.{html,ts}",
|
||||
"../../libs/tools/generator/components/src/**/*.{html,ts}",
|
||||
"../../bitwarden_license/bit-web/src/**/*.{html,ts}",
|
||||
path.resolve(__dirname, "./src/**/*.{html,ts}"),
|
||||
path.resolve(__dirname, "../../libs/components/src/**/*.{html,ts}"),
|
||||
path.resolve(__dirname, "../../libs/assets/src/**/*.{html,ts}"),
|
||||
path.resolve(__dirname, "../../libs/auth/src/**/*.{html,ts}"),
|
||||
path.resolve(__dirname, "../../libs/key-management-ui/src/**/*.{html,ts}"),
|
||||
path.resolve(__dirname, "../../libs/vault/src/**/*.{html,ts}"),
|
||||
path.resolve(__dirname, "../../libs/angular/src/**/*.{html,ts}"),
|
||||
path.resolve(__dirname, "../../libs/tools/generator/components/src/**/*.{html,ts}"),
|
||||
path.resolve(__dirname, "../../bitwarden_license/bit-web/src/**/*.{html,ts}"),
|
||||
];
|
||||
config.corePlugins.preflight = true;
|
||||
|
||||
|
||||
@@ -9,17 +9,21 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
const webpack = require("webpack");
|
||||
|
||||
const config = require("./config.js");
|
||||
const pjson = require("./package.json");
|
||||
const config = require(path.resolve(__dirname, "config.js"));
|
||||
const pjson = require(path.resolve(__dirname, "package.json"));
|
||||
|
||||
module.exports.getEnv = function getEnv() {
|
||||
const ENV = process.env.ENV == null ? "development" : process.env.ENV;
|
||||
module.exports.getEnv = function getEnv(params) {
|
||||
const ENV = params.env || (process.env.ENV == null ? "development" : process.env.ENV);
|
||||
const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV;
|
||||
const LOGGING = process.env.LOGGING != "false";
|
||||
|
||||
return { ENV, NODE_ENV, LOGGING };
|
||||
};
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
outputPath: path.resolve(__dirname, "build"),
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{
|
||||
@@ -29,10 +33,14 @@ module.exports.getEnv = function getEnv() {
|
||||
* entryModule: string;
|
||||
* };
|
||||
* tsConfig: string;
|
||||
* outputPath?: string;
|
||||
* mode?: string;
|
||||
* env?: string;
|
||||
* }} params
|
||||
*/
|
||||
module.exports.buildConfig = function buildConfig(params) {
|
||||
const { ENV, NODE_ENV, LOGGING } = module.exports.getEnv();
|
||||
params = { ...DEFAULT_PARAMS, ...params };
|
||||
const { ENV, NODE_ENV, LOGGING } = module.exports.getEnv(params);
|
||||
|
||||
const envConfig = config.load(ENV);
|
||||
if (LOGGING) {
|
||||
@@ -89,6 +97,9 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
loader: "postcss-loader",
|
||||
options: {
|
||||
sourceMap: true,
|
||||
postcssOptions: {
|
||||
config: path.resolve(__dirname, "postcss.config.js"),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -99,7 +110,7 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
{
|
||||
loader: "babel-loader",
|
||||
options: {
|
||||
configFile: "../../babel.config.json",
|
||||
configFile: path.resolve(__dirname, "../../babel.config.json"),
|
||||
cacheDirectory: NODE_ENV !== "production",
|
||||
},
|
||||
},
|
||||
@@ -113,43 +124,43 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
|
||||
const plugins = [
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/index.html",
|
||||
template: path.resolve(__dirname, "src/index.html"),
|
||||
filename: "index.html",
|
||||
chunks: ["theme_head", "app/polyfills", "app/vendor", "app/main", "styles"],
|
||||
}),
|
||||
new HtmlWebpackInjector(),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/connectors/webauthn.html",
|
||||
template: path.resolve(__dirname, "src/connectors/webauthn.html"),
|
||||
filename: "webauthn-connector.html",
|
||||
chunks: ["connectors/webauthn", "styles"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/connectors/webauthn-mobile.html",
|
||||
template: path.resolve(__dirname, "src/connectors/webauthn-mobile.html"),
|
||||
filename: "webauthn-mobile-connector.html",
|
||||
chunks: ["connectors/webauthn", "styles"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/connectors/webauthn-fallback.html",
|
||||
template: path.resolve(__dirname, "src/connectors/webauthn-fallback.html"),
|
||||
filename: "webauthn-fallback-connector.html",
|
||||
chunks: ["connectors/webauthn-fallback", "styles"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/connectors/sso.html",
|
||||
template: path.resolve(__dirname, "src/connectors/sso.html"),
|
||||
filename: "sso-connector.html",
|
||||
chunks: ["connectors/sso", "styles"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/connectors/redirect.html",
|
||||
template: path.resolve(__dirname, "src/connectors/redirect.html"),
|
||||
filename: "redirect-connector.html",
|
||||
chunks: ["connectors/redirect", "styles"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/connectors/duo-redirect.html",
|
||||
template: path.resolve(__dirname, "src/connectors/duo-redirect.html"),
|
||||
filename: "duo-redirect-connector.html",
|
||||
chunks: ["connectors/duo-redirect", "styles"],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: "./src/404.html",
|
||||
template: path.resolve(__dirname, "src/404.html"),
|
||||
filename: "404.html",
|
||||
chunks: ["styles"],
|
||||
// 404 page is a wildcard, this ensures it uses absolute paths.
|
||||
@@ -157,18 +168,28 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{ from: "./src/.nojekyll" },
|
||||
{ from: "./src/manifest.json" },
|
||||
{ from: "./src/favicon.ico" },
|
||||
{ from: "./src/browserconfig.xml" },
|
||||
{ from: "./src/app-id.json" },
|
||||
{ from: "./src/images", to: "images" },
|
||||
{ from: "./src/videos", to: "videos" },
|
||||
{ from: "./src/locales", to: "locales" },
|
||||
{ from: "../../node_modules/qrious/dist/qrious.min.js", to: "scripts" },
|
||||
{ from: "../../node_modules/braintree-web-drop-in/dist/browser/dropin.js", to: "scripts" },
|
||||
{ from: path.resolve(__dirname, "src/.nojekyll") },
|
||||
{ from: path.resolve(__dirname, "src/manifest.json") },
|
||||
{ from: path.resolve(__dirname, "src/favicon.ico") },
|
||||
{ from: path.resolve(__dirname, "src/browserconfig.xml") },
|
||||
{ from: path.resolve(__dirname, "src/app-id.json") },
|
||||
{ from: path.resolve(__dirname, "src/images"), to: "images" },
|
||||
{ from: path.resolve(__dirname, "src/images/icons"), to: "images" },
|
||||
{ from: path.resolve(__dirname, "src/videos"), to: "videos" },
|
||||
{ from: path.resolve(__dirname, "src/locales"), to: "locales" },
|
||||
{
|
||||
from: "./src/version.json",
|
||||
from: path.resolve(__dirname, "../../node_modules/qrious/dist/qrious.min.js"),
|
||||
to: "scripts",
|
||||
},
|
||||
{
|
||||
from: path.resolve(
|
||||
__dirname,
|
||||
"../../node_modules/braintree-web-drop-in/dist/browser/dropin.js",
|
||||
),
|
||||
to: "scripts",
|
||||
},
|
||||
{
|
||||
from: path.resolve(__dirname, "src/version.json"),
|
||||
transform(content, path) {
|
||||
return content.toString().replace("process.env.APPLICATION_VERSION", pjson.version);
|
||||
},
|
||||
@@ -203,7 +224,9 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
];
|
||||
|
||||
// ref: https://webpack.js.org/configuration/dev-server/#devserver
|
||||
let certSuffix = fs.existsSync("dev-server.local.pem") ? ".local" : ".shared";
|
||||
let certSuffix = fs.existsSync(path.resolve(__dirname, "dev-server.local.pem"))
|
||||
? ".local"
|
||||
: ".shared";
|
||||
const devServer =
|
||||
NODE_ENV !== "development"
|
||||
? {}
|
||||
@@ -211,8 +234,8 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
server: {
|
||||
type: "https",
|
||||
options: {
|
||||
key: fs.readFileSync("dev-server" + certSuffix + ".pem"),
|
||||
cert: fs.readFileSync("dev-server" + certSuffix + ".pem"),
|
||||
key: fs.readFileSync(path.resolve(__dirname, "dev-server" + certSuffix + ".pem")),
|
||||
cert: fs.readFileSync(path.resolve(__dirname, "dev-server" + certSuffix + ".pem")),
|
||||
},
|
||||
},
|
||||
// host: '192.168.1.9',
|
||||
@@ -332,6 +355,15 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
hot: false,
|
||||
port: envConfig.dev?.port ?? 8080,
|
||||
allowedHosts: envConfig.dev?.allowedHosts ?? "auto",
|
||||
static: {
|
||||
directory: path.resolve(params.outputPath),
|
||||
publicPath: "/",
|
||||
},
|
||||
devMiddleware: {
|
||||
// when running `serve` locally this option writes all built files to `dist`
|
||||
// files are still served from memory, this is just a helpful debug tool
|
||||
writeToDisk: true,
|
||||
},
|
||||
client: {
|
||||
overlay: {
|
||||
errors: true,
|
||||
@@ -347,15 +379,21 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
devServer: devServer,
|
||||
target: "web",
|
||||
entry: {
|
||||
"app/polyfills": "./src/polyfills.ts",
|
||||
"app/polyfills": path.resolve(__dirname, "src/polyfills.ts"),
|
||||
"app/main": params.app.entry,
|
||||
"connectors/webauthn": "./src/connectors/webauthn.ts",
|
||||
"connectors/webauthn-fallback": "./src/connectors/webauthn-fallback.ts",
|
||||
"connectors/sso": "./src/connectors/sso.ts",
|
||||
"connectors/duo-redirect": "./src/connectors/duo-redirect.ts",
|
||||
"connectors/redirect": "./src/connectors/redirect.ts",
|
||||
styles: ["./src/scss/styles.scss", "./src/scss/tailwind.css"],
|
||||
theme_head: "./src/theme.ts",
|
||||
"connectors/webauthn": path.resolve(__dirname, "src/connectors/webauthn.ts"),
|
||||
"connectors/webauthn-fallback": path.resolve(
|
||||
__dirname,
|
||||
"src/connectors/webauthn-fallback.ts",
|
||||
),
|
||||
"connectors/sso": path.resolve(__dirname, "src/connectors/sso.ts"),
|
||||
"connectors/duo-redirect": path.resolve(__dirname, "src/connectors/duo-redirect.ts"),
|
||||
"connectors/redirect": path.resolve(__dirname, "src/connectors/redirect.ts"),
|
||||
styles: [
|
||||
path.resolve(__dirname, "src/scss/styles.scss"),
|
||||
path.resolve(__dirname, "src/scss/tailwind.css"),
|
||||
],
|
||||
theme_head: path.resolve(__dirname, "src/theme.ts"),
|
||||
},
|
||||
cache:
|
||||
NODE_ENV === "production"
|
||||
@@ -402,7 +440,10 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
resolve: {
|
||||
extensions: [".ts", ".js"],
|
||||
symlinks: false,
|
||||
modules: [path.resolve("../../node_modules")],
|
||||
modules: [
|
||||
path.resolve(__dirname, "../../node_modules"),
|
||||
path.resolve(process.cwd(), "node_modules"),
|
||||
],
|
||||
fallback: {
|
||||
buffer: false,
|
||||
util: require.resolve("util/"),
|
||||
@@ -415,7 +456,7 @@ module.exports.buildConfig = function buildConfig(params) {
|
||||
},
|
||||
output: {
|
||||
filename: "[name].[contenthash].js",
|
||||
path: path.resolve(__dirname, "build"),
|
||||
path: params.outputPath,
|
||||
clean: true,
|
||||
},
|
||||
module: {
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
const { buildConfig } = require("./webpack.base");
|
||||
const path = require("path");
|
||||
const { buildConfig } = require(path.resolve(__dirname, "webpack.base"));
|
||||
|
||||
module.exports = buildConfig({
|
||||
configName: "OSS",
|
||||
app: {
|
||||
entry: "./src/main.ts",
|
||||
entryModule: "src/app/app.module#AppModule",
|
||||
},
|
||||
tsConfig: "tsconfig.build.json",
|
||||
});
|
||||
module.exports = (webpackConfig, context) => {
|
||||
const isNxBuild = context && context.options;
|
||||
|
||||
if (isNxBuild) {
|
||||
return buildConfig({
|
||||
configName: "OSS",
|
||||
app: {
|
||||
entry: context.options.main
|
||||
? path.resolve(context.context.root, context.options.main)
|
||||
: path.resolve(__dirname, "src/main.ts"),
|
||||
entryModule: "src/app/app.module#AppModule",
|
||||
},
|
||||
tsConfig: "apps/web/tsconfig.build.json",
|
||||
outputPath: path.resolve(context.context.root, context.options.outputPath),
|
||||
});
|
||||
} else {
|
||||
return buildConfig({
|
||||
configName: "OSS",
|
||||
app: {
|
||||
entry: path.resolve(__dirname, "src/main.ts"),
|
||||
entryModule: "src/app/app.module#AppModule",
|
||||
},
|
||||
tsConfig: "tsconfig.build.json",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
4
bitwarden_license/bit-browser/tsconfig.build.json
Normal file
4
bitwarden_license/bit-browser/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["**/*.stories.*", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -1,13 +1,54 @@
|
||||
const path = require("path");
|
||||
const { buildConfig } = require("../../apps/browser/webpack.base");
|
||||
|
||||
module.exports = buildConfig({
|
||||
configName: "Commercial",
|
||||
popup: {
|
||||
entry: "../../bitwarden_license/bit-browser/src/popup/main.ts",
|
||||
entryModule: "../../bitwarden_license/bit-browser/src/popup/app.module#AppModule",
|
||||
},
|
||||
background: {
|
||||
entry: "../../bitwarden_license/bit-browser/src/platform/background.ts",
|
||||
},
|
||||
tsConfig: "../../bitwarden_license/bit-browser/tsconfig.json",
|
||||
});
|
||||
module.exports = (webpackConfig, context) => {
|
||||
// Detect if called by Nx (context parameter exists)
|
||||
const isNxBuild = context && context.options;
|
||||
|
||||
if (isNxBuild) {
|
||||
// Nx build configuration
|
||||
const mode = context.options.mode || "development";
|
||||
if (process.env.NODE_ENV == null) {
|
||||
process.env.NODE_ENV = mode;
|
||||
}
|
||||
const ENV = (process.env.ENV = process.env.NODE_ENV);
|
||||
|
||||
// Set environment variables from Nx context
|
||||
if (context.options.env) {
|
||||
Object.keys(context.options.env).forEach((key) => {
|
||||
process.env[key] = context.options.env[key];
|
||||
});
|
||||
}
|
||||
|
||||
return buildConfig({
|
||||
configName: "Commercial",
|
||||
popup: {
|
||||
entry: path.resolve(__dirname, "src/popup/main.ts"),
|
||||
entryModule: "bitwarden_license/bit-browser/src/popup/app.module#AppModule",
|
||||
},
|
||||
background: {
|
||||
entry: path.resolve(__dirname, "src/platform/background.ts"),
|
||||
},
|
||||
tsConfig: path.resolve(__dirname, "tsconfig.build.json"),
|
||||
outputPath:
|
||||
context.context && context.context.root
|
||||
? path.resolve(context.context.root, context.options.outputPath)
|
||||
: context.options.outputPath,
|
||||
mode: mode,
|
||||
env: ENV,
|
||||
});
|
||||
} else {
|
||||
// npm build configuration
|
||||
return buildConfig({
|
||||
configName: "Commercial",
|
||||
popup: {
|
||||
entry: path.resolve(__dirname, "src/popup/main.ts"),
|
||||
entryModule: "bitwarden_license/bit-browser/src/popup/app.module#AppModule",
|
||||
},
|
||||
background: {
|
||||
entry: path.resolve(__dirname, "src/platform/background.ts"),
|
||||
},
|
||||
tsConfig: path.resolve(__dirname, "tsconfig.json"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<div class="tw-items-baseline tw-gap-2">
|
||||
<span bitTypography="body2">{{ "onceYouReviewApplications" | i18n }}</span>
|
||||
<span bitTypography="body2">{{ "onceYouReviewApps" | i18n }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } from "rx
|
||||
import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe";
|
||||
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
@@ -56,6 +57,7 @@ export class MemberAccessReportComponent implements OnInit {
|
||||
protected dialogService: DialogService,
|
||||
protected userNamePipe: UserNamePipe,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
protected organizationMetadataService: OrganizationMetadataServiceAbstraction,
|
||||
) {
|
||||
// Connect the search input to the table dataSource filter input
|
||||
this.searchControl.valueChanges
|
||||
@@ -69,8 +71,8 @@ export class MemberAccessReportComponent implements OnInit {
|
||||
const params = await firstValueFrom(this.route.params);
|
||||
this.organizationId = params.organizationId;
|
||||
|
||||
const billingMetadata = await this.billingApiService.getOrganizationBillingMetadata(
|
||||
this.organizationId,
|
||||
const billingMetadata = await firstValueFrom(
|
||||
this.organizationMetadataService.getOrganizationMetadata$(this.organizationId),
|
||||
);
|
||||
|
||||
this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone;
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
const { buildConfig } = require("../../apps/web/webpack.base");
|
||||
const path = require("path");
|
||||
const { buildConfig } = require(path.resolve(__dirname, "../../apps/web/webpack.base"));
|
||||
|
||||
module.exports = buildConfig({
|
||||
configName: "Commercial",
|
||||
app: {
|
||||
entry: "../../bitwarden_license/bit-web/src/main.ts",
|
||||
entryModule: "../../bitwarden_license/bit-web/src/app/app.module#AppModule",
|
||||
},
|
||||
tsConfig: "../../bitwarden_license/bit-web/tsconfig.build.json",
|
||||
});
|
||||
module.exports = (webpackConfig, context) => {
|
||||
const isNxBuild = context && context.options;
|
||||
if (isNxBuild) {
|
||||
return buildConfig({
|
||||
configName: "Commercial",
|
||||
app: {
|
||||
entry: context.options.main
|
||||
? path.resolve(context.context.root, context.options.main)
|
||||
: path.resolve(__dirname, "src/main.ts"),
|
||||
entryModule: "bitwarden_license/bit-web/src/app/app.module#AppModule",
|
||||
},
|
||||
tsConfig: "bitwarden_license/bit-web/tsconfig.build.json",
|
||||
outputPath:
|
||||
context.context && context.context.root
|
||||
? path.resolve(context.context.root, context.options.outputPath)
|
||||
: context.options.outputPath,
|
||||
});
|
||||
} else {
|
||||
return buildConfig({
|
||||
configName: "Commercial",
|
||||
app: {
|
||||
entry: path.resolve(__dirname, "src/main.ts"),
|
||||
entryModule: "bitwarden_license/bit-web/src/app/app.module#AppModule",
|
||||
},
|
||||
tsConfig: path.resolve(__dirname, "tsconfig.build.json"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Bitwarden uses [Nx](https://nx.dev/) to make building projects from the monorepo easier. To build, lint, or test a project you'll want to reference the project's `project.json` file for availible commands and their names. Then you'll run `npx nx [your_command] [your_project] [your_options]`. Run `npx nx --help` to see availible options, there are many.
|
||||
|
||||
Please note: the Nx implementation is a work in progress. Not all apps support Nx yet, CI still uses the old npm builds, and we have many "legacy" libraries that use hacks to get them into the Nx project graph.
|
||||
Please note: the Nx implementation is a work in progress. CI still uses the old npm builds, and we have many "legacy" libraries that use hacks to get them into the Nx project graph.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -11,6 +11,7 @@ Please note: the Nx implementation is a work in progress. Not all apps support N
|
||||
```bash
|
||||
# Build a project
|
||||
npx nx build cli
|
||||
npx nx build-native desktop # Some apps have special build commands
|
||||
npx nx build state # Modern libs and apps have simple, all lowercase target names
|
||||
npx nx build @bitwarden/common # Legacy libs have a special naming convention and include the @bitwarden prefix
|
||||
|
||||
@@ -203,6 +204,6 @@ npx nx reset
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Nx Documentation](https://nx.dev/getting-started/intro)
|
||||
- [Nx CLI Reference](https://nx.dev/packages/nx/documents/cli)
|
||||
- [Nx Intro Documentation](https://nx.dev/getting-started/intro)
|
||||
- [Nx CLI Commands Reference](https://nx.dev/docs/reference/nx-commands)
|
||||
- [Nx Workspace Configuration](https://nx.dev/reference/project-configuration)
|
||||
|
||||
@@ -145,12 +145,14 @@ import {
|
||||
} from "@bitwarden/common/billing/abstractions";
|
||||
import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
|
||||
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
|
||||
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
|
||||
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
|
||||
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
|
||||
import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service";
|
||||
import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service";
|
||||
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
|
||||
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
|
||||
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
|
||||
@@ -170,6 +172,8 @@ import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/ch
|
||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
||||
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
|
||||
import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service";
|
||||
import {
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
MasterPasswordServiceAbstraction,
|
||||
@@ -177,6 +181,8 @@ import {
|
||||
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
|
||||
import {
|
||||
SendPasswordService,
|
||||
DefaultSendPasswordService,
|
||||
@@ -702,6 +708,11 @@ const safeProviders: SafeProvider[] = [
|
||||
KdfConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SecurityStateService,
|
||||
useClass: DefaultSecurityStateService,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: RestrictedItemTypesService,
|
||||
useClass: RestrictedItemTypesService,
|
||||
@@ -797,6 +808,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: SendApiService,
|
||||
deps: [ApiServiceAbstraction, FileUploadServiceAbstraction, InternalSendService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: KeyApiService,
|
||||
useClass: DefaultKeyApiService,
|
||||
deps: [ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SyncService,
|
||||
useClass: DefaultSyncService,
|
||||
@@ -825,6 +841,7 @@ const safeProviders: SafeProvider[] = [
|
||||
TokenServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
StateProvider,
|
||||
SecurityStateService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -1399,6 +1416,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: BillingApiService,
|
||||
deps: [ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useClass: DefaultOrganizationMetadataService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BillingAccountProfileStateService,
|
||||
useClass: DefaultBillingAccountProfileStateService,
|
||||
@@ -1523,6 +1545,7 @@ const safeProviders: SafeProvider[] = [
|
||||
AccountServiceAbstraction,
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
SecurityStateService,
|
||||
ApiServiceAbstraction,
|
||||
StateProvider,
|
||||
ConfigService,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SubscriptionCancellationRequest } from "../../billing/models/request/su
|
||||
import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response";
|
||||
import { PlanResponse } from "../../billing/models/response/plan.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { OrganizationId } from "../../types/guid";
|
||||
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
|
||||
import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request";
|
||||
import { InvoicesResponse } from "../models/response/invoices.response";
|
||||
@@ -23,7 +24,11 @@ export abstract class BillingApiServiceAbstraction {
|
||||
): Promise<void>;
|
||||
|
||||
abstract getOrganizationBillingMetadata(
|
||||
organizationId: string,
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract getOrganizationBillingMetadataVNext(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { OrganizationId } from "../../types/guid";
|
||||
import { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response";
|
||||
|
||||
export abstract class OrganizationMetadataServiceAbstraction {
|
||||
abstract getOrganizationMetadata$(
|
||||
organizationId: OrganizationId,
|
||||
): Observable<OrganizationBillingMetadataResponse>;
|
||||
|
||||
abstract refreshMetadataCache(): void;
|
||||
}
|
||||
@@ -1,35 +1,12 @@
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
|
||||
export class OrganizationBillingMetadataResponse extends BaseResponse {
|
||||
isEligibleForSelfHost: boolean;
|
||||
isManaged: boolean;
|
||||
isOnSecretsManagerStandalone: boolean;
|
||||
isSubscriptionUnpaid: boolean;
|
||||
hasSubscription: boolean;
|
||||
hasOpenInvoice: boolean;
|
||||
invoiceDueDate: Date | null;
|
||||
invoiceCreatedDate: Date | null;
|
||||
subPeriodEndDate: Date | null;
|
||||
isSubscriptionCanceled: boolean;
|
||||
organizationOccupiedSeats: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
|
||||
this.isManaged = this.getResponseProperty("IsManaged");
|
||||
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
|
||||
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
|
||||
this.hasSubscription = this.getResponseProperty("HasSubscription");
|
||||
this.hasOpenInvoice = this.getResponseProperty("HasOpenInvoice");
|
||||
|
||||
this.invoiceDueDate = this.parseDate(this.getResponseProperty("InvoiceDueDate"));
|
||||
this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate"));
|
||||
this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate"));
|
||||
this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled");
|
||||
this.organizationOccupiedSeats = this.getResponseProperty("OrganizationOccupiedSeats");
|
||||
}
|
||||
|
||||
private parseDate(dateString: any): Date | null {
|
||||
return dateString ? new Date(dateString) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { OrganizationId } from "../../types/guid";
|
||||
import { BillingApiServiceAbstraction } from "../abstractions";
|
||||
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
|
||||
import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request";
|
||||
@@ -48,7 +49,7 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
}
|
||||
|
||||
async getOrganizationBillingMetadata(
|
||||
organizationId: string,
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
@@ -61,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction {
|
||||
return new OrganizationBillingMetadataResponse(r);
|
||||
}
|
||||
|
||||
async getOrganizationBillingMetadataVNext(
|
||||
organizationId: OrganizationId,
|
||||
): Promise<OrganizationBillingMetadataResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/organizations/" + organizationId + "/billing/vnext/metadata",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return new OrganizationBillingMetadataResponse(r);
|
||||
}
|
||||
|
||||
async getPlans(): Promise<ListResponse<PlanResponse>> {
|
||||
const r = await this.apiService.send("GET", "/plans", null, false, true);
|
||||
return new ListResponse(r, PlanResponse);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { OrganizationId } from "../../../types/guid";
|
||||
|
||||
import { DefaultOrganizationMetadataService } from "./organization-metadata.service";
|
||||
|
||||
describe("DefaultOrganizationMetadataService", () => {
|
||||
let service: DefaultOrganizationMetadataService;
|
||||
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
let featureFlagSubject: BehaviorSubject<boolean>;
|
||||
|
||||
const mockOrganizationId = newGuid() as OrganizationId;
|
||||
const mockOrganizationId2 = newGuid() as OrganizationId;
|
||||
|
||||
const createMockMetadataResponse = (
|
||||
isOnSecretsManagerStandalone = false,
|
||||
organizationOccupiedSeats = 5,
|
||||
): OrganizationBillingMetadataResponse => {
|
||||
return {
|
||||
isOnSecretsManagerStandalone,
|
||||
organizationOccupiedSeats,
|
||||
} as OrganizationBillingMetadataResponse;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
featureFlagSubject = new BehaviorSubject<boolean>(false);
|
||||
|
||||
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
|
||||
|
||||
service = new DefaultOrganizationMetadataService(billingApiService, configService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
featureFlagSubject.complete();
|
||||
});
|
||||
|
||||
describe("getOrganizationMetadata$", () => {
|
||||
describe("feature flag OFF", () => {
|
||||
beforeEach(() => {
|
||||
featureFlagSubject.next(false);
|
||||
});
|
||||
|
||||
it("calls getOrganizationBillingMetadata when feature flag is off", async () => {
|
||||
const mockResponse = createMockMetadataResponse(false, 10);
|
||||
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
|
||||
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("does not cache metadata when feature flag is off", async () => {
|
||||
const mockResponse1 = createMockMetadataResponse(false, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(false, 15);
|
||||
billingApiService.getOrganizationBillingMetadata
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
|
||||
expect(result1).toEqual(mockResponse1);
|
||||
expect(result2).toEqual(mockResponse2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature flag ON", () => {
|
||||
beforeEach(() => {
|
||||
featureFlagSubject.next(true);
|
||||
});
|
||||
|
||||
it("calls getOrganizationBillingMetadataVNext when feature flag is on", async () => {
|
||||
const mockResponse = createMockMetadataResponse(true, 15);
|
||||
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
|
||||
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledWith(
|
||||
mockOrganizationId,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("caches metadata by organization ID when feature flag is on", async () => {
|
||||
const mockResponse = createMockMetadataResponse(true, 10);
|
||||
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
|
||||
|
||||
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
|
||||
expect(result1).toEqual(mockResponse);
|
||||
expect(result2).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it("maintains separate cache entries for different organization IDs", async () => {
|
||||
const mockResponse1 = createMockMetadataResponse(true, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(false, 20);
|
||||
billingApiService.getOrganizationBillingMetadataVNext
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
|
||||
const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
|
||||
const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
|
||||
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockOrganizationId,
|
||||
);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockOrganizationId2,
|
||||
);
|
||||
expect(result1).toEqual(mockResponse1);
|
||||
expect(result2).toEqual(mockResponse2);
|
||||
expect(result3).toEqual(mockResponse1);
|
||||
expect(result4).toEqual(mockResponse2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shareReplay behavior", () => {
|
||||
beforeEach(() => {
|
||||
featureFlagSubject.next(true);
|
||||
});
|
||||
|
||||
it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => {
|
||||
const mockResponse = createMockMetadataResponse(true, 10);
|
||||
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
|
||||
|
||||
const metadata$ = service.getOrganizationMetadata$(mockOrganizationId);
|
||||
|
||||
const subscription1Promise = firstValueFrom(metadata$);
|
||||
const subscription2Promise = firstValueFrom(metadata$);
|
||||
const subscription3Promise = firstValueFrom(metadata$);
|
||||
|
||||
const [result1, result2, result3] = await Promise.all([
|
||||
subscription1Promise,
|
||||
subscription2Promise,
|
||||
subscription3Promise,
|
||||
]);
|
||||
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
|
||||
expect(result1).toEqual(mockResponse);
|
||||
expect(result2).toEqual(mockResponse);
|
||||
expect(result3).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshMetadataCache", () => {
|
||||
beforeEach(() => {
|
||||
featureFlagSubject.next(true);
|
||||
});
|
||||
|
||||
it("refreshes cached metadata when called with feature flag on", (done) => {
|
||||
const mockResponse1 = createMockMetadataResponse(true, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(true, 20);
|
||||
let invocationCount = 0;
|
||||
|
||||
billingApiService.getOrganizationBillingMetadataVNext
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
|
||||
next: (result) => {
|
||||
invocationCount++;
|
||||
|
||||
if (invocationCount === 1) {
|
||||
expect(result).toEqual(mockResponse1);
|
||||
} else if (invocationCount === 2) {
|
||||
expect(result).toEqual(mockResponse2);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
|
||||
subscription.unsubscribe();
|
||||
done();
|
||||
}
|
||||
},
|
||||
error: done.fail,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
service.refreshMetadataCache();
|
||||
}, 10);
|
||||
});
|
||||
|
||||
it("does not trigger refresh when feature flag is disabled", async () => {
|
||||
featureFlagSubject.next(false);
|
||||
|
||||
const mockResponse1 = createMockMetadataResponse(false, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(false, 20);
|
||||
let invocationCount = 0;
|
||||
|
||||
billingApiService.getOrganizationBillingMetadata
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2);
|
||||
|
||||
const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
|
||||
next: () => {
|
||||
invocationCount++;
|
||||
},
|
||||
});
|
||||
|
||||
// wait for initial invocation
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(invocationCount).toBe(1);
|
||||
|
||||
service.refreshMetadataCache();
|
||||
|
||||
// wait to ensure no additional invocations
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(invocationCount).toBe(1);
|
||||
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("bypasses cache when refreshing metadata", (done) => {
|
||||
const mockResponse1 = createMockMetadataResponse(true, 10);
|
||||
const mockResponse2 = createMockMetadataResponse(true, 20);
|
||||
const mockResponse3 = createMockMetadataResponse(true, 30);
|
||||
let invocationCount = 0;
|
||||
|
||||
billingApiService.getOrganizationBillingMetadataVNext
|
||||
.mockResolvedValueOnce(mockResponse1)
|
||||
.mockResolvedValueOnce(mockResponse2)
|
||||
.mockResolvedValueOnce(mockResponse3);
|
||||
|
||||
const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
|
||||
next: (result) => {
|
||||
invocationCount++;
|
||||
|
||||
if (invocationCount === 1) {
|
||||
expect(result).toEqual(mockResponse1);
|
||||
service.refreshMetadataCache();
|
||||
} else if (invocationCount === 2) {
|
||||
expect(result).toEqual(mockResponse2);
|
||||
service.refreshMetadataCache();
|
||||
} else if (invocationCount === 3) {
|
||||
expect(result).toEqual(mockResponse3);
|
||||
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(3);
|
||||
subscription.unsubscribe();
|
||||
done();
|
||||
}
|
||||
},
|
||||
error: done.fail,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user