mirror of
https://github.com/bitwarden/browser
synced 2026-02-02 01:33:22 +00:00
Merge remote-tracking branch 'origin/autofill/pm-26089/beeep-use-tracing-in-macos-provider' into feature/passkey-provider
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'
|
||||
@@ -920,7 +1153,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'
|
||||
@@ -1184,7 +1417,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"
|
||||
@@ -5579,17 +5579,37 @@
|
||||
"hasItemsVaultNudgeTitle": {
|
||||
"message": "Welcome to your vault!"
|
||||
},
|
||||
"phishingPageTitle":{
|
||||
"message": "Phishing website"
|
||||
"phishingPageTitleV2":{
|
||||
"message": "Phishing attempt detected"
|
||||
},
|
||||
"phishingPageCloseTab": {
|
||||
"message": "Close tab"
|
||||
"phishingPageSummary": {
|
||||
"message": "The site you are attempting to visit is a known malicious site and a security risk."
|
||||
},
|
||||
"phishingPageContinue": {
|
||||
"message": "Continue"
|
||||
"phishingPageCloseTabV2": {
|
||||
"message": "Close this tab"
|
||||
},
|
||||
"phishingPageLearnWhy": {
|
||||
"message": "Why are you seeing this?"
|
||||
"phishingPageContinueV2": {
|
||||
"message": "Continue to this site (not recommended)"
|
||||
},
|
||||
"phishingPageExplanation1": {
|
||||
"message": "This site was found in ",
|
||||
"description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this."
|
||||
},
|
||||
"phishingPageExplanation2": {
|
||||
"message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.",
|
||||
"description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this."
|
||||
},
|
||||
"phishingPageLearnMore" : {
|
||||
"message": "Learn more about phishing detection"
|
||||
},
|
||||
"protectedBy": {
|
||||
"message": "Protected by $PRODUCT$",
|
||||
"placeholders": {
|
||||
"product": {
|
||||
"content": "$1",
|
||||
"example": "Bitwarden Phishing Blocker"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hasItemsVaultNudgeBodyOne": {
|
||||
"message": "Autofill items for the current page"
|
||||
|
||||
@@ -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,
|
||||
@@ -984,6 +990,7 @@ export default class MainBackground {
|
||||
|
||||
this.sendStateProvider = new SendStateProvider(this.stateProvider);
|
||||
this.sendService = new SendService(
|
||||
this.accountService,
|
||||
this.keyService,
|
||||
this.i18nService,
|
||||
this.keyGenerationService,
|
||||
@@ -999,7 +1006,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 +1031,7 @@ export default class MainBackground {
|
||||
this.tokenService,
|
||||
this.authService,
|
||||
this.stateProvider,
|
||||
this.securityStateService,
|
||||
);
|
||||
|
||||
this.syncServiceListener = new SyncServiceListener(
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<span>{{ "phishingPageLearnWhy"| i18n}}</span>
|
||||
<a href="http://bitwarden.com/help/phishing-blocked/" bitLink block buttonType="primary">
|
||||
{{ "learnMore" | i18n }}
|
||||
</a>
|
||||
@@ -1,13 +1,46 @@
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "phishingPageTitle" | i18n }}</bit-label>
|
||||
<input bitInput disabled type="text" [value]="phishingHost" />
|
||||
</bit-form-field>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<div class="tw-flex tw-gap-4 tw-items-baseline">
|
||||
<bit-icon-tile size="large" icon="bwi-exclamation-triangle" variant="danger"></bit-icon-tile>
|
||||
<h1 bitTypography="h2" noMargin class="!tw-mb-0">{{ "phishingPageTitleV2" | i18n }}</h1>
|
||||
</div>
|
||||
|
||||
<button type="button" (click)="closeTab()" bitButton buttonType="primary">
|
||||
{{ "phishingPageCloseTab" | i18n }}
|
||||
</button>
|
||||
<button type="button" (click)="continueAnyway()" bitButton buttonType="danger">
|
||||
{{ "phishingPageContinue" | i18n }}
|
||||
</button>
|
||||
<hr class="!tw-mt-6 !tw-mb-4 !tw-border-secondary-100" />
|
||||
|
||||
<p bitTypography="body1">{{ "phishingPageSummary" | i18n }}</p>
|
||||
|
||||
<bit-callout class="tw-mb-0" type="danger" icon="bwi-globe" [title]="null">
|
||||
<span class="tw-font-mono">{{ phishingHost$ | async }}</span>
|
||||
</bit-callout>
|
||||
|
||||
<bit-callout class="tw-mt-2" [icon]="null" type="default">
|
||||
<p bitTypography="body2">
|
||||
{{ "phishingPageExplanation1" | i18n }}<b>Phishing.Database</b
|
||||
>{{ "phishingPageExplanation2" | i18n }}
|
||||
</p>
|
||||
|
||||
<a
|
||||
bitLink
|
||||
linkType="primary"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
href="https://bitwarden.com/help/phishing-blocked/"
|
||||
>
|
||||
{{ "phishingPageLearnMore" | i18n }}<i class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-callout>
|
||||
|
||||
<div class="tw-flex tw-flex-col tw-gap-4 tw-items-center tw-mt-2">
|
||||
<button type="button" (click)="closeTab()" bitButton buttonType="primary" [block]="true">
|
||||
{{ "phishingPageCloseTabV2" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
class="tw-text-sm"
|
||||
type="button"
|
||||
(click)="continueAnyway()"
|
||||
bitLink
|
||||
linkType="secondary"
|
||||
>
|
||||
{{ "phishingPageContinueV2" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CommonModule } from "@angular/common";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Component, OnDestroy } from "@angular/core";
|
||||
import { Component, inject } from "@angular/core";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { map } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
@@ -13,12 +13,16 @@ import {
|
||||
CheckboxModule,
|
||||
FormFieldModule,
|
||||
IconModule,
|
||||
IconTileComponent,
|
||||
LinkModule,
|
||||
CalloutComponent,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PhishingDetectionService } from "../services/phishing-detection.service";
|
||||
|
||||
@Component({
|
||||
selector: "dirt-phishing-warning",
|
||||
standalone: true,
|
||||
templateUrl: "phishing-warning.component.html",
|
||||
imports: [
|
||||
@@ -31,18 +35,16 @@ import { PhishingDetectionService } from "../services/phishing-detection.service
|
||||
CheckboxModule,
|
||||
ButtonModule,
|
||||
RouterModule,
|
||||
IconTileComponent,
|
||||
CalloutComponent,
|
||||
TypographyModule,
|
||||
],
|
||||
})
|
||||
export class PhishingWarning implements OnDestroy {
|
||||
phishingHost = "";
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(private activatedRoute: ActivatedRoute) {
|
||||
this.activatedRoute.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe((params) => {
|
||||
this.phishingHost = params.get("phishingHost") || "";
|
||||
});
|
||||
}
|
||||
export class PhishingWarning {
|
||||
private activatedRoute = inject(ActivatedRoute);
|
||||
protected phishingHost$ = this.activatedRoute.queryParamMap.pipe(
|
||||
map((params) => params.get("phishingHost") || ""),
|
||||
);
|
||||
|
||||
async closeTab() {
|
||||
await PhishingDetectionService.requestClosePhishingWarningPage();
|
||||
@@ -50,9 +52,4 @@ export class PhishingWarning implements OnDestroy {
|
||||
async continueAnyway() {
|
||||
await PhishingDetectionService.requestContinueToDangerousUrl();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// TODO: This needs to be dealt with by moving this folder or updating the lint rule.
|
||||
/* eslint-disable no-restricted-imports */
|
||||
import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { DeactivatedOrg } from "@bitwarden/assets/svg";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components";
|
||||
|
||||
import { PhishingWarning } from "./phishing-warning.component";
|
||||
import { ProtectedByComponent } from "./protected-by-component";
|
||||
|
||||
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
|
||||
getApplicationVersion = () => Promise.resolve("Version 2024.1.1");
|
||||
getClientType = () => ClientType.Web;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create ActivatedRoute mock with query parameters
|
||||
*/
|
||||
function mockActivatedRoute(queryParams: Record<string, string>) {
|
||||
return {
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
queryParamMap: of({
|
||||
get: (key: string) => queryParams[key] || null,
|
||||
}),
|
||||
queryParams: of(queryParams),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type StoryArgs = {
|
||||
phishingHost: string;
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Browser/DIRT/Phishing Warning",
|
||||
component: PhishingWarning,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [AnonLayoutComponent, ProtectedByComponent, RouterModule],
|
||||
providers: [
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useClass: MockPlatformUtilsService,
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () =>
|
||||
new I18nMockService({
|
||||
accessing: "Accessing",
|
||||
appLogoLabel: "Bitwarden logo",
|
||||
phishingPageTitleV2: "Phishing attempt detected",
|
||||
phishingPageCloseTabV2: "Close this tab",
|
||||
phishingPageSummary:
|
||||
"The site you are attempting to visit is a known malicious site and a security risk.",
|
||||
phishingPageContinueV2: "Continue to this site (not recommended)",
|
||||
phishingPageExplanation1: "This site was found in ",
|
||||
phishingPageExplanation2:
|
||||
", an open-source list of known phishing sites used for stealing personal and sensitive information.",
|
||||
phishingPageLearnMore: "Learn more about phishing detection",
|
||||
protectedBy: (product) => `Protected by ${product}`,
|
||||
learnMore: "Learn more",
|
||||
danger: "error",
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
environment$: new BehaviorSubject({
|
||||
getHostname() {
|
||||
return "bitwarden.com";
|
||||
},
|
||||
}).asObservable(),
|
||||
},
|
||||
},
|
||||
mockActivatedRoute({ phishingHost: "malicious-example.com" }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<auth-anon-layout
|
||||
[hideIcon]="true"
|
||||
[hideBackgroundIllustration]="true"
|
||||
>
|
||||
<dirt-phishing-warning></dirt-phishing-warning>
|
||||
<dirt-phishing-protected-by slot="secondary"></dirt-phishing-protected-by>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
argTypes: {
|
||||
phishingHost: {
|
||||
control: "text",
|
||||
description: "The suspicious host that was blocked",
|
||||
},
|
||||
},
|
||||
args: {
|
||||
phishingHost: "malicious-example.com",
|
||||
pageIcon: DeactivatedOrg,
|
||||
},
|
||||
} satisfies Meta<StoryArgs & { pageIcon: any }>;
|
||||
|
||||
type Story = StoryObj<StoryArgs & { pageIcon: any }>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
phishingHost: "malicious-example.com",
|
||||
},
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [mockActivatedRoute({ phishingHost: "malicious-example.com" })],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export const LongHostname: Story = {
|
||||
args: {
|
||||
phishingHost: "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com",
|
||||
},
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [
|
||||
mockActivatedRoute({
|
||||
phishingHost:
|
||||
"very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<span class="tw-text-muted">{{ "protectedBy" | i18n: "Bitwarden Phishing Blocker" }}</span>
|
||||
@@ -4,13 +4,12 @@ import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ButtonModule } from "@bitwarden/components";
|
||||
import { ButtonModule, LinkModule } from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
selector: "dirt-phishing-protected-by",
|
||||
standalone: true,
|
||||
templateUrl: "learn-more-component.html",
|
||||
imports: [CommonModule, CommonModule, JslibModule, ButtonModule],
|
||||
templateUrl: "protected-by-component.html",
|
||||
imports: [CommonModule, CommonModule, JslibModule, ButtonModule, LinkModule],
|
||||
})
|
||||
export class LearnMoreComponent {
|
||||
constructor() {}
|
||||
}
|
||||
export class ProtectedByComponent {}
|
||||
@@ -116,15 +116,15 @@ export class PhishingDetectionService {
|
||||
/**
|
||||
* Sends a message to the phishing detection service to close the warning page
|
||||
*/
|
||||
static requestClosePhishingWarningPage(): void {
|
||||
void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close });
|
||||
static async requestClosePhishingWarningPage() {
|
||||
await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the phishing detection service to continue to the caught url
|
||||
*/
|
||||
static async requestContinueToDangerousUrl() {
|
||||
void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue });
|
||||
await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
VaultIcon,
|
||||
LockIcon,
|
||||
TwoFactorAuthSecurityKeyIcon,
|
||||
DeactivatedOrg,
|
||||
} from "@bitwarden/assets/svg";
|
||||
import {
|
||||
LoginComponent,
|
||||
@@ -54,8 +53,8 @@ import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-doma
|
||||
import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component";
|
||||
import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
|
||||
import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component";
|
||||
import { LearnMoreComponent } from "../dirt/phishing-detection/pages/learn-more-component";
|
||||
import { PhishingWarning } from "../dirt/phishing-detection/pages/phishing-warning.component";
|
||||
import { ProtectedByComponent } from "../dirt/phishing-detection/pages/protected-by-component";
|
||||
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
|
||||
import BrowserPopupUtils from "../platform/browser/browser-popup-utils";
|
||||
import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service";
|
||||
@@ -718,14 +717,13 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: LearnMoreComponent,
|
||||
component: ProtectedByComponent,
|
||||
outlet: "secondary",
|
||||
},
|
||||
],
|
||||
data: {
|
||||
pageIcon: DeactivatedOrg,
|
||||
pageTitle: "Bitwarden blocked it!",
|
||||
pageSubtitle: "Bitwarden blocked a known phishing site from loading.",
|
||||
hideIcon: true,
|
||||
hideBackgroundIllustration: true,
|
||||
showReadonlyHostname: true,
|
||||
} satisfies AnonLayoutWrapperData,
|
||||
},
|
||||
|
||||
@@ -382,7 +382,7 @@ app-root {
|
||||
}
|
||||
}
|
||||
|
||||
main:not(popup-page main) {
|
||||
main:not(popup-page main):not(auth-anon-layout main) {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
bottom: 0;
|
||||
|
||||
@@ -302,7 +302,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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -211,6 +211,7 @@ export class OssServeConfigurator {
|
||||
this.serviceContainer.sendService,
|
||||
this.serviceContainer.sendApiService,
|
||||
this.serviceContainer.environmentService,
|
||||
this.serviceContainer.accountService,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -547,6 +552,7 @@ export class ServiceContainer {
|
||||
this.sendStateProvider = new SendStateProvider(this.stateProvider);
|
||||
|
||||
this.sendService = new SendService(
|
||||
this.accountService,
|
||||
this.keyService,
|
||||
this.i18nService,
|
||||
this.keyGenerationService,
|
||||
@@ -612,6 +618,7 @@ export class ServiceContainer {
|
||||
this.accountService,
|
||||
this.kdfConfigService,
|
||||
this.keyService,
|
||||
this.securityStateService,
|
||||
this.apiService,
|
||||
this.stateProvider,
|
||||
this.configService,
|
||||
@@ -818,6 +825,7 @@ export class ServiceContainer {
|
||||
this.tokenService,
|
||||
this.authService,
|
||||
this.stateProvider,
|
||||
this.securityStateService,
|
||||
);
|
||||
|
||||
this.totpService = new TotpService(this.sdkService);
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as path from "path";
|
||||
import { firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
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/account/billing-account-profile-state.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
@@ -142,7 +143,8 @@ export class SendCreateCommand {
|
||||
|
||||
await this.sendApiService.save([encSend, fileData]);
|
||||
const newSend = await this.sendService.getFromState(encSend.id);
|
||||
const decSend = await newSend.decrypt();
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const decSend = await newSend.decrypt(activeUserId);
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const res = new SendResponse(decSend, env.getWebVaultUrl());
|
||||
return Response.success(res);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
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/account/billing-account-profile-state.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
@@ -83,7 +84,8 @@ export class SendEditCommand {
|
||||
return Response.error("Premium status is required to use this feature.");
|
||||
}
|
||||
|
||||
let sendView = await send.decrypt();
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
let sendView = await send.decrypt(activeUserId);
|
||||
sendView = SendResponse.toView(req, sendView);
|
||||
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { isGuid } from "@bitwarden/guid";
|
||||
|
||||
import { DownloadCommand } from "../../../commands/download.command";
|
||||
import { Response } from "../../../models/response";
|
||||
@@ -74,13 +75,13 @@ export class SendGetCommand extends DownloadCommand {
|
||||
}
|
||||
|
||||
private async getSendView(id: string): Promise<SendView | SendView[]> {
|
||||
if (Utils.isGuid(id)) {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
if (isGuid(id)) {
|
||||
const send = await this.sendService.getFromState(id);
|
||||
if (send != null) {
|
||||
return await send.decrypt();
|
||||
return await send.decrypt(activeUserId);
|
||||
}
|
||||
} else if (id.trim() !== "") {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
let sends = await this.sendService.getAllDecryptedFromState(activeUserId);
|
||||
sends = this.searchService.searchSends(sends, id);
|
||||
if (sends.length > 1) {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services//send.service.abstraction";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
@@ -14,6 +16,7 @@ export class SendRemovePasswordCommand {
|
||||
private sendService: SendService,
|
||||
private sendApiService: SendApiService,
|
||||
private environmentService: EnvironmentService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async run(id: string) {
|
||||
@@ -21,7 +24,8 @@ export class SendRemovePasswordCommand {
|
||||
await this.sendApiService.removePassword(id);
|
||||
|
||||
const updatedSend = await firstValueFrom(this.sendService.get$(id));
|
||||
const decSend = await updatedSend.decrypt();
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const decSend = await updatedSend.decrypt(activeUserId);
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const webVaultUrl = env.getWebVaultUrl();
|
||||
const res = new SendResponse(decSend, webVaultUrl);
|
||||
|
||||
@@ -297,6 +297,7 @@ export class SendProgram extends BaseProgram {
|
||||
this.serviceContainer.sendService,
|
||||
this.serviceContainer.sendApiService,
|
||||
this.serviceContainer.environmentService,
|
||||
this.serviceContainer.accountService,
|
||||
);
|
||||
const response = await cmd.run(id);
|
||||
this.processResponse(response);
|
||||
|
||||
57
apps/desktop/desktop_native/Cargo.lock
generated
57
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"
|
||||
@@ -1793,13 +1824,14 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"desktop_core",
|
||||
"futures",
|
||||
"log",
|
||||
"oslog",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-oslog",
|
||||
"tracing-subscriber",
|
||||
"uniffi",
|
||||
]
|
||||
|
||||
@@ -1886,7 +1918,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 +2591,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"
|
||||
@@ -3445,6 +3486,18 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-oslog"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"tracing-core",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.20"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -16,12 +16,13 @@ bench = false
|
||||
[dependencies]
|
||||
desktop_core = { path = "../core" }
|
||||
futures = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tokio-util = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-oslog = "0.3.0"
|
||||
tracing-subscriber = { workspace = true }
|
||||
uniffi = { workspace = true, features = ["cli"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -9,6 +9,11 @@ use std::{
|
||||
use futures::FutureExt;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use tracing::{error, info};
|
||||
use tracing_subscriber::{
|
||||
filter::{EnvFilter, LevelFilter},
|
||||
layer::SubscriberExt,
|
||||
util::SubscriberInitExt,
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
|
||||
@@ -88,8 +93,17 @@ impl MacOSProviderClient {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
#[uniffi::constructor]
|
||||
pub fn connect() -> Self {
|
||||
let _ = oslog::OsLogger::new("com.bitwarden.desktop.autofill-extension")
|
||||
.level_filter(log::LevelFilter::Trace)
|
||||
let filter = EnvFilter::builder()
|
||||
// Everything logs at `INFO`
|
||||
.with_default_directive(LevelFilter::INFO.into())
|
||||
.from_env_lossy();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_oslog::OsLogger::new(
|
||||
"com.bitwarden.desktop.autofill-extension",
|
||||
"default",
|
||||
))
|
||||
.init();
|
||||
|
||||
let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32);
|
||||
|
||||
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"],
|
||||
|
||||
@@ -36,9 +36,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 && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",
|
||||
@@ -47,6 +48,7 @@
|
||||
"pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
|
||||
"pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"",
|
||||
"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",
|
||||
},
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { CommonModule, DatePipe } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
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/account/billing-account-profile-state.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -63,7 +65,8 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
|
||||
async refresh() {
|
||||
const send = await this.loadSend();
|
||||
this.send = await send.decrypt();
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
this.send = await send.decrypt(userId);
|
||||
this.updateFormValues();
|
||||
this.hasPassword = this.send.password != null && this.send.password.trim() !== "";
|
||||
}
|
||||
|
||||
@@ -4224,7 +4224,7 @@
|
||||
"message": "Archive",
|
||||
"description": "Verb"
|
||||
},
|
||||
"unarchive": {
|
||||
"unArchive": {
|
||||
"message": "Unarchive"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
@@ -4236,11 +4236,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"
|
||||
|
||||
@@ -12,10 +12,13 @@ import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
|
||||
/**
|
||||
* The SSO Localhost login service uses a local host listener as fallback in case scheme handling deeplinks does not work.
|
||||
* This way it is possible to log in with SSO on appimage, snap, and electron dev using the same methods that the cli uses.
|
||||
* This way it is possible to log in with SSO on appimage and electron dev using the same methods that the cli uses.
|
||||
*/
|
||||
export class SSOLocalhostCallbackService {
|
||||
private ssoRedirectUri = "";
|
||||
// We will only track one server at a time for use-case and performance considerations.
|
||||
// This will result in a last-one-wins behavior if multiple SSO flows are started simultaneously.
|
||||
private currentServer: http.Server | null = null;
|
||||
|
||||
constructor(
|
||||
private environmentService: EnvironmentService,
|
||||
@@ -23,11 +26,30 @@ export class SSOLocalhostCallbackService {
|
||||
private ssoUrlService: SsoUrlService,
|
||||
) {
|
||||
ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state, email }) => {
|
||||
const { ssoCode, recvState } = await this.openSsoPrompt(codeChallenge, state, email);
|
||||
this.messagingService.send("ssoCallback", {
|
||||
code: ssoCode,
|
||||
state: recvState,
|
||||
redirectUri: this.ssoRedirectUri,
|
||||
// Close any existing server before starting new one
|
||||
if (this.currentServer) {
|
||||
await this.closeCurrentServer();
|
||||
}
|
||||
|
||||
return this.openSsoPrompt(codeChallenge, state, email).then(({ ssoCode, recvState }) => {
|
||||
this.messagingService.send("ssoCallback", {
|
||||
code: ssoCode,
|
||||
state: recvState,
|
||||
redirectUri: this.ssoRedirectUri,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async closeCurrentServer(): Promise<void> {
|
||||
if (!this.currentServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
this.currentServer!.close(() => {
|
||||
this.currentServer = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -59,6 +81,7 @@ export class SSOLocalhostCallbackService {
|
||||
"<p>You may now close this tab and return to the app.</p>" +
|
||||
"</body></html>",
|
||||
);
|
||||
this.currentServer = null;
|
||||
callbackServer.close(() =>
|
||||
resolve({
|
||||
ssoCode: code,
|
||||
@@ -73,41 +96,68 @@ export class SSOLocalhostCallbackService {
|
||||
"<p>You may now close this tab and return to the app.</p>" +
|
||||
"</body></html>",
|
||||
);
|
||||
this.currentServer = null;
|
||||
callbackServer.close(() => reject());
|
||||
}
|
||||
});
|
||||
|
||||
let foundPort = false;
|
||||
const webUrl = env.getWebVaultUrl();
|
||||
for (let port = 8065; port <= 8070; port++) {
|
||||
try {
|
||||
this.ssoRedirectUri = "http://localhost:" + port;
|
||||
const ssoUrl = this.ssoUrlService.buildSsoUrl(
|
||||
webUrl,
|
||||
ClientType.Desktop,
|
||||
this.ssoRedirectUri,
|
||||
state,
|
||||
codeChallenge,
|
||||
email,
|
||||
);
|
||||
callbackServer.listen(port, () => {
|
||||
this.messagingService.send("launchUri", {
|
||||
url: ssoUrl,
|
||||
});
|
||||
});
|
||||
foundPort = true;
|
||||
break;
|
||||
} catch {
|
||||
// Ignore error since we run the same command up to 5 times.
|
||||
}
|
||||
}
|
||||
if (!foundPort) {
|
||||
reject();
|
||||
}
|
||||
// Store reference to current server
|
||||
this.currentServer = callbackServer;
|
||||
|
||||
// after 5 minutes, close the server
|
||||
const webUrl = env.getWebVaultUrl();
|
||||
|
||||
const tryNextPort = (port: number) => {
|
||||
if (port > 8070) {
|
||||
this.currentServer = null;
|
||||
reject("All available SSO ports in use");
|
||||
return;
|
||||
}
|
||||
|
||||
this.ssoRedirectUri = "http://localhost:" + port;
|
||||
const ssoUrl = this.ssoUrlService.buildSsoUrl(
|
||||
webUrl,
|
||||
ClientType.Desktop,
|
||||
this.ssoRedirectUri,
|
||||
state,
|
||||
codeChallenge,
|
||||
email,
|
||||
);
|
||||
|
||||
// Set up error handler before attempting to listen
|
||||
callbackServer.once("error", (err: any) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
// Port is in use, try next port
|
||||
tryNextPort(port + 1);
|
||||
} else {
|
||||
// Another error - reject and set the current server to null
|
||||
// (one server alive at a time)
|
||||
this.currentServer = null;
|
||||
reject();
|
||||
}
|
||||
});
|
||||
|
||||
// Attempt to listen on the port
|
||||
callbackServer.listen(port, () => {
|
||||
// Success - remove error listener and launch SSO
|
||||
callbackServer.removeAllListeners("error");
|
||||
|
||||
this.messagingService.send("launchUri", {
|
||||
url: ssoUrl,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Start trying from port 8065
|
||||
tryNextPort(8065);
|
||||
|
||||
// Don't allow any server to stay up for more than 5 minutes;
|
||||
// this gives plenty of time to complete SSO but ensures we don't
|
||||
// have a server running indefinitely.
|
||||
setTimeout(
|
||||
() => {
|
||||
if (this.currentServer === callbackServer) {
|
||||
this.currentServer = null;
|
||||
}
|
||||
callbackServer.close(() => reject());
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
|
||||
@@ -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,6 +1,11 @@
|
||||
@if (step() == PlanSelectionStep) {
|
||||
<app-upgrade-account (planSelected)="onPlanSelected($event)" (closeClicked)="onCloseClicked()" />
|
||||
} @else if (step() == PaymentStep && selectedPlan() !== null) {
|
||||
<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()"
|
||||
[account]="account()"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@if (!loading()) {
|
||||
<section
|
||||
class="tw-bg-background tw-rounded-xl tw-shadow-lg tw-max-w-5xl tw-min-w-[332px] tw-w-[870px] tw-border-secondary-100 tw-border-solid tw-border"
|
||||
class="tw-w-screen tw-max-h-screen tw-min-w-[332px] md:tw-max-w-4xl tw-overflow-y-auto tw-self-center tw-bg-background tw-rounded-xl tw-shadow-lg tw-border-secondary-100 tw-border-solid tw-border"
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
@@ -17,17 +17,17 @@
|
||||
<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 }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-flex-row tw-gap-6 tw-mb-4">
|
||||
<div class="tw-grid tw-grid-cols-1 sm:tw-grid-cols-2 tw-gap-5 tw-mb-4">
|
||||
@if (premiumCardDetails) {
|
||||
<billing-pricing-card
|
||||
class="tw-flex-1 tw-basis-0 tw-min-w-0"
|
||||
class="tw-w-full tw-min-w-[216px] tw-max-w-[456px]"
|
||||
[tagline]="premiumCardDetails.tagline"
|
||||
[price]="premiumCardDetails.price"
|
||||
[button]="premiumCardDetails.button"
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
@if (familiesCardDetails) {
|
||||
<billing-pricing-card
|
||||
class="tw-flex-1 tw-basis-0 tw-min-w-0"
|
||||
class="tw-w-full tw-min-w-[216px] tw-max-w-[456px]"
|
||||
[tagline]="familiesCardDetails.tagline"
|
||||
[price]="familiesCardDetails.price"
|
||||
[button]="familiesCardDetails.button"
|
||||
@@ -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>
|
||||
@@ -26,6 +42,7 @@
|
||||
<section>
|
||||
@if (passwordManager) {
|
||||
<billing-cart-summary
|
||||
#cartSummaryComponent
|
||||
[passwordManager]="passwordManager"
|
||||
[estimatedTax]="estimatedTax"
|
||||
></billing-cart-summary>
|
||||
|
||||
@@ -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();
|
||||
@@ -118,27 +122,24 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
||||
tier: this.selectedPlanId(),
|
||||
details: planDetails,
|
||||
};
|
||||
this.passwordManager = {
|
||||
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
|
||||
cost: this.selectedPlan.details.passwordManager.annualPrice,
|
||||
quantity: 1,
|
||||
cadence: "year",
|
||||
};
|
||||
|
||||
this.upgradeToMessage = this.i18nService.t(
|
||||
this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium",
|
||||
);
|
||||
|
||||
this.estimatedTax = 0;
|
||||
} else {
|
||||
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (!this.selectedPlan) {
|
||||
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
this.passwordManager = {
|
||||
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
|
||||
cost: this.selectedPlan.details.passwordManager.annualPrice,
|
||||
quantity: 1,
|
||||
cadence: "year",
|
||||
};
|
||||
|
||||
this.upgradeToMessage = this.i18nService.t(
|
||||
this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium",
|
||||
);
|
||||
|
||||
this.estimatedTax = 0;
|
||||
|
||||
this.formGroup.valueChanges
|
||||
.pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.refreshSalesTax());
|
||||
@@ -146,7 +147,9 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.cartSummaryComponent.isExpanded.set(false);
|
||||
if (this.cartSummaryComponent) {
|
||||
this.cartSummaryComponent.isExpanded.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected get isPremiumPlan(): boolean {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ScrollingModule } from "@angular/cdk/scrolling";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { MenuModule, TableModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { VaultItem } from "./vault-item";
|
||||
import { VaultItemsComponent } from "./vault-items.component";
|
||||
|
||||
describe("VaultItemsComponent", () => {
|
||||
let component: VaultItemsComponent<CipherViewLike>;
|
||||
|
||||
const cipher1: Partial<CipherView> = {
|
||||
id: "cipher-1",
|
||||
name: "Cipher 1",
|
||||
organizationId: undefined,
|
||||
};
|
||||
|
||||
const cipher2: Partial<CipherView> = {
|
||||
id: "cipher-2",
|
||||
name: "Cipher 2",
|
||||
organizationId: undefined,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [VaultItemsComponent],
|
||||
imports: [ScrollingModule, TableModule, I18nPipe, MenuModule],
|
||||
providers: [
|
||||
{
|
||||
provide: CipherAuthorizationService,
|
||||
useValue: {
|
||||
canDeleteCipher$: jest.fn(),
|
||||
canRestoreCipher$: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RestrictedItemTypesService,
|
||||
useValue: {
|
||||
restricted$: of([]),
|
||||
isCipherRestricted: jest.fn().mockReturnValue(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(VaultItemsComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe("bulkUnarchiveAllowed", () => {
|
||||
it("returns false when no items are selected", () => {
|
||||
component["selection"].clear();
|
||||
|
||||
expect(component.bulkUnarchiveAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when selecting collections only", () => {
|
||||
const collection1 = { id: "col-1", name: "Collection 1" } as CollectionView;
|
||||
const collection2 = { id: "col-2", name: "Collection 2" } as CollectionView;
|
||||
|
||||
const items: VaultItem<CipherView>[] = [
|
||||
{ collection: collection1 },
|
||||
{ collection: collection2 },
|
||||
];
|
||||
|
||||
component["selection"].select(...items);
|
||||
|
||||
expect(component.bulkUnarchiveAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when selecting archived ciphers without organization", () => {
|
||||
const archivedCipher1 = {
|
||||
...cipher1,
|
||||
archivedDate: new Date("2024-01-01"),
|
||||
};
|
||||
const archivedCipher2 = {
|
||||
...cipher2,
|
||||
archivedDate: new Date("2024-01-02"),
|
||||
};
|
||||
|
||||
const items: VaultItem<CipherView>[] = [
|
||||
{ cipher: archivedCipher1 as CipherView },
|
||||
{ cipher: archivedCipher2 as CipherView },
|
||||
];
|
||||
|
||||
component["selection"].select(...items);
|
||||
|
||||
expect(component.bulkUnarchiveAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when any selected cipher has an organizationId", () => {
|
||||
const archivedCipher1: Partial<CipherView> = {
|
||||
...cipher1,
|
||||
archivedDate: new Date("2024-01-01"),
|
||||
organizationId: undefined,
|
||||
};
|
||||
|
||||
const archivedCipher2: Partial<CipherView> = {
|
||||
...cipher2,
|
||||
archivedDate: new Date("2024-01-02"),
|
||||
organizationId: "org-1",
|
||||
};
|
||||
|
||||
const items: VaultItem<CipherView>[] = [
|
||||
{ cipher: archivedCipher1 as CipherView },
|
||||
{ cipher: archivedCipher2 as CipherView },
|
||||
];
|
||||
|
||||
component["selection"].select(...items);
|
||||
|
||||
expect(component.bulkUnarchiveAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when any selected cipher is not archived", () => {
|
||||
const items: VaultItem<CipherView>[] = [
|
||||
{ cipher: cipher1 as CipherView },
|
||||
{ cipher: cipher2 as CipherView },
|
||||
];
|
||||
|
||||
component["selection"].select(...items);
|
||||
|
||||
expect(component.bulkUnarchiveAllowed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user