1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-14 07:23:45 +00:00

Merge branch 'main' into km/beeep/clean-agent-rewrite

This commit is contained in:
Bernd Schoolmann
2025-10-16 17:58:33 +02:00
committed by GitHub
261 changed files with 10721 additions and 1998 deletions

View File

@@ -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'

View File

@@ -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'

View File

@@ -174,7 +174,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -323,7 +323,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -429,7 +429,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -665,6 +665,239 @@ jobs:
path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml
if-no-files-found: error
windows-beta:
name: Windows Beta Build
runs-on: windows-2022
needs: setup
permissions:
contents: read
id-token: write
defaults:
run:
shell: pwsh
working-directory: apps/desktop
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
NODE_OPTIONS: --max_old_space_size=4096
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
- name: Install AST
run: dotnet tool install --global AzureSignTool --version 4.0.1
- name: Print environment
run: |
node --version
npm --version
choco --version
rustup show
- name: Log in to Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "code-signing-vault-url,
code-signing-client-id,
code-signing-tenant-id,
code-signing-client-secret,
code-signing-cert-name"
- name: Log out from Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/azure-logout@main
- name: Install Node dependencies
run: npm ci
working-directory: ./
- name: Download SDK Artifacts
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
artifacts: sdk-internal
repo: bitwarden/sdk-internal
path: ../sdk-internal
if_no_artifact_found: fail
- name: Override SDK
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
working-directory: ./
run: |
ls -l ../
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
id: cache
with:
path: |
apps/desktop/desktop_native/napi/*.node
apps/desktop/desktop_native/dist/*
key: rust-${{ runner.os }}-${{ hashFiles('apps/desktop/desktop_native/**/*') }}
- name: Build Native Module
if: steps.cache.outputs.cache-hit != 'true'
working-directory: apps/desktop/desktop_native
run: node build.js cross-platform
- name: Build
run: npm run build
- name: Pack
if: ${{ needs.setup.outputs.has_secrets == 'false' }}
run: npm run pack:win:beta
- name: Pack & Sign
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
env:
ELECTRON_BUILDER_SIGN: 1
SIGNING_VAULT_URL: ${{ steps.retrieve-secrets.outputs.code-signing-vault-url }}
SIGNING_CLIENT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-client-id }}
SIGNING_TENANT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-tenant-id }}
SIGNING_CLIENT_SECRET: ${{ steps.retrieve-secrets.outputs.code-signing-client-secret }}
SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }}
run: npm run pack:win:beta
- name: Rename appx files for store
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
run: |
Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx" `
-Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx"
Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx" `
-Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx"
Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx" `
-Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx"
- name: Fix NSIS artifact names for auto-updater
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
run: |
Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z `
-NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z
Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z `
-NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z
Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z `
-NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z
- name: Upload portable exe artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe
path: apps/desktop/dist/Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe
if-no-files-found: error
- name: Upload installer exe artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe
path: apps/desktop/dist/nsis-web/Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe
if-no-files-found: error
- name: Upload appx ia32 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx
if-no-files-found: error
- name: Upload store appx ia32 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx
if-no-files-found: error
- name: Upload NSIS ia32 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z
path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z
if-no-files-found: error
- name: Upload appx x64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx
if-no-files-found: error
- name: Upload store appx x64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx
if-no-files-found: error
- name: Upload NSIS x64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z
path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z
if-no-files-found: error
- name: Upload appx ARM64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx
if-no-files-found: error
- name: Upload store appx ARM64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx
if-no-files-found: error
- name: Upload NSIS ARM64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z
path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z
if-no-files-found: error
- name: Upload auto-update artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: ${{ needs.setup.outputs.release_channel }}-beta.yml
path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml
if-no-files-found: error
macos-build:
name: MacOS Build
@@ -688,7 +921,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -914,7 +1147,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -1172,7 +1405,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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

View File

@@ -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'

View File

@@ -25,6 +25,11 @@ export const formatArgsForCodeSnippet = <ComponentType extends Record<string, an
const formattedArray = value.map((v) => `'${v}'`).join(", ");
return `[${key}]="[${formattedArray}]"`;
}
if (typeof value === "number") {
return `[${key}]="${value}"`;
}
return `${key}="${value}"`;
})
.join(" ");

View File

@@ -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
View 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"]
}
}
}
}

View File

@@ -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"

View File

@@ -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 },

View File

@@ -9,15 +9,17 @@
<app-vault-icon slot="start" [cipher]="cipher"></app-vault-icon>
<span data-testid="item-name">
{{ cipher.name }}
<i
*ngIf="cipher.organizationId"
[appA11yTitle]="'shared' | i18n"
class="bwi bwi-collection-shared text-muted"
></i>
@if (cipher.organizationId) {
<i [appA11yTitle]="'shared' | i18n" class="bwi bwi-collection-shared tw-text-muted"></i>
}
</span>
<ng-container slot="secondary">
<div *ngIf="getSubName(cipher)">{{ getSubName(cipher) }}</div>
<div *ngIf="cipher.subTitle">{{ cipher.subTitle }}</div>
@if (getSubName(cipher)) {
<div>{{ getSubName(cipher) }}</div>
}
@if (cipher.subTitle) {
<div>{{ cipher.subTitle }}</div>
}
</ng-container>
</button>
</bit-item>

View File

@@ -1,52 +1,24 @@
<ng-container *ngIf="(fido2PopoutSessionData$ | async).fallbackSupported">
<div class="useBrowserlink">
<button
type="button"
(click)="toggle()"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
aria-haspopup="dialog"
aria-controls="cdk-overlay-container"
>
<span class="text-primary">
@if ((fido2PopoutSessionData$ | async).fallbackSupported) {
<div class="tw-flex tw-items-center tw-justify-center tw-p-2">
<button type="button" [bitMenuTriggerFor]="deviceMenu">
<span bitTypography="body2">
{{ "useDeviceOrHardwareKey" | i18n }}
</span>
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
</button>
<bit-menu #deviceMenu>
<button type="button" bitMenuItem (click)="abort(false)">
{{ "justOnce" | i18n }}
</button>
<button type="button" bitMenuItem (click)="abort()">
{{ "alwaysForThisSite" | i18n }}
</button>
</bit-menu>
</div>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="isOpen"
[cdkConnectedOverlayPositions]="overlayPosition"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
(backdropClick)="isOpen = false"
(detach)="close()"
>
<div class="box-content">
<div
class="fido2-browser-selector-dropdown"
[@transformPanel]="'open'"
cdkTrapFocus
cdkTrapFocusAutoCapture
role="dialog"
aria-modal="true"
>
<button type="button" class="fido2-browser-selector-dropdown-item" (click)="abort(false)">
<span>{{ "justOnce" | i18n }}</span>
</button>
<br />
<button type="button" class="fido2-browser-selector-dropdown-item" (click)="abort()">
<span>{{ "alwaysForThisSite" | i18n }}</span>
</button>
</div>
</div>
</ng-template>
<div
*ngIf="showOverlay"
class="tw-absolute tw-size-full tw-bg-background-alt tw-inset-0 tw-bg-opacity-80 tw-z-50"
></div>
</ng-container>
@if (showOverlay) {
<div
class="tw-absolute tw-size-full tw-bg-background-alt tw-inset-0 tw-bg-opacity-80 tw-z-50"
></div>
}
}

View File

@@ -1,8 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { animate, state, style, transition, trigger } from "@angular/animations";
import { A11yModule } from "@angular/cdk/a11y";
import { ConnectedPosition, CdkOverlayOrigin, CdkConnectedOverlay } from "@angular/cdk/overlay";
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { firstValueFrom } from "rxjs";
@@ -13,6 +10,7 @@ import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { MenuModule } from "@bitwarden/components";
import { fido2PopoutSessionData$ } from "../../../vault/popup/utils/fido2-popout-session-data";
import { BrowserFido2UserInterfaceSession } from "../../fido2/services/browser-fido2-user-interface.service";
@@ -20,63 +18,24 @@ import { BrowserFido2UserInterfaceSession } from "../../fido2/services/browser-f
@Component({
selector: "app-fido2-use-browser-link",
templateUrl: "fido2-use-browser-link.component.html",
imports: [A11yModule, CdkConnectedOverlay, CdkOverlayOrigin, CommonModule, JslibModule],
animations: [
trigger("transformPanel", [
state(
"void",
style({
opacity: 0,
}),
),
transition(
"void => open",
animate(
"100ms linear",
style({
opacity: 1,
}),
),
),
transition("* => void", animate("100ms linear", style({ opacity: 0 }))),
]),
],
imports: [CommonModule, JslibModule, MenuModule],
})
export class Fido2UseBrowserLinkComponent {
showOverlay = false;
isOpen = false;
overlayPosition: ConnectedPosition[] = [
{
originX: "start",
originY: "bottom",
overlayX: "start",
overlayY: "top",
offsetY: 5,
},
];
protected fido2PopoutSessionData$ = fido2PopoutSessionData$();
constructor(
private domainSettingsService: DomainSettingsService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private readonly domainSettingsService: DomainSettingsService,
private readonly platformUtilsService: PlatformUtilsService,
private readonly i18nService: I18nService,
) {}
toggle() {
this.isOpen = !this.isOpen;
}
close() {
this.isOpen = false;
}
/**
* Aborts the current FIDO2 session and fallsback to the browser.
* @param excludeDomain - Identifies if the domain should be excluded from future FIDO2 prompts.
*/
protected async abort(excludeDomain = true) {
this.close();
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
if (!excludeDomain) {

View File

@@ -1,144 +1,140 @@
<popup-page *ngIf="data$ | async as data">
<popup-header
slot="header"
pageTitle="{{
(passkeyAction === PasskeyActions.Register ? 'savePasskey' : 'logInWithPasskeyQuestion')
| i18n
}}"
>
<button
*ngIf="showNewPasskeyButton"
bitButton
buttonType="primary"
type="button"
(click)="addCipher()"
slot="end"
@if (data$ | async; as data) {
<popup-page>
<popup-header
slot="header"
pageTitle="{{
(passkeyAction === PasskeyActions.Register ? 'savePasskey' : 'logInWithPasskeyQuestion')
| i18n
}}"
>
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>
</popup-header>
@if (showNewPasskeyButton) {
<button bitButton buttonType="primary" type="button" (click)="addCipher()" slot="end">
<i class="bwi bwi-plus" aria-hidden="true"></i>
{{ "new" | i18n }}
</button>
}
</popup-header>
<div class="tw-p-2">
<bit-section *ngIf="passkeyAction === PasskeyActions.Register">
<bit-search
appAutofocus
autocomplete="off"
id="search"
placeholder="{{ 'searchVault' | i18n }}"
(ngModelChange)="search()"
[(ngModel)]="searchText"
></bit-search>
</bit-section>
<div class="tw-p-2">
@if (passkeyAction === PasskeyActions.Register) {
<bit-section>
<bit-search
appAutofocus
autocomplete="off"
id="search"
placeholder="{{ 'searchVault' | i18n }}"
(ngModelChange)="search()"
[(ngModel)]="searchText"
></bit-search>
</bit-section>
}
<!-- Display when adding a new passkey -->
<bit-section *ngIf="data.message.type === BrowserFido2MessageTypes.ConfirmNewCredentialRequest">
<!-- Display when matching ciphers (i.e. same domain, no passkeys) exist -->
<ng-container *ngIf="displayedCiphers.length > 0">
<bit-section-header>
<h2 bitTypography="h6">{{ "chooseCipherForPasskeySave" | i18n }}</h2>
</bit-section-header>
<app-fido2-cipher-row
*ngFor="let cipherItem of displayedCiphers"
[cipher]="cipherItem"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="handleCipherItemSelect($event)"
></app-fido2-cipher-row>
</ng-container>
@switch (data.message.type) {
@case (BrowserFido2MessageTypes.ConfirmNewCredentialRequest) {
<bit-section>
@if (displayedCiphers.length > 0) {
<bit-section-header>
<h2 bitTypography="h6">{{ "chooseCipherForPasskeySave" | i18n }}</h2>
</bit-section-header>
@for (cipherItem of displayedCiphers; track cipherItem.id) {
<app-fido2-cipher-row
[cipher]="cipherItem"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="handleCipherItemSelect($event)"
></app-fido2-cipher-row>
}
}
@if (!displayedCiphers.length) {
<bit-no-items [icon]="noResultsIcon">
<ng-container slot="title">{{
(hasSearched ? "noItemsMatchSearch" : "noMatchingLoginsForSite") | i18n
}}</ng-container>
<ng-container slot="description">{{
(hasSearched ? "searchSavePasskeyNewLogin" : "clearFiltersOrTryAnother") | i18n
}}</ng-container>
<!-- Display when no matching ciphers exist -->
<ng-container *ngIf="!displayedCiphers.length">
<bit-no-items class="tw-text-main" [icon]="noResultsIcon">
<ng-container slot="title">{{
(hasSearched ? "noItemsMatchSearch" : "noMatchingLoginsForSite") | i18n
}}</ng-container>
<ng-container slot="description">{{
(hasSearched ? "searchSavePasskeyNewLogin" : "clearFiltersOrTryAnother") | i18n
}}</ng-container>
<button
bitButton
buttonType="primary"
slot="button"
type="button"
(click)="hasSearched ? clearSearch() : saveNewLogin()"
[loading]="loading"
>
{{ (hasSearched ? "multiSelectClearAll" : "savePasskeyNewLogin") | i18n }}
</button>
</bit-no-items>
</ng-container>
</bit-section>
<!-- Display when the passkey being saved already exists -->
<bit-section
*ngIf="data.message.type === BrowserFido2MessageTypes.InformExcludedCredentialRequest"
>
<div class="auth-flow">
<p class="subtitle">{{ "passkeyAlreadyExists" | i18n }}</p>
<div class="box list">
<div class="box-content">
<app-fido2-cipher-row
*ngFor="let cipherItem of displayedCiphers"
[cipher]="cipherItem"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="handleCipherItemSelect($event)"
></app-fido2-cipher-row>
</div>
</div>
</div>
</bit-section>
<!-- Display when picking a passkey to login with -->
<bit-section *ngIf="data.message.type === BrowserFido2MessageTypes.PickCredentialRequest">
<!-- Display when matching ciphers exist -->
<ng-container *ngIf="displayedCiphers.length > 0">
<ng-container slot="title">{{ "chooseCipherForPasskeyAuth" | i18n }}</ng-container>
<app-fido2-cipher-row
*ngFor="let cipherItem of displayedCiphers"
[cipher]="cipherItem"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="handleCipherItemSelect($event)"
></app-fido2-cipher-row>
</ng-container>
<!-- Display when no matching ciphers exist -->
<ng-container *ngIf="!displayedCiphers.length">
<bit-no-items class="tw-text-main" [icon]="noResultsIcon">
<ng-container slot="title">{{
(hasSearched ? "noItemsMatchSearch" : "noMatchingLoginsForSite") | i18n
}}</ng-container>
<ng-container slot="description">{{
(hasSearched ? "searchSavePasskeyNewLogin" : "clearFiltersOrTryAnother") | i18n
}}</ng-container>
<button
bitButton
buttonType="primary"
slot="button"
type="button"
(click)="hasSearched ? clearSearch() : saveNewLogin()"
[loading]="loading"
>
{{ (hasSearched ? "multiSelectClearAll" : "savePasskeyNewLogin") | i18n }}
</button>
</bit-no-items>
</ng-container>
</bit-section>
<!-- Display when initiating passkey login, but no cooresponding cipher is found in the vault -->
<bit-section
*ngIf="data.message.type === BrowserFido2MessageTypes.InformCredentialNotFoundRequest"
>
<div class="auth-flow">
<p class="subtitle">{{ "noPasskeysFoundForThisApplication" | i18n }}</p>
</div>
<button type="button" class="btn primary block" (click)="abort(false)">
<span [hidden]="loading">{{ "close" | i18n }}</span>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
</button>
</bit-section>
<app-fido2-use-browser-link></app-fido2-use-browser-link>
</div>
</popup-page>
<button
bitButton
buttonType="primary"
slot="button"
type="button"
(click)="hasSearched ? clearSearch() : saveNewLogin()"
[loading]="loading"
>
{{ (hasSearched ? "multiSelectClearAll" : "savePasskeyNewLogin") | i18n }}
</button>
</bit-no-items>
}
</bit-section>
}
@case (BrowserFido2MessageTypes.InformExcludedCredentialRequest) {
<bit-section>
<div class="tw-space-y-4">
<p>{{ "passkeyAlreadyExists" | i18n }}</p>
<div class="tw-divide-y tw-divide-secondary-300">
@for (cipherItem of displayedCiphers; track cipherItem.id) {
<app-fido2-cipher-row
[cipher]="cipherItem"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="handleCipherItemSelect($event)"
></app-fido2-cipher-row>
}
</div>
</div>
</bit-section>
}
@case (BrowserFido2MessageTypes.PickCredentialRequest) {
<bit-section>
@if (displayedCiphers.length > 0) {
<ng-container slot="title">{{ "chooseCipherForPasskeyAuth" | i18n }}</ng-container>
@for (cipherItem of displayedCiphers; track cipherItem.id) {
<app-fido2-cipher-row
[cipher]="cipherItem"
title="{{ 'passkeyItem' | i18n }}"
(onSelected)="handleCipherItemSelect($event)"
></app-fido2-cipher-row>
}
} @else {
<bit-no-items [icon]="noResultsIcon">
<ng-container slot="title">{{
(hasSearched ? "noItemsMatchSearch" : "noMatchingLoginsForSite") | i18n
}}</ng-container>
<ng-container slot="description">{{
(hasSearched ? "searchSavePasskeyNewLogin" : "clearFiltersOrTryAnother") | i18n
}}</ng-container>
<button
bitButton
buttonType="primary"
slot="button"
type="button"
(click)="hasSearched ? clearSearch() : saveNewLogin()"
[loading]="loading"
>
{{ (hasSearched ? "multiSelectClearAll" : "savePasskeyNewLogin") | i18n }}
</button>
</bit-no-items>
}
</bit-section>
}
@case (BrowserFido2MessageTypes.InformCredentialNotFoundRequest) {
<bit-section>
<div class="tw-space-y-4">
<p>{{ "noPasskeysFoundForThisApplication" | i18n }}</p>
</div>
<button
bitButton
block
buttonType="primary"
type="button"
(click)="abort(false)"
[loading]="loading"
>
{{ "close" | i18n }}
</button>
</bit-section>
}
}
<app-fido2-use-browser-link></app-fido2-use-browser-link>
</div>
</popup-page>
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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(

View File

@@ -1,4 +0,0 @@
<span>{{ "phishingPageLearnWhy"| i18n}}</span>
<a href="http://bitwarden.com/help/phishing-blocked/" bitLink block buttonType="primary">
{{ "learnMore" | i18n }}
</a>

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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",
}),
],
}),
],
};

View File

@@ -0,0 +1 @@
<span class="tw-text-muted">{{ "protectedBy" | i18n: "Bitwarden Phishing Blocker" }}</span>

View File

@@ -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 {}

View File

@@ -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 });
}
/**

View File

@@ -29,6 +29,9 @@ import {
SearchModule,
SectionComponent,
ScrollLayoutDirective,
SkeletonComponent,
SkeletonTextComponent,
SkeletonGroupComponent,
} from "@bitwarden/components";
import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service";
@@ -335,6 +338,9 @@ export default {
SectionComponent,
IconButtonModule,
BadgeModule,
SkeletonComponent,
SkeletonTextComponent,
SkeletonGroupComponent,
],
providers: [
{
@@ -594,6 +600,34 @@ export const Loading: Story = {
}),
};
export const SkeletonLoading: Story = {
render: (args) => ({
props: { ...args, data: Array(8) },
template: /* HTML */ `
<extension-container>
<popup-tab-navigation>
<popup-page>
<popup-header slot="header" pageTitle="Page Header"></popup-header>
<div>
<div class="tw-sr-only" role="status">Loading...</div>
<div class="tw-flex tw-flex-col tw-gap-4">
<bit-skeleton-text class="tw-w-1/3"></bit-skeleton-text>
@for (num of data; track $index) {
<bit-skeleton-group>
<bit-skeleton class="tw-size-8" slot="start"></bit-skeleton>
<bit-skeleton-text [lines]="2" class="tw-w-1/2"></bit-skeleton-text>
</bit-skeleton-group>
<bit-skeleton class="tw-w-full tw-h-[1px]"></bit-skeleton>
}
</div>
</div>
</popup-page>
</popup-tab-navigation>
</extension-container>
`,
}),
};
export const TransparentHeader: Story = {
render: (args) => ({
props: args,

View File

@@ -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,
},

View File

@@ -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;

View File

@@ -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"),
});
}
}

View File

@@ -49,7 +49,7 @@
{{ "clone" | i18n }}
</button>
<button type="button" bitMenuItem (click)="unarchive(cipher)">
{{ "unarchive" | i18n }}
{{ "unArchive" | i18n }}
</button>
<button
type="button"

View File

@@ -133,7 +133,7 @@ export class ArchiveComponent {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemRemovedFromArchive"),
message: this.i18nService.t("itemUnarchived"),
});
}

View File

@@ -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" },
],
}),
);

View File

@@ -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",
});
}
};

View File

@@ -14,7 +14,6 @@ import {
SsoUrlService,
UserApiLoginCredentials,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -29,6 +28,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
import { UpdateTempPasswordRequest } from "@bitwarden/common/auth/models/request/update-temp-password.request";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
@@ -62,7 +62,7 @@ export class LoginCommand {
constructor(
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected authService: AuthService,
protected apiService: ApiService,
protected twoFactorApiService: TwoFactorApiService,
protected masterPasswordApiService: MasterPasswordApiService,
protected cryptoFunctionService: CryptoFunctionService,
protected environmentService: EnvironmentService,
@@ -279,7 +279,7 @@ export class LoginCommand {
const emailReq = new TwoFactorEmailRequest();
emailReq.email = await this.loginStrategyService.getEmail();
emailReq.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash();
await this.apiService.postTwoFactorEmail(emailReq);
await this.twoFactorApiService.postTwoFactorEmail(emailReq);
}
if (twoFactorToken == null) {

View File

@@ -7,7 +7,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UserId } from "@bitwarden/common/types/guid";
import { UnlockCommand } from "./auth/commands/unlock.command";
import { UnlockCommand } from "./key-management/commands/unlock.command";
import { Response } from "./models/response";
import { ListResponse } from "./models/response/list.response";
import { MessageResponse } from "./models/response/message.response";
@@ -182,6 +182,8 @@ export abstract class BaseProgram {
this.serviceContainer.organizationApiService,
this.serviceContainer.logout,
this.serviceContainer.i18nService,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);
const response = await command.run(null, null);
if (!response.success) {

View File

@@ -261,8 +261,13 @@ export class EditCommand {
/** Prompt the user to accept movement of their cipher back to the their vault. */
private async promptForArchiveEdit(): Promise<boolean> {
// When running in serve or no interaction mode, automatically accept the prompt
if (process.env.BW_SERVE === "true" || process.env.BW_NOINTERACTION === "true") {
// When user has disabled interactivity or does not have the ability to prompt,
// automatically move the item back to the vault and inform them.
if (
process.env.BW_SERVE === "true" ||
process.env.BW_NOINTERACTION === "true" ||
!process.stdin.isTTY
) {
CliUtils.writeLn(
"Archive is only available with a Premium subscription, which has ended. Your edit was saved and the item was moved back to your vault.",
);

View File

@@ -0,0 +1,318 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { MasterPasswordVerificationResponse } from "@bitwarden/common/auth/types/verification";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { ConsoleLogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import { MessageResponse } from "../../models/response/message.response";
import { I18nService } from "../../platform/services/i18n.service";
import { ConvertToKeyConnectorCommand } from "../convert-to-key-connector.command";
import { UnlockCommand } from "./unlock.command";
describe("UnlockCommand", () => {
let command: UnlockCommand;
const accountService = mock<AccountService>();
const masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
const keyService = mock<KeyService>();
const userVerificationService = mock<UserVerificationService>();
const cryptoFunctionService = mock<CryptoFunctionService>();
const logService = mock<ConsoleLogService>();
const keyConnectorService = mock<KeyConnectorService>();
const environmentService = mock<EnvironmentService>();
const organizationApiService = mock<OrganizationApiServiceAbstraction>();
const logout = jest.fn();
const i18nService = mock<I18nService>();
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
const configService = mock<ConfigService>();
const mockMasterPassword = "testExample";
const activeAccount: Account = {
id: "user-id" as UserId,
email: "user@example.com",
emailVerified: true,
name: "User",
};
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockSessionKey = new Uint8Array(64) as CsprngArray;
const b64sessionKey = Utils.fromBufferToB64(mockSessionKey);
const expectedSuccessMessage = new MessageResponse(
"Your vault is now unlocked!",
"\n" +
"To unlock your vault, set your session key to the `BW_SESSION` environment variable. ex:\n" +
'$ export BW_SESSION="' +
b64sessionKey +
'"\n' +
'> $env:BW_SESSION="' +
b64sessionKey +
'"\n\n' +
"You can also pass the session key to any command with the `--session` option. ex:\n" +
"$ bw list items --session " +
b64sessionKey,
);
expectedSuccessMessage.raw = b64sessionKey;
// Legacy test data
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey;
beforeEach(async () => {
jest.clearAllMocks();
i18nService.t.mockImplementation((key: string) => key);
accountService.activeAccount$ = of(activeAccount);
keyConnectorService.convertAccountRequired$ = of(false);
cryptoFunctionService.randomBytes.mockResolvedValue(mockSessionKey);
command = new UnlockCommand(
accountService,
masterPasswordService,
keyService,
userVerificationService,
cryptoFunctionService,
logService,
keyConnectorService,
environmentService,
organizationApiService,
logout,
i18nService,
masterPasswordUnlockService,
configService,
);
});
describe("run", () => {
test.each([null as unknown as Account, undefined as unknown as Account])(
"returns error response when the active account is %s",
async (account) => {
accountService.activeAccount$ = of(account);
const response = await command.run(mockMasterPassword, {});
expect(response).not.toBeNull();
expect(response.success).toEqual(false);
expect(response.message).toEqual("No active account found");
expect(keyService.setUserKey).not.toHaveBeenCalled();
},
);
test.each([null as unknown as string, undefined as unknown as string, ""])(
"returns error response when the provided password is %s",
async (mockMasterPassword) => {
process.env.BW_NOINTERACTION = "true";
const response = await command.run(mockMasterPassword, {});
expect(response).not.toBeNull();
expect(response.success).toEqual(false);
expect(response.message).toEqual(
"Master password is required. Try again in interactive mode or provide a password file or environment variable.",
);
expect(keyService.setUserKey).not.toHaveBeenCalled();
},
);
describe("UnlockWithMasterPasswordUnlockData feature flag enabled", () => {
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(true));
});
it("calls masterPasswordUnlockService successfully", async () => {
masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey);
const response = await command.run(mockMasterPassword, {});
expect(response).not.toBeNull();
expect(response.success).toEqual(true);
expect(response.data).toEqual(expectedSuccessMessage);
expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith(
mockMasterPassword,
activeAccount.id,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id);
});
it("returns error response if unlockWithMasterPassword fails", async () => {
masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue(
new Error("Unlock failed"),
);
const response = await command.run(mockMasterPassword, {});
expect(response).not.toBeNull();
expect(response.success).toEqual(false);
expect(response.message).toEqual("Unlock failed");
expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith(
mockMasterPassword,
activeAccount.id,
);
expect(keyService.setUserKey).not.toHaveBeenCalled();
});
});
describe("unlock with feature flag off", () => {
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(false));
});
it("calls decryptUserKeyWithMasterKey successfully", async () => {
userVerificationService.verifyUserByMasterPassword.mockResolvedValue({
masterKey: mockMasterKey,
} as MasterPasswordVerificationResponse);
masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey);
const response = await command.run(mockMasterPassword, {});
expect(response).not.toBeNull();
expect(response.success).toEqual(true);
expect(response.data).toEqual(expectedSuccessMessage);
expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith(
{
type: VerificationType.MasterPassword,
secret: mockMasterPassword,
},
activeAccount.id,
activeAccount.email,
);
expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
mockMasterKey,
activeAccount.id,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id);
});
it("returns error response when verifyUserByMasterPassword throws", async () => {
userVerificationService.verifyUserByMasterPassword.mockRejectedValue(
new Error("Verification failed"),
);
const response = await command.run(mockMasterPassword, {});
expect(response).not.toBeNull();
expect(response.success).toEqual(false);
expect(response.message).toEqual("Verification failed");
expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith(
{
type: VerificationType.MasterPassword,
secret: mockMasterPassword,
},
activeAccount.id,
activeAccount.email,
);
expect(masterPasswordService.decryptUserKeyWithMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
});
});
describe("calls convertToKeyConnectorCommand if required", () => {
let convertToKeyConnectorSpy: jest.SpyInstance;
beforeEach(() => {
keyConnectorService.convertAccountRequired$ = of(true);
// Feature flag on
masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey);
// Feature flag off
userVerificationService.verifyUserByMasterPassword.mockResolvedValue({
masterKey: mockMasterKey,
} as MasterPasswordVerificationResponse);
masterPasswordService.decryptUserKeyWithMasterKey.mockResolvedValue(mockUserKey);
});
test.each([true, false])("returns failure when feature flag is %s", async (flagValue) => {
configService.getFeatureFlag$.mockReturnValue(of(flagValue));
// Mock the ConvertToKeyConnectorCommand
const mockRun = jest.fn().mockResolvedValue({ success: false, message: "convert failed" });
convertToKeyConnectorSpy = jest
.spyOn(ConvertToKeyConnectorCommand.prototype, "run")
.mockImplementation(mockRun);
const response = await command.run(mockMasterPassword, {});
expect(response).not.toBeNull();
expect(response.success).toEqual(false);
expect(response.message).toEqual("convert failed");
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id);
expect(convertToKeyConnectorSpy).toHaveBeenCalled();
if (flagValue === true) {
expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith(
mockMasterPassword,
activeAccount.id,
);
} else {
expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith(
{
type: VerificationType.MasterPassword,
secret: mockMasterPassword,
},
activeAccount.id,
activeAccount.email,
);
expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
mockMasterKey,
activeAccount.id,
);
}
});
test.each([true, false])(
"returns expected success when feature flag is %s",
async (flagValue) => {
configService.getFeatureFlag$.mockReturnValue(of(flagValue));
// Mock the ConvertToKeyConnectorCommand
const mockRun = jest.fn().mockResolvedValue({ success: true });
const convertToKeyConnectorSpy = jest
.spyOn(ConvertToKeyConnectorCommand.prototype, "run")
.mockImplementation(mockRun);
const response = await command.run(mockMasterPassword, {});
expect(response).not.toBeNull();
expect(response.success).toEqual(true);
expect(response.data).toEqual(expectedSuccessMessage);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, activeAccount.id);
expect(convertToKeyConnectorSpy).toHaveBeenCalled();
if (flagValue === true) {
expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith(
mockMasterPassword,
activeAccount.id,
);
} else {
expect(userVerificationService.verifyUserByMasterPassword).toHaveBeenCalledWith(
{
type: VerificationType.MasterPassword,
secret: mockMasterPassword,
},
activeAccount.id,
activeAccount.email,
);
expect(masterPasswordService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
mockMasterKey,
activeAccount.id,
);
}
},
);
});
});
});

View File

@@ -1,26 +1,29 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom } from "rxjs";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { MasterKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { ConvertToKeyConnectorCommand } from "../../key-management/convert-to-key-connector.command";
import { Response } from "../../models/response";
import { MessageResponse } from "../../models/response/message.response";
import { I18nService } from "../../platform/services/i18n.service";
import { CliUtils } from "../../utils";
import { ConvertToKeyConnectorCommand } from "../convert-to-key-connector.command";
export class UnlockCommand {
constructor(
@@ -35,6 +38,8 @@ export class UnlockCommand {
private organizationApiService: OrganizationApiServiceAbstraction,
private logout: () => Promise<void>,
private i18nService: I18nService,
private masterPasswordUnlockService: MasterPasswordUnlockService,
private configService: ConfigService,
) {}
async run(password: string, cmdOptions: Record<string, any>) {
@@ -48,30 +53,53 @@ export class UnlockCommand {
}
await this.setNewSessionKey();
const [userId, email] = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
);
const verification = {
type: VerificationType.MasterPassword,
secret: password,
} as MasterPasswordVerification;
let masterKey: MasterKey;
try {
const response = await this.userVerificationService.verifyUserByMasterPassword(
verification,
userId,
email,
);
masterKey = response.masterKey;
} catch (e) {
// verification failure throws
return Response.error(e.message);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount == null) {
return Response.error("No active account found");
}
const userId = activeAccount.id;
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId);
await this.keyService.setUserKey(userKey, userId);
if (
await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.UnlockWithMasterPasswordUnlockData),
)
) {
try {
const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword(
password,
userId,
);
await this.keyService.setUserKey(userKey, userId);
} catch (e) {
return Response.error(e.message);
}
} else {
const email = activeAccount.email;
const verification = {
type: VerificationType.MasterPassword,
secret: password,
} as MasterPasswordVerification;
let masterKey: MasterKey;
try {
const response = await this.userVerificationService.verifyUserByMasterPassword(
verification,
userId,
email,
);
masterKey = response.masterKey;
} catch (e) {
// verification failure throws
return Response.error(e.message);
}
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
userId,
);
await this.keyService.setUserKey(userKey, userId);
}
if (await firstValueFrom(this.keyConnectorService.convertAccountRequired$)) {
const convertToKeyConnectorCommand = new ConvertToKeyConnectorCommand(

View File

@@ -10,12 +10,12 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfirmCommand } from "./admin-console/commands/confirm.command";
import { ShareCommand } from "./admin-console/commands/share.command";
import { LockCommand } from "./auth/commands/lock.command";
import { UnlockCommand } from "./auth/commands/unlock.command";
import { EditCommand } from "./commands/edit.command";
import { GetCommand } from "./commands/get.command";
import { ListCommand } from "./commands/list.command";
import { RestoreCommand } from "./commands/restore.command";
import { StatusCommand } from "./commands/status.command";
import { UnlockCommand } from "./key-management/commands/unlock.command";
import { Response } from "./models/response";
import { FileResponse } from "./models/response/file.response";
import { ServiceContainer } from "./service-container/service-container";
@@ -173,6 +173,8 @@ export class OssServeConfigurator {
this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(),
this.serviceContainer.i18nService,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);
this.sendCreateCommand = new SendCreateCommand(
@@ -211,6 +213,7 @@ export class OssServeConfigurator {
this.serviceContainer.sendService,
this.serviceContainer.sendApiService,
this.serviceContainer.environmentService,
this.serviceContainer.accountService,
);
}

View File

@@ -10,12 +10,12 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { LockCommand } from "./auth/commands/lock.command";
import { LoginCommand } from "./auth/commands/login.command";
import { LogoutCommand } from "./auth/commands/logout.command";
import { UnlockCommand } from "./auth/commands/unlock.command";
import { BaseProgram } from "./base-program";
import { CompletionCommand } from "./commands/completion.command";
import { EncodeCommand } from "./commands/encode.command";
import { StatusCommand } from "./commands/status.command";
import { UpdateCommand } from "./commands/update.command";
import { UnlockCommand } from "./key-management/commands/unlock.command";
import { Response } from "./models/response";
import { MessageResponse } from "./models/response/message.response";
import { ConfigCommand } from "./platform/commands/config.command";
@@ -175,7 +175,7 @@ export class Program extends BaseProgram {
const command = new LoginCommand(
this.serviceContainer.loginStrategyService,
this.serviceContainer.authService,
this.serviceContainer.apiService,
this.serviceContainer.twoFactorApiService,
this.serviceContainer.masterPasswordApiService,
this.serviceContainer.cryptoFunctionService,
this.serviceContainer.environmentService,
@@ -303,6 +303,8 @@ export class Program extends BaseProgram {
this.serviceContainer.organizationApiService,
async () => await this.serviceContainer.logout(),
this.serviceContainer.i18nService,
this.serviceContainer.masterPasswordUnlockService,
this.serviceContainer.configService,
);
const response = await command.run(password, cmd);
this.processResponse(response);

View File

@@ -49,6 +49,7 @@ import { TokenService } from "@bitwarden/common/auth/services/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service";
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
import { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import {
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
@@ -69,10 +70,14 @@ import { EncryptServiceImplementation } from "@bitwarden/common/key-management/c
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { DefaultMasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/services/default-master-password-unlock.service";
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,
@@ -226,6 +231,7 @@ export class ServiceContainer {
tokenService: TokenService;
appIdService: AppIdService;
apiService: NodeApiService;
twoFactorApiService: TwoFactorApiService;
hibpApiService: HibpApiService;
environmentService: EnvironmentService;
cipherService: CipherService;
@@ -305,6 +311,8 @@ export class ServiceContainer {
cipherEncryptionService: CipherEncryptionService;
restrictedItemTypesService: RestrictedItemTypesService;
cliRestrictedItemTypesService: CliRestrictedItemTypesService;
securityStateService: SecurityStateService;
masterPasswordUnlockService: MasterPasswordUnlockService;
cipherArchiveService: CipherArchiveService;
constructor() {
@@ -406,6 +414,8 @@ export class ServiceContainer {
this.derivedStateProvider,
);
this.securityStateService = new DefaultSecurityStateService(this.stateProvider);
this.environmentService = new DefaultEnvironmentService(
this.stateProvider,
this.accountService,
@@ -473,6 +483,11 @@ export class ServiceContainer {
this.kdfConfigService,
);
this.masterPasswordUnlockService = new DefaultMasterPasswordUnlockService(
this.masterPasswordService,
this.keyService,
);
this.appIdService = new AppIdService(this.storageService, this.logService);
const customUserAgent =
@@ -523,6 +538,8 @@ export class ServiceContainer {
this.configApiService = new ConfigApiService(this.apiService);
this.twoFactorApiService = new DefaultTwoFactorApiService(this.apiService);
this.authService = new AuthService(
this.accountService,
this.messagingService,
@@ -547,6 +564,7 @@ export class ServiceContainer {
this.sendStateProvider = new SendStateProvider(this.stateProvider);
this.sendService = new SendService(
this.accountService,
this.keyService,
this.i18nService,
this.keyGenerationService,
@@ -612,6 +630,7 @@ export class ServiceContainer {
this.accountService,
this.kdfConfigService,
this.keyService,
this.securityStateService,
this.apiService,
this.stateProvider,
this.configService,
@@ -818,6 +837,7 @@ export class ServiceContainer {
this.tokenService,
this.authService,
this.stateProvider,
this.securityStateService,
);
this.totpService = new TotpService(this.sdkService);

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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);

View File

@@ -909,6 +909,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"
@@ -1261,6 +1277,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"
@@ -2164,7 +2195,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",
@@ -2919,6 +2950,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"

View File

@@ -6,6 +6,7 @@ members = [
"core",
"macos_provider",
"napi",
"process_isolation",
"proxy",
"ssh_agent",
"windows_plugin_authenticator",
@@ -28,6 +29,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"

View File

@@ -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();
});

View 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 }

View 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();
}
}

View File

@@ -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

View 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"]
}
]
}

View File

@@ -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"],

View File

@@ -35,9 +35,10 @@
"build:renderer:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer --watch",
"electron": "node ./scripts/start.js",
"electron:ignore": "node ./scripts/start.js --ignore-certificate-errors",
"flatpak:dev": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --force-clean --install --user ../../.flatpak/ ./resources/com.bitwarden.desktop.devel.yaml && flatpak run com.bitwarden.desktop",
"clean:dist": "rimraf ./dist",
"pack:dir": "npm run clean:dist && electron-builder --dir -p never",
"pack:lin:flatpak": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --repo=build/.repo build/.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ./build/.repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
"pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/",
"pack:lin:arm64": "npm run clean:dist && electron-builder --dir -p never && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .",
"pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never",
@@ -48,6 +49,7 @@
"pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never",
"pack:mac:masdev:with-extension": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
"pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
"pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
"pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never",
"dist:dir": "npm run build && npm run pack:dir",
"dist:lin": "npm run build && npm run pack:lin",

115
apps/desktop/project.json Normal file
View 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"]
}
}
}
}

View File

@@ -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 "$@"

View File

@@ -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=""

View 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"],
},
);

View File

@@ -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",
},

View File

@@ -629,7 +629,6 @@ describe("SettingsComponent", () => {
});
it("should not save vault timeout when vault timeout is invalid", async () => {
i18nService.t.mockReturnValue("Number too large test error");
component["form"].controls.vaultTimeout.setErrors({}, { emitEvent: false });
await component.saveVaultTimeout(DEFAULT_VAULT_TIMEOUT, 999_999_999);
@@ -639,11 +638,6 @@ describe("SettingsComponent", () => {
DEFAULT_VAULT_TIMEOUT_ACTION,
);
expect(component["form"].getRawValue().vaultTimeout).toEqual(DEFAULT_VAULT_TIMEOUT);
expect(platformUtilsService.showToast).toHaveBeenCalledWith(
"error",
null,
"Number too large test error",
);
});
});

View File

@@ -510,16 +510,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
// Avoid saving 0 since it's useless as a timeout value.
if (this.form.value.vaultTimeout === 0) {
if (newValue === 0) {
return;
}
if (!this.form.controls.vaultTimeout.valid) {
this.platformUtilsService.showToast(
"error",
null,
this.i18nService.t("vaultTimeoutTooLarge"),
);
return;
}

View File

@@ -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() !== "";
}

View File

@@ -2549,6 +2549,9 @@
}
}
},
"vaultCustomTimeoutMinimum": {
"message": "Minimum custom timeout is 1 minute."
},
"inviteAccepted": {
"message": "Invitation accepted"
},
@@ -4149,7 +4152,7 @@
"message": "Archive",
"description": "Verb"
},
"unarchive": {
"unArchive": {
"message": "Unarchive"
},
"itemsInArchive": {
@@ -4161,11 +4164,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"

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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(() => {});

View File

@@ -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"],
}),

View File

@@ -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"),
},
});
}
};

View File

@@ -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
View 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"]
}
}
}
}

View File

@@ -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 }),

View File

@@ -5,7 +5,6 @@ import { ActivatedRoute } from "@angular/router";
import { concatMap, takeUntil, map, lastValueFrom, firstValueFrom } from "rxjs";
import { first, tap } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import {
getOrganizationById,
OrganizationService,
@@ -15,6 +14,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -35,7 +35,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
tabbedHeader = false;
constructor(
dialogService: DialogService,
apiService: ApiService,
twoFactorApiService: TwoFactorApiService,
messagingService: MessagingService,
policyService: PolicyService,
private route: ActivatedRoute,
@@ -47,7 +47,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
) {
super(
dialogService,
apiService,
twoFactorApiService,
messagingService,
policyService,
billingAccountProfileStateService,
@@ -116,7 +116,7 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme
}
protected getTwoFactorProviders() {
return this.apiService.getTwoFactorOrganizationProviders(this.organizationId);
return this.twoFactorApiService.getTwoFactorOrganizationProviders(this.organizationId);
}
protected filterProvider(type: TwoFactorProviderType): boolean {

View File

@@ -7,6 +7,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -22,12 +23,14 @@ describe("ChangeEmailComponent", () => {
let fixture: ComponentFixture<ChangeEmailComponent>;
let apiService: MockProxy<ApiService>;
let twoFactorApiService: MockProxy<TwoFactorApiService>;
let accountService: FakeAccountService;
let keyService: MockProxy<KeyService>;
let kdfConfigService: MockProxy<KdfConfigService>;
beforeEach(async () => {
apiService = mock<ApiService>();
twoFactorApiService = mock<TwoFactorApiService>();
keyService = mock<KeyService>();
kdfConfigService = mock<KdfConfigService>();
accountService = mockAccountServiceWith("UserId" as UserId);
@@ -37,6 +40,7 @@ describe("ChangeEmailComponent", () => {
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: ApiService, useValue: apiService },
{ provide: TwoFactorApiService, useValue: twoFactorApiService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: KeyService, useValue: keyService },
{ provide: MessagingService, useValue: mock<MessagingService>() },
@@ -57,7 +61,7 @@ describe("ChangeEmailComponent", () => {
describe("ngOnInit", () => {
beforeEach(() => {
apiService.getTwoFactorProviders.mockResolvedValue({
twoFactorApiService.getTwoFactorProviders.mockResolvedValue({
data: [{ type: TwoFactorProviderType.Email, enabled: true } as TwoFactorProviderResponse],
} as ListResponse<TwoFactorProviderResponse>);
});

View File

@@ -8,6 +8,7 @@ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-p
import { EmailTokenRequest } from "@bitwarden/common/auth/models/request/email-token.request";
import { EmailRequest } from "@bitwarden/common/auth/models/request/email.request";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -37,6 +38,7 @@ export class ChangeEmailComponent implements OnInit {
constructor(
private accountService: AccountService,
private apiService: ApiService,
private twoFactorApiService: TwoFactorApiService,
private i18nService: I18nService,
private keyService: KeyService,
private messagingService: MessagingService,
@@ -48,7 +50,7 @@ export class ChangeEmailComponent implements OnInit {
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const twoFactorProviders = await this.apiService.getTwoFactorProviders();
const twoFactorProviders = await this.twoFactorApiService.getTwoFactorProviders();
this.showTwoFactorEmailWarning = twoFactorProviders.data.some(
(p) => p.type === TwoFactorProviderType.Email && p.enabled,
);

View File

@@ -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: " +

View File

@@ -5,11 +5,11 @@ import { firstValueFrom, Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { SetVerifyDevicesRequest } from "@bitwarden/common/auth/models/request/set-verify-devices.request";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -64,7 +64,7 @@ export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy
private userVerificationService: UserVerificationService,
private dialogRef: DialogRef,
private toastService: ToastService,
private apiService: ApiService,
private twoFactorApiService: TwoFactorApiService,
) {
this.accountService.accountVerifyNewDeviceLogin$
.pipe(takeUntil(this.destroy$))
@@ -74,7 +74,7 @@ export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy
}
async ngOnInit() {
const twoFactorProviders = await this.apiService.getTwoFactorProviders();
const twoFactorProviders = await this.twoFactorApiService.getTwoFactorProviders();
this.has2faConfigured = twoFactorProviders.data.length > 0;
}

View File

@@ -6,13 +6,13 @@ import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angu
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request";
import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request";
import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -92,7 +92,7 @@ export class TwoFactorSetupAuthenticatorComponent
constructor(
@Inject(DIALOG_DATA) protected data: AuthResponse<TwoFactorAuthenticatorResponse>,
private dialogRef: DialogRef,
apiService: ApiService,
twoFactorApiService: TwoFactorApiService,
i18nService: I18nService,
userVerificationService: UserVerificationService,
private formBuilder: FormBuilder,
@@ -104,7 +104,7 @@ export class TwoFactorSetupAuthenticatorComponent
protected toastService: ToastService,
) {
super(
apiService,
twoFactorApiService,
i18nService,
platformUtilsService,
logService,
@@ -154,7 +154,7 @@ export class TwoFactorSetupAuthenticatorComponent
request.key = this.key;
request.userVerificationToken = this.userVerificationToken;
const response = await this.apiService.putTwoFactorAuthenticator(request);
const response = await this.twoFactorApiService.putTwoFactorAuthenticator(request);
await this.processResponse(response);
this.onUpdated.emit(true);
}
@@ -174,7 +174,7 @@ export class TwoFactorSetupAuthenticatorComponent
request.type = this.type;
request.key = this.key;
request.userVerificationToken = this.userVerificationToken;
await this.apiService.deleteTwoFactorAuthenticator(request);
await this.twoFactorApiService.deleteTwoFactorAuthenticator(request);
this.enabled = false;
this.toastService.showToast({
variant: "success",

View File

@@ -2,11 +2,11 @@ import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Inject, OnInit, Output } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { UpdateTwoFactorDuoRequest } from "@bitwarden/common/auth/models/request/update-two-factor-duo.request";
import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two-factor-duo.response";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -63,7 +63,7 @@ export class TwoFactorSetupDuoComponent
constructor(
@Inject(DIALOG_DATA) protected data: TwoFactorDuoComponentConfig,
apiService: ApiService,
twoFactorApiService: TwoFactorApiService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
logService: LogService,
@@ -74,7 +74,7 @@ export class TwoFactorSetupDuoComponent
protected toastService: ToastService,
) {
super(
apiService,
twoFactorApiService,
i18nService,
platformUtilsService,
logService,
@@ -139,9 +139,12 @@ export class TwoFactorSetupDuoComponent
let response: TwoFactorDuoResponse;
if (this.organizationId != null) {
response = await this.apiService.putTwoFactorOrganizationDuo(this.organizationId, request);
response = await this.twoFactorApiService.putTwoFactorOrganizationDuo(
this.organizationId,
request,
);
} else {
response = await this.apiService.putTwoFactorDuo(request);
response = await this.twoFactorApiService.putTwoFactorDuo(request);
}
this.processResponse(response);

View File

@@ -3,13 +3,13 @@ import { Component, EventEmitter, Inject, OnInit, Output } from "@angular/core";
import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms";
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
import { UpdateTwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/update-two-factor-email.request";
import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -66,7 +66,7 @@ export class TwoFactorSetupEmailComponent
constructor(
@Inject(DIALOG_DATA) protected data: AuthResponse<TwoFactorEmailResponse>,
apiService: ApiService,
twoFactorApiService: TwoFactorApiService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
logService: LogService,
@@ -78,7 +78,7 @@ export class TwoFactorSetupEmailComponent
protected toastService: ToastService,
) {
super(
apiService,
twoFactorApiService,
i18nService,
platformUtilsService,
logService,
@@ -131,7 +131,7 @@ export class TwoFactorSetupEmailComponent
sendEmail = async () => {
const request = await this.buildRequestModel(TwoFactorEmailRequest);
request.email = this.email;
this.emailPromise = this.apiService.postTwoFactorEmailSetup(request);
this.emailPromise = this.twoFactorApiService.postTwoFactorEmailSetup(request);
await this.emailPromise;
this.sentEmail = this.email;
};
@@ -141,7 +141,7 @@ export class TwoFactorSetupEmailComponent
request.email = this.email;
request.token = this.token;
const response = await this.apiService.putTwoFactorEmail(request);
const response = await this.twoFactorApiService.putTwoFactorEmail(request);
await this.processResponse(response);
this.onUpdated.emit(true);
}

View File

@@ -1,11 +1,11 @@
import { Directive, EventEmitter, Output } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
import { TwoFactorProviderRequest } from "@bitwarden/common/auth/models/request/two-factor-provider.request";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponseBase } from "@bitwarden/common/auth/types/auth-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -30,7 +30,7 @@ export abstract class TwoFactorSetupMethodBaseComponent {
protected componentName = "";
constructor(
protected apiService: ApiService,
protected twoFactorApiService: TwoFactorApiService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected logService: LogService,
@@ -77,9 +77,12 @@ export abstract class TwoFactorSetupMethodBaseComponent {
}
request.type = this.type;
if (this.organizationId != null) {
promise = this.apiService.putTwoFactorOrganizationDisable(this.organizationId, request);
promise = this.twoFactorApiService.putTwoFactorOrganizationDisable(
this.organizationId,
request,
);
} else {
promise = this.apiService.putTwoFactorDisable(request);
promise = this.twoFactorApiService.putTwoFactorDisable(request);
}
await promise;
this.enabled = false;
@@ -111,9 +114,9 @@ export abstract class TwoFactorSetupMethodBaseComponent {
}
request.type = this.type;
if (this.organizationId != null) {
await this.apiService.putTwoFactorOrganizationDisable(this.organizationId, request);
await this.twoFactorApiService.putTwoFactorOrganizationDisable(this.organizationId, request);
} else {
await this.apiService.putTwoFactorDisable(request);
await this.twoFactorApiService.putTwoFactorDisable(request);
}
this.enabled = false;
this.toastService.showToast({

View File

@@ -3,7 +3,6 @@ import { Component, Inject, NgZone } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
@@ -13,6 +12,7 @@ import {
ChallengeResponse,
TwoFactorWebAuthnResponse,
} from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -79,7 +79,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
constructor(
@Inject(DIALOG_DATA) protected data: AuthResponse<TwoFactorWebAuthnResponse>,
private dialogRef: DialogRef,
apiService: ApiService,
twoFactorApiService: TwoFactorApiService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
private ngZone: NgZone,
@@ -89,7 +89,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
toastService: ToastService,
) {
super(
apiService,
twoFactorApiService,
i18nService,
platformUtilsService,
logService,
@@ -127,7 +127,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
request.id = this.keyIdAvailable;
request.name = this.formGroup.value.name || "";
const response = await this.apiService.putTwoFactorWebAuthn(request);
const response = await this.twoFactorApiService.putTwoFactorWebAuthn(request);
this.processResponse(response);
this.toastService.showToast({
title: this.i18nService.t("success"),
@@ -163,7 +163,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
const request = await this.buildRequestModel(UpdateTwoFactorWebAuthnDeleteRequest);
request.id = key.id;
try {
key.removePromise = this.apiService.deleteTwoFactorWebAuthn(request);
key.removePromise = this.twoFactorApiService.deleteTwoFactorWebAuthn(request);
const response = await key.removePromise;
key.removePromise = null;
await this.processResponse(response);
@@ -177,7 +177,7 @@ export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseCom
return;
}
const request = await this.buildRequestModel(SecretVerificationRequest);
this.challengePromise = this.apiService.getTwoFactorWebAuthnChallenge(request);
this.challengePromise = this.twoFactorApiService.getTwoFactorWebAuthnChallenge(request);
const challenge = await this.challengePromise;
this.readDevice(challenge);
};

View File

@@ -9,11 +9,11 @@ import {
} from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { UpdateTwoFactorYubikeyOtpRequest } from "@bitwarden/common/auth/models/request/update-two-factor-yubikey-otp.request";
import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -93,7 +93,7 @@ export class TwoFactorSetupYubiKeyComponent
constructor(
@Inject(DIALOG_DATA) protected data: AuthResponse<TwoFactorYubiKeyResponse>,
apiService: ApiService,
twoFactorApiService: TwoFactorApiService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
logService: LogService,
@@ -103,7 +103,7 @@ export class TwoFactorSetupYubiKeyComponent
protected toastService: ToastService,
) {
super(
apiService,
twoFactorApiService,
i18nService,
platformUtilsService,
logService,
@@ -176,7 +176,7 @@ export class TwoFactorSetupYubiKeyComponent
request.key5 = keys != null && keys.length > 4 ? (keys[4]?.key ?? "") : "";
request.nfc = this.formGroup.value.anyKeyHasNfc ?? false;
this.processResponse(await this.apiService.putTwoFactorYubiKey(request));
this.processResponse(await this.twoFactorApiService.putTwoFactorYubiKey(request));
this.refreshFormArrayData();
this.toastService.showToast({
title: this.i18nService.t("success"),

View File

@@ -13,7 +13,6 @@ import {
} from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -26,6 +25,7 @@ import { TwoFactorWebAuthnResponse } from "@bitwarden/common/auth/models/respons
import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
@@ -68,7 +68,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
constructor(
protected dialogService: DialogService,
protected apiService: ApiService,
protected twoFactorApiService: TwoFactorApiService,
protected messagingService: MessagingService,
protected policyService: PolicyService,
billingAccountProfileStateService: BillingAccountProfileStateService,
@@ -270,7 +270,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
}
protected getTwoFactorProviders() {
return this.apiService.getTwoFactorProviders();
return this.twoFactorApiService.getTwoFactorProviders();
}
protected filterProvider(type: TwoFactorProviderType): boolean {

View File

@@ -2,11 +2,11 @@ import { Component, EventEmitter, Inject, Output } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { TwoFactorResponse } from "@bitwarden/common/auth/types/two-factor-response";
import { Verification } from "@bitwarden/common/auth/types/verification";
@@ -55,7 +55,7 @@ export class TwoFactorVerifyComponent {
constructor(
@Inject(DIALOG_DATA) protected data: TwoFactorVerifyDialogData,
private dialogRef: DialogRef,
private apiService: ApiService,
private twoFactorApiService: TwoFactorApiService,
private i18nService: I18nService,
private userVerificationService: UserVerificationService,
) {
@@ -116,22 +116,22 @@ export class TwoFactorVerifyComponent {
private apiCall(request: SecretVerificationRequest): Promise<TwoFactorResponse> {
switch (this.type) {
case -1 as TwoFactorProviderType:
return this.apiService.getTwoFactorRecover(request);
return this.twoFactorApiService.getTwoFactorRecover(request);
case TwoFactorProviderType.Duo:
case TwoFactorProviderType.OrganizationDuo:
if (this.organizationId != null) {
return this.apiService.getTwoFactorOrganizationDuo(this.organizationId, request);
return this.twoFactorApiService.getTwoFactorOrganizationDuo(this.organizationId, request);
} else {
return this.apiService.getTwoFactorDuo(request);
return this.twoFactorApiService.getTwoFactorDuo(request);
}
case TwoFactorProviderType.Email:
return this.apiService.getTwoFactorEmail(request);
return this.twoFactorApiService.getTwoFactorEmail(request);
case TwoFactorProviderType.WebAuthn:
return this.apiService.getTwoFactorWebAuthn(request);
return this.twoFactorApiService.getTwoFactorWebAuthn(request);
case TwoFactorProviderType.Authenticator:
return this.apiService.getTwoFactorAuthenticator(request);
return this.twoFactorApiService.getTwoFactorAuthenticator(request);
case TwoFactorProviderType.Yubikey:
return this.apiService.getTwoFactorYubiKey(request);
return this.twoFactorApiService.getTwoFactorYubiKey(request);
default:
throw new Error(`Unknown two-factor type: ${this.type}`);
}

View File

@@ -1,9 +1,12 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { PremiumVNextComponent } from "./premium/premium-vnext.component";
import { PremiumComponent } from "./premium/premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@@ -20,11 +23,15 @@ const routes: Routes = [
component: UserSubscriptionComponent,
data: { titleId: "premiumMembership" },
},
{
path: "premium",
component: PremiumComponent,
data: { titleId: "goPremium" },
},
...featureFlaggedRoute({
defaultComponent: PremiumComponent,
flaggedComponent: PremiumVNextComponent,
featureFlag: FeatureFlag.PM24033PremiumUpgradeNewDesign,
routeOptions: {
data: { titleId: "goPremium" },
path: "premium",
},
}),
{
path: "payment-details",
component: AccountPaymentDetailsComponent,

View File

@@ -1,5 +1,6 @@
import { NgModule } from "@angular/core";
import { PricingCardComponent } from "@bitwarden/pricing";
import {
EnterBillingAddressComponent,
EnterPaymentMethodComponent,
@@ -21,6 +22,7 @@ import { UserSubscriptionComponent } from "./user-subscription.component";
HeaderModule,
EnterPaymentMethodComponent,
EnterBillingAddressComponent,
PricingCardComponent,
],
declarations: [
SubscriptionComponent,

View File

@@ -0,0 +1,68 @@
<div class="tw-max-w-3xl tw-mx-auto">
<bit-section *ngIf="shouldShowNewDesign$ | async">
<div class="tw-text-center">
<div class="tw-mt-8 tw-mb-6">
<span bitBadge variant="secondary" [truncate]="false">
{{ "bitwardenFreeplanMessage" | i18n }}
</span>
</div>
<h2 *ngIf="!isSelfHost" class="tw-mt-2 tw-text-4xl">
{{ "upgradeCompleteSecurity" | i18n }}
</h2>
<p class="tw-text-muted tw-mb-6 tw-mt-4">
{{ "individualUpgradeDescriptionMessage" | i18n }}
</p>
</div>
<!-- Two-Card Layout -->
<div class="tw-grid tw-grid-cols-1 md:tw-grid-cols-2 tw-gap-6 tw-mt-6 tw-justify-center">
<!-- Premium Card -->
<div>
@if (premiumCardData$ | async; as premiumData) {
<billing-pricing-card
[tagline]="'planDescPremium' | i18n"
[price]="{ amount: premiumData.price, cadence: 'monthly' }"
[button]="{ type: 'primary', text: ('upgradeToPremium' | i18n) }"
[features]="premiumData.features"
(buttonClick)="openUpgradeDialog('Premium')"
>
<h3 slot="title" bitTypography="h3" class="tw-m-0">{{ "premium" | i18n }}</h3>
</billing-pricing-card>
}
</div>
<!-- Families Card -->
<div>
@if (familiesCardData$ | async; as familiesData) {
<billing-pricing-card
[tagline]="'planDescFamiliesV2' | i18n"
[price]="{ amount: familiesData.price, cadence: 'monthly' }"
[button]="{ type: 'secondary', text: ('upgradeToFamilies' | i18n) }"
[features]="familiesData.features"
(buttonClick)="openUpgradeDialog('Families')"
>
<h3 slot="title" bitTypography="h3" class="tw-m-0">{{ "families" | i18n }}</h3>
</billing-pricing-card>
}
</div>
</div>
<!-- Business Plans Link -->
<div class="tw-text-center tw-mt-6">
<p class="tw-text-muted tw-mb-2 tw-italic">
{{ "individualUpgradeTaxInformationMessage" | i18n }}
</p>
<a
bitLink
linkType="primary"
href="https://bitwarden.com/pricing/business/"
target="_blank"
rel="noopener noreferrer"
>
{{ "viewbusinessplans" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</div>
</bit-section>
</div>

View File

@@ -0,0 +1,182 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { combineLatest, firstValueFrom, map, Observable, of, shareReplay, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
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";
import {
DialogService,
ToastService,
SectionComponent,
BadgeModule,
TypographyModule,
LinkModule,
} from "@bitwarden/components";
import { PricingCardComponent } from "@bitwarden/pricing";
import { I18nPipe } from "@bitwarden/ui-common";
import { SubscriptionPricingService } from "../../services/subscription-pricing.service";
import { BitwardenSubscriber, mapAccountToSubscriber } from "../../types";
import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "../../types/subscription-pricing-tier";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogParams,
UnifiedUpgradeDialogResult,
UnifiedUpgradeDialogStatus,
UnifiedUpgradeDialogStep,
} from "../upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component";
@Component({
templateUrl: "./premium-vnext.component.html",
standalone: true,
imports: [
CommonModule,
SectionComponent,
BadgeModule,
TypographyModule,
LinkModule,
I18nPipe,
PricingCardComponent,
],
})
export class PremiumVNextComponent {
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
protected hasPremiumPersonally$: Observable<boolean>;
protected shouldShowNewDesign$: Observable<boolean>;
protected personalPricingTiers$: Observable<PersonalSubscriptionPricingTier[]>;
protected premiumCardData$: Observable<{
tier: PersonalSubscriptionPricingTier | undefined;
price: number;
features: string[];
}>;
protected familiesCardData$: Observable<{
tier: PersonalSubscriptionPricingTier | undefined;
price: number;
features: string[];
}>;
protected subscriber!: BitwardenSubscriber;
protected isSelfHost = false;
private destroyRef = inject(DestroyRef);
constructor(
private accountService: AccountService,
private i18nService: I18nService,
private apiService: ApiService,
private dialogService: DialogService,
private platformUtilsService: PlatformUtilsService,
private syncService: SyncService,
private toastService: ToastService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingService,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id)
: of(false),
),
);
this.hasPremiumPersonally$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id)
: of(false),
),
);
this.accountService.activeAccount$
.pipe(mapAccountToSubscriber, takeUntilDestroyed(this.destroyRef))
.subscribe((subscriber) => {
this.subscriber = subscriber;
});
this.shouldShowNewDesign$ = combineLatest([
this.hasPremiumFromAnyOrganization$,
this.hasPremiumPersonally$,
]).pipe(map(([hasOrgPremium, hasPersonalPremium]) => !hasOrgPremium && !hasPersonalPremium));
this.personalPricingTiers$ =
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$();
this.premiumCardData$ = this.personalPricingTiers$.pipe(
map((tiers) => {
const tier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
return {
tier,
price:
tier?.passwordManager.type === "standalone"
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0,
features: tier?.passwordManager.features.map((f) => f.value) || [],
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.familiesCardData$ = this.personalPricingTiers$.pipe(
map((tiers) => {
const tier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Families);
return {
tier,
price:
tier?.passwordManager.type === "packaged"
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0,
features: tier?.passwordManager.features.map((f) => f.value) || [],
};
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
finalizeUpgrade = async () => {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
};
protected async openUpgradeDialog(planType: "Premium" | "Families"): Promise<void> {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (!account) {
return;
}
const selectedPlan =
planType === "Premium"
? PersonalSubscriptionPricingTierIds.Premium
: PersonalSubscriptionPricingTierIds.Families;
const dialogParams: UnifiedUpgradeDialogParams = {
account,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: selectedPlan,
redirectOnCompletion: true,
};
const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, {
data: dialogParams,
});
dialogRef.closed
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result: UnifiedUpgradeDialogResult | undefined) => {
if (
result?.status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
result?.status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
) {
void this.finalizeUpgrade();
}
});
}
}

View File

@@ -1,137 +1,132 @@
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan" | i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<bit-container>
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
title="{{ 'youHavePremiumAccess' | i18n }}"
icon="bwi bwi-star-f"
>
{{ "alreadyPremiumFromOrg" | i18n }}
</bit-callout>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice | currency: "$") : familyPlanMaxUserCount
}}
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
>
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitLink
linkType="primary"
routerLink="/create-organization"
[queryParams]="{ plan: 'families' }"
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
{{ "bitwardenFamiliesPlan" | i18n }}
{{ "purchasePremium" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<individual-self-hosting-license-uploader
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</bit-callout>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
<bit-section *ngIf="isSelfHost">
<individual-self-hosting-license-uploader
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
[showAccountCredit]="true"
[hasEnoughAccountCredit]="hasEnoughAccountCredit$ | async"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "additionalStorageGb" | i18n }}</bit-label>
<input
bitInput
formControlName="additionalStorage"
type="number"
step="1"
placeholder="{{ 'additionalStorageGbDesc' | i18n }}"
/>
<bit-hint>{{
"additionalStorageIntervalDesc"
| i18n: "1 GB" : (storageGBPrice | currency: "$") : ("year" | i18n)
}}</bit-hint>
</bit-form-field>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
<button
type="submit"
buttonType="primary"
bitButton
bitFormButton
[disabled]="!(hasEnoughAccountCredit$ | async)"
>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>
</bit-section>
<bit-section>
<h2 bitTypography="h2">{{ "summary" | i18n }}</h2>
{{ "premiumMembership" | i18n }}: {{ premiumPrice | currency: "$" }} <br />
{{ "additionalStorageGb" | i18n }}: {{ formGroup.value.additionalStorage || 0 }} GB &times;
{{ storageGBPrice | currency: "$" }} =
{{ additionalStorageCost | currency: "$" }}
<hr class="tw-my-3" />
</bit-section>
<bit-section>
<h3 bitTypography="h2">{{ "paymentInformation" | i18n }}</h3>
<div class="tw-mb-4">
<app-enter-payment-method
[group]="formGroup.controls.paymentMethod"
[showBankAccount]="false"
>
</app-enter-payment-method>
<app-enter-billing-address
[group]="formGroup.controls.billingAddress"
[scenario]="{ type: 'checkout', supportsTaxId: false }"
>
</app-enter-billing-address>
</div>
<div class="tw-mb-4">
<div class="tw-text-muted tw-text-sm tw-flex tw-flex-col">
<span>{{ "planPrice" | i18n }}: {{ subtotal | currency: "USD $" }}</span>
<span>{{ "estimatedTax" | i18n }}: {{ estimatedTax | currency: "USD $" }}</span>
</div>
</div>
<hr class="tw-my-1 tw-w-1/4 tw-ml-0" />
<p bitTypography="body1">
<strong>{{ "total" | i18n }}:</strong> {{ total | currency: "USD $" }}/{{ "year" | i18n }}
</p>
<button type="submit" buttonType="primary" bitButton bitFormButton>
{{ "submit" | i18n }}
</button>
</bit-section>
</form>
</bit-container>

View File

@@ -8,6 +8,4 @@
</bit-tab-nav-bar>
</app-header>
<bit-container>
<router-outlet></router-outlet>
</bit-container>
<router-outlet></router-outlet>

View File

@@ -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()"

View File

@@ -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);
});
});
});

View File

@@ -1,6 +1,7 @@
import { DIALOG_DATA } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit, signal } from "@angular/core";
import { Router } from "@angular/router";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
@@ -48,11 +49,17 @@ 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.
* @property {boolean} [redirectOnCompletion] - Whether to redirect after successful upgrade. Premium upgrades redirect to subscription settings, Families upgrades redirect to organization vault.
*/
export type UnifiedUpgradeDialogParams = {
account: Account;
initialStep?: UnifiedUpgradeDialogStep | null;
selectedPlan?: PersonalSubscriptionPricingTierId | null;
planSelectionStepTitleOverride?: string | null;
hideContinueWithoutUpgradingButton?: boolean;
redirectOnCompletion?: boolean;
};
@Component({
@@ -73,6 +80,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;
@@ -80,12 +89,17 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
constructor(
private dialogRef: DialogRef<UnifiedUpgradeDialogResult>,
@Inject(DIALOG_DATA) private params: UnifiedUpgradeDialogParams,
private router: Router,
) {}
ngOnInit(): void {
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 {
@@ -132,7 +146,20 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
default:
status = UnifiedUpgradeDialogStatus.Closed;
}
this.close({ status, organizationId: result.organizationId });
if (
this.params.redirectOnCompletion &&
(status === UnifiedUpgradeDialogStatus.UpgradedToPremium ||
status === UnifiedUpgradeDialogStatus.UpgradedToFamilies)
) {
const redirectUrl =
status === UnifiedUpgradeDialogStatus.UpgradedToFamilies
? `/organizations/${result.organizationId}/vault`
: "/settings/subscription/user-subscription";
void this.router.navigate([redirectUrl]);
}
}
/**

View File

@@ -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>

View File

@@ -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();
});
});
});

View File

@@ -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,

View File

@@ -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>

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