1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-01 01:03:39 +00:00

Merge branch 'main' into km/auto-kdf

This commit is contained in:
Bernd Schoolmann
2025-11-03 12:48:04 +01:00
561 changed files with 15239 additions and 3926 deletions

View File

@@ -0,0 +1,25 @@
Please review this pull request with a focus on:
- Code quality and best practices
- Potential bugs or issues
- Security implications
- Performance considerations
Note: The PR branch is already checked out in the current working directory.
Provide a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.

8
.github/CODEOWNERS vendored
View File

@@ -30,7 +30,7 @@ libs/common/src/auth @bitwarden/team-auth-dev
apps/browser/src/tools @bitwarden/team-tools-dev
apps/cli/src/tools @bitwarden/team-tools-dev
apps/desktop/src/app/tools @bitwarden/team-tools-dev
apps/desktop/desktop_native/bitwarden_chromium_importer @bitwarden/team-tools-dev
apps/desktop/desktop_native/chromium_importer @bitwarden/team-tools-dev
apps/web/src/app/tools @bitwarden/team-tools-dev
libs/angular/src/tools @bitwarden/team-tools-dev
libs/common/src/models/export @bitwarden/team-tools-dev
@@ -174,6 +174,7 @@ apps/desktop/src/key-management @bitwarden/team-key-management-dev
apps/web/src/app/key-management @bitwarden/team-key-management-dev
apps/browser/src/key-management @bitwarden/team-key-management-dev
apps/cli/src/key-management @bitwarden/team-key-management-dev
bitwarden_license/bit-web/src/app/key-management @bitwarden/team-key-management-dev
libs/key-management @bitwarden/team-key-management-dev
libs/key-management-ui @bitwarden/team-key-management-dev
libs/common/src/key-management @bitwarden/team-key-management-dev
@@ -223,3 +224,8 @@ apps/web/src/locales/en/messages.json
**/jest.config.js @bitwarden/team-platform-dev
**/project.jsons @bitwarden/team-platform-dev
libs/pricing @bitwarden/team-billing-dev
# Claude related files
.claude/ @bitwarden/team-ai-sme
.github/workflows/respond.yml @bitwarden/team-ai-sme
.github/workflows/review-code.yml @bitwarden/team-ai-sme

View File

@@ -139,6 +139,7 @@
"@babel/core",
"@babel/preset-env",
"@bitwarden/sdk-internal",
"@bitwarden/commercial-sdk-internal",
"@electron/fuses",
"@electron/notarize",
"@electron/rebuild",

View File

@@ -219,12 +219,14 @@ jobs:
archive_name_prefix: ""
npm_command_prefix: "dist:"
readable: "open source license"
type: "oss"
- build_prefix: "bit-"
artifact_prefix: "bit-"
source_archive_name_prefix: "bit-"
archive_name_prefix: "bit-"
npm_command_prefix: "dist:bit:"
readable: "commercial license"
type: "commercial"
browser:
- name: "chrome"
npm_command_suffix: "chrome"
@@ -279,6 +281,11 @@ jobs:
run: npm ci
working-directory: browser-source/
- name: Remove commercial packages
if: ${{ matrix.license_type.type == 'oss' }}
run: rm -rf node_modules/@bitwarden/commercial-sdk-internal
working-directory: browser-source/
- name: Download SDK artifacts
if: ${{ inputs.sdk_branch != '' }}
uses: bitwarden/gh-actions/download-artifacts@main
@@ -350,11 +357,13 @@ jobs:
archive_name_prefix: ""
npm_command_prefix: "dist:"
readable: "open source license"
type: "oss"
- build_prefix: "bit-"
artifact_prefix: "bit-"
archive_name_prefix: "bit-"
npm_command_prefix: "dist:bit:"
readable: "commercial license"
type: "commercial"
env:
_BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -461,6 +470,11 @@ jobs:
run: npm ci
working-directory: ./
- name: Remove commercial packages
if: ${{ matrix.license_type.type == 'oss' }}
run: rm -rf node_modules/@bitwarden/commercial-sdk-internal
working-directory: ./
- name: Download SDK Artifacts
if: ${{ inputs.sdk_branch != '' }}
uses: bitwarden/gh-actions/download-artifacts@main

View File

@@ -98,8 +98,8 @@ jobs:
]
license_type:
[
{ build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" },
{ build_prefix: "bit", artifact_prefix: "", readable: "commercial license" }
{ type: "oss", build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" },
{ type: "commercial", build_prefix: "bit", artifact_prefix: "", readable: "commercial license" }
]
runs-on: ${{ matrix.os.distro }}
needs: setup
@@ -140,6 +140,11 @@ jobs:
run: npm ci
working-directory: ./
- name: Remove commercial packages
if: ${{ matrix.license_type.type == 'oss' }}
run: rm -rf node_modules/@bitwarden/commercial-sdk-internal
working-directory: ./
- name: Download SDK Artifacts
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/download-artifacts@main
@@ -291,8 +296,8 @@ jobs:
matrix:
license_type:
[
{ build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" },
{ build_prefix: "bit", artifact_prefix: "", readable: "commercial license" }
{ type: "oss", build_prefix: "oss", artifact_prefix: "-oss", readable: "open source license" },
{ type: "commercial", build_prefix: "bit", artifact_prefix: "", readable: "commercial license" }
]
runs-on: windows-2022
permissions:
@@ -410,6 +415,11 @@ jobs:
run: npm ci
working-directory: ./
- name: Remove commercial packages
if: ${{ matrix.license_type.type == 'oss' }}
run: Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "node_modules/@bitwarden/commercial-sdk-internal"
working-directory: ./
- name: Download SDK Artifacts
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/download-artifacts@main

View File

@@ -99,34 +99,43 @@ jobs:
matrix:
include:
- artifact_name: selfhosted-open-source
license_type: "oss"
image_name: web-oss
npm_command: dist:oss:selfhost
- artifact_name: cloud-COMMERCIAL
license_type: "commercial"
image_name: web-cloud
npm_command: dist:bit:cloud
- artifact_name: selfhosted-COMMERCIAL
license_type: "commercial"
image_name: web
npm_command: dist:bit:selfhost
- artifact_name: selfhosted-DEV
license_type: "commercial"
image_name: web
npm_command: build:bit:selfhost:dev
git_metadata: true
- artifact_name: cloud-QA
license_type: "commercial"
image_name: web-qa-cloud
npm_command: build:bit:qa
git_metadata: true
- artifact_name: ee
license_type: "commercial"
image_name: web-ee
npm_command: build:bit:ee
git_metadata: true
- artifact_name: cloud-euprd
license_type: "commercial"
image_name: web-euprd
npm_command: build:bit:euprd
- artifact_name: cloud-euqa
license_type: "commercial"
image_name: web-euqa
npm_command: build:bit:euqa
git_metadata: true
- artifact_name: cloud-usdev
license_type: "commercial"
image_name: web-usdev
npm_command: build:bit:usdev
git_metadata: true
@@ -269,6 +278,7 @@ jobs:
build-args: |
NODE_VERSION=${{ env._NODE_VERSION }}
NPM_COMMAND=${{ matrix.npm_command }}
LICENSE_TYPE=${{ matrix.license_type }}
context: .
file: apps/web/Dockerfile
load: true

View File

@@ -75,6 +75,9 @@ jobs:
- name: Lint unowned dependencies
run: npm run lint:dep-ownership
- name: Lint sdk-internal versions
run: npm run lint:sdk-internal-versions
- name: Run linter
run: npm run lint
@@ -114,3 +117,12 @@ jobs:
- name: Cargo sort
working-directory: ./apps/desktop/desktop_native
run: cargo sort --workspace --check
- name: Install cargo-deny
uses: taiki-e/install-action@v2
with:
tool: cargo-deny
- name: Run cargo deny
working-directory: ./apps/desktop/desktop_native
run: cargo deny --log-level error --all-features check all

28
.github/workflows/respond.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Respond
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
permissions: {}
jobs:
respond:
name: Respond
uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: write
id-token: write
issues: write
pull-requests: write

View File

@@ -1,124 +1,20 @@
name: Review code
name: Code Review
on:
pull_request:
types: [opened, synchronize, reopened]
types: [opened, synchronize, reopened, ready_for_review]
permissions: {}
jobs:
review:
name: Review
runs-on: ubuntu-24.04
uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
contents: read
id-token: write
pull-requests: write
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Check for Vault team changes
id: check_changes
run: |
# Ensure we have the base branch
git fetch origin ${{ github.base_ref }}
echo "Comparing changes between origin/${{ github.base_ref }} and HEAD"
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
if [ -z "$CHANGED_FILES" ]; then
echo "Zero files changed"
echo "vault_team_changes=false" >> $GITHUB_OUTPUT
exit 0
fi
# Handle variations in spacing and multiple teams
VAULT_PATTERNS=$(grep -E "@bitwarden/team-vault-dev(\s|$)" .github/CODEOWNERS 2>/dev/null | awk '{print $1}')
if [ -z "$VAULT_PATTERNS" ]; then
echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS"
echo "vault_team_changes=false" >> $GITHUB_OUTPUT
exit 0
fi
vault_team_changes=false
for pattern in $VAULT_PATTERNS; do
echo "Checking pattern: $pattern"
# Handle **/directory patterns
if [[ "$pattern" == "**/"* ]]; then
# Remove the **/ prefix
dir_pattern="${pattern#\*\*/}"
# Check if any file contains this directory in its path
if echo "$CHANGED_FILES" | grep -qE "(^|/)${dir_pattern}(/|$)"; then
vault_team_changes=true
echo "✅ Found files matching pattern: $pattern"
echo "$CHANGED_FILES" | grep -E "(^|/)${dir_pattern}(/|$)" | sed 's/^/ - /'
break
fi
else
# Handle other patterns (shouldn't happen based on your CODEOWNERS)
if echo "$CHANGED_FILES" | grep -q "$pattern"; then
vault_team_changes=true
echo "✅ Found files matching pattern: $pattern"
echo "$CHANGED_FILES" | grep "$pattern" | sed 's/^/ - /'
break
fi
fi
done
echo "vault_team_changes=$vault_team_changes" >> $GITHUB_OUTPUT
if [ "$vault_team_changes" = "true" ]; then
echo ""
echo "✅ Vault team changes detected - proceeding with review"
else
echo ""
echo "❌ No Vault team changes detected - skipping review"
fi
- name: Review with Claude Code
if: steps.check_changes.outputs.vault_team_changes == 'true'
uses: anthropics/claude-code-action@ac1a3207f3f00b4a37e2f3a6f0935733c7c64651 # v1.0.11
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
track_progress: true
use_sticky_comment: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
TITLE: ${{ github.event.pull_request.title }}
BODY: ${{ github.event.pull_request.body }}
AUTHOR: ${{ github.event.pull_request.user.login }}
COMMIT: ${{ github.event.pull_request.head.sha }}
Please review this pull request with a focus on:
- Code quality and best practices
- Potential bugs or issues
- Security implications
- Performance considerations
Note: The PR branch is already checked out in the current working directory.
Provide a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.
claude_args: |
--allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*)"

View File

@@ -73,7 +73,7 @@ jobs:
- name: Trigger test-all workflow in browser-interactions-testing
if: steps.changed-files.outputs.monitored == 'true'
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0
with:
token: ${{ steps.app-token.outputs.token }}
repository: "bitwarden/browser-interactions-testing"

1
.gitignore vendored
View File

@@ -10,7 +10,6 @@ Thumbs.db
*.launch
.settings/
*.sublime-workspace
.claude
.serena
# Visual Studio Code

2
.npmrc
View File

@@ -1,4 +1,4 @@
save-exact=true
# Increase available heap size to avoid running out of memory when compiling.
# This applies to all npm scripts in this repository.
node-options=--max-old-space-size=8192
node-options=--max-old-space-size=8192

View File

@@ -588,6 +588,9 @@
"view": {
"message": "View"
},
"viewAll": {
"message": "View all"
},
"viewLogin": {
"message": "View login"
},
@@ -1028,6 +1031,18 @@
"editedItem": {
"message": "Item saved"
},
"savedWebsite": {
"message": "Saved website"
},
"savedWebsites": {
"message": "Saved websites ( $COUNT$ )",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"deleteItemConfirmation": {
"message": "Do you really want to send to the trash?"
},
@@ -1694,9 +1709,30 @@
"turnOffAutofill": {
"message": "Turn off autofill"
},
"confirmAutofill": {
"message": "Confirm autofill"
},
"confirmAutofillDesc": {
"message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site."
},
"showInlineMenuLabel": {
"message": "Show autofill suggestions on form fields"
},
"howDoesBitwardenProtectFromPhishing": {
"message": "How does Bitwarden protect your data from phishing?"
},
"currentWebsite": {
"message": "Current website"
},
"autofillAndAddWebsite": {
"message": "Autofill and add this website"
},
"autofillWithoutAdding": {
"message": "Autofill without adding"
},
"doNotAutofill": {
"message": "Do not autofill"
},
"showInlineMenuIdentitiesLabel": {
"message": "Display identities as suggestions"
},
@@ -3258,6 +3294,9 @@
"decryptionError": {
"message": "Decryption error"
},
"errorGettingAutoFillData": {
"message": "Error getting autofill data"
},
"couldNotDecryptVaultItemsBelow": {
"message": "Bitwarden could not decrypt the vault item(s) listed below."
},
@@ -4029,6 +4068,15 @@
"message": "Autofill on page load set to use default setting.",
"description": "Toast message for informing the user that autofill on page load has been set to the default setting."
},
"cannotAutofill": {
"message": "Cannot autofill"
},
"cannotAutofillExactMatch": {
"message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item."
},
"okay": {
"message": "Okay"
},
"toggleSideNavigation": {
"message": "Toggle side navigation"
},
@@ -5739,5 +5787,11 @@
"settingDisabledByPolicy": {
"message": "This setting is disabled by your organization's policy.",
"description": "This hint text is displayed when a user setting is disabled due to an organization policy."
},
"zipPostalCodeLabel": {
"message": "ZIP / Postal code"
},
"cardNumberLabel": {
"message": "Card number"
}
}

View File

@@ -0,0 +1,8 @@
// Full routes that auth owns in the extension
export const AuthExtensionRoute = Object.freeze({
AccountSecurity: "account-security",
DeviceManagement: "device-management",
AccountSwitcher: "account-switcher",
} as const);
export type AuthExtensionRoute = (typeof AuthExtensionRoute)[keyof typeof AuthExtensionRoute];

View File

@@ -0,0 +1 @@
export * from "./auth-extension-route.constant";

View File

@@ -5,55 +5,5 @@
<title>Bitwarden</title>
<meta charset="utf-8" />
</head>
<body>
<div id="notification-bar-outer-wrapper" class="outer-wrapper">
<div class="logo-wrapper">
<a href="https://vault.bitwarden.com" target="_blank" id="logo-link" rel="noreferrer">
<img id="logo" alt="Bitwarden" />
</a>
</div>
<div id="content"></div>
<div class="notification-close">
<button type="button" class="neutral" id="close-button">
<svg id="close" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none">
<path
d="M14.431 13.57 8.865 8.173a.388.388 0 0 1 0-.559l5.498-5.33a.388.388 0 0 0-.005-.553.415.415 0 0 0-.572-.006l-5.498 5.33a.416.416 0 0 1-.577 0L2.196 1.72a.403.403 0 0 0-.29-.12.422.422 0 0 0-.292.115.395.395 0 0 0-.12.283.386.386 0 0 0 .125.28l5.515 5.338a.388.388 0 0 1 0 .559L1.56 13.568a.397.397 0 0 0-.12.28c0 .105.044.205.12.28a.416.416 0 0 0 .578-.001l5.574-5.395a.416.416 0 0 1 .577 0l5.567 5.395a.422.422 0 0 0 .582.005.398.398 0 0 0 .12-.282.387.387 0 0 0-.125-.281Z"
/>
</svg>
</button>
</div>
</div>
</body>
<template id="template-add">
<div class="inner-wrapper">
<div id="add-text" class="notification-body"></div>
<div class="add-change-cipher-buttons notification-actions">
<button type="button" id="never-save" class="link"></button>
<select id="select-folder"></select>
<button type="button" id="add-edit" class="secondary"></button>
<button type="button" id="add-save" class="primary"></button>
</div>
</div>
</template>
<template id="template-change">
<div class="inner-wrapper">
<div id="change-text" class="notification-body"></div>
<div class="add-change-cipher-buttons notification-actions">
<button type="button" id="change-edit" class="secondary"></button>
<button type="button" id="change-save" class="primary"></button>
</div>
</div>
</template>
<template id="template-unlock">
<div class="inner-wrapper">
<div id="unlock-text" class="notification-body"></div>
<div class="notification-actions">
<button type="button" id="unlock-vault" class="primary"></button>
</div>
</div>
</template>
<body></body>
</html>

View File

@@ -1,304 +0,0 @@
@import "../shared/styles/variables";
body {
margin: 0;
padding: 0;
height: 100%;
font-size: 14px;
line-height: 16px;
font-family: $font-family-sans-serif;
@include themify($themes) {
color: themed("textColor");
background-color: themed("backgroundColor");
}
}
img {
margin: 0;
padding: 0;
border: 0;
}
button,
select {
font-size: $font-size-base;
font-family: $font-family-sans-serif;
}
.outer-wrapper {
display: block;
position: relative;
padding: 8px;
min-height: 42px;
border: 1px solid transparent;
border-bottom: 2px solid transparent;
border-radius: 4px;
box-sizing: border-box;
@include themify($themes) {
border-color: themed("borderColor");
border-bottom-color: themed("primaryColor");
}
&.success-event {
@include themify($themes) {
border-bottom-color: themed("successColor");
}
}
&.error-event {
@include themify($themes) {
border-bottom-color: themed("errorColor");
}
}
}
.inner-wrapper {
display: grid;
grid-template-columns: auto max-content;
}
.outer-wrapper > *,
.inner-wrapper > * {
align-self: center;
}
#logo {
width: 24px;
height: 24px;
display: block;
}
.logo-wrapper {
position: absolute;
top: 8px;
left: 10px;
overflow: hidden;
}
#close-button {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin-right: 10px;
padding: 0;
&:hover {
@include themify($themes) {
border-color: rgba(themed("textColor"), 0.2);
background-color: rgba(themed("textColor"), 0.2);
}
}
}
#close {
display: block;
width: 16px;
height: 16px;
> path {
@include themify($themes) {
fill: themed("textColor");
}
}
}
.notification-close {
position: absolute;
top: 6px;
right: 6px;
}
#content .inner-wrapper {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
.notification-body {
width: 100%;
padding: 4px 38px 24px 42px;
font-weight: 400;
}
.notification-actions {
display: flex;
width: 100%;
align-items: stretch;
justify-content: flex-end;
#never-save {
margin-right: auto;
padding: 0;
font-size: 16px;
font-weight: 500;
letter-spacing: 0.5px;
}
#select-folder {
width: 125px;
margin-right: 6px;
font-size: 12px;
appearance: none;
background-repeat: no-repeat;
background-position: center right 4px;
background-size: 16px;
@include themify($themes) {
color: themed("mutedTextColor");
border-color: themed("mutedTextColor");
}
&:not([disabled]) {
display: block;
}
}
.primary,
.secondary {
font-size: 12px;
}
.secondary {
margin-right: 6px;
border-width: 1px;
}
.primary {
margin-right: 2px;
}
&.success-message,
&.error-message {
padding: 4px 36px 6px 42px;
}
}
}
button {
padding: 4px 8px;
border-radius: $border-radius;
border: 1px solid transparent;
cursor: pointer;
}
button.primary:not(.neutral) {
@include themify($themes) {
background-color: themed("primaryColor");
color: themed("textContrast");
border-color: themed("primaryColor");
}
&:hover {
@include themify($themes) {
background-color: darken(themed("primaryColor"), 1.5%);
color: darken(themed("textContrast"), 6%);
}
}
}
button.secondary:not(.neutral) {
@include themify($themes) {
background-color: themed("backgroundColor");
color: themed("mutedTextColor");
border-color: themed("mutedTextColor");
}
&:hover {
@include themify($themes) {
background-color: themed("backgroundOffsetColor");
color: darken(themed("mutedTextColor"), 6%);
}
}
}
button.link,
button.neutral {
@include themify($themes) {
background-color: transparent;
color: themed("primaryColor");
}
&:hover {
text-decoration: underline;
@include themify($themes) {
color: darken(themed("primaryColor"), 6%);
}
}
}
select {
padding: 4px 6px;
border: 1px solid #000000;
border-radius: $border-radius;
@include themify($themes) {
color: themed("textColor");
background-color: themed("inputBackgroundColor");
border-color: themed("inputBorderColor");
}
}
.success-message {
display: flex;
align-items: center;
justify-content: center;
@include themify($themes) {
color: themed("successColor");
}
svg {
margin-right: 8px;
path {
@include themify($themes) {
fill: themed("successColor");
}
}
}
}
.error-message {
@include themify($themes) {
color: themed("errorColor");
}
}
.success-event,
.error-event {
.notification-body {
display: none;
}
}
@media screen and (max-width: 768px) {
#select-folder {
display: none;
}
}
@media print {
body {
display: none;
}
}
.theme_light {
#content .inner-wrapper {
#select-folder {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nMTYnIGhlaWdodD0nMTYnIGZpbGw9J25vbmUnPjxwYXRoIHN0cm9rZT0nIzIxMjUyOScgZD0nbTUgNiAzIDMgMy0zJy8+PC9zdmc+");
}
}
}
.theme_dark {
#content .inner-wrapper {
#select-folder {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxNicgaGVpZ2h0PScxNicgZmlsbD0nbm9uZSc+PHBhdGggc3Ryb2tlPScjZmZmZmZmJyBkPSdtNSA2IDMgMyAzLTMnLz48L3N2Zz4=");
}
}
}

View File

@@ -187,8 +187,6 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
const notificationTestId = getNotificationTestId(notificationType);
appendHeaderMessageToTitle(headerMessage);
document.body.innerHTML = "";
if (isVaultLocked) {
const notificationConfig = {
...notificationBarIframeInitData,

View File

@@ -1,10 +1,7 @@
import { AutofillOverlayElement } from "../../../../enums/autofill-overlay.enum";
import { AutofillInlineMenuButton } from "./autofill-inline-menu-button";
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./button.scss");
import "./button.css";
(function () {
globalThis.customElements.define(AutofillOverlayElement.Button, AutofillInlineMenuButton);

View File

@@ -1,5 +1,3 @@
@import "../../../../shared/styles/variables";
* {
box-sizing: border-box;
}
@@ -27,10 +25,10 @@ autofill-inline-menu-button {
border: none;
background: transparent;
cursor: pointer;
.inline-menu-button-svg-icon {
display: block;
width: 100%;
height: auto;
}
}
.inline-menu-button .inline-menu-button-svg-icon {
display: block;
width: 100%;
height: auto;
}

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`OverlayNotificationsContentService opening the notification bar creates the notification bar elements and appends them to the body 1`] = `
exports[`OverlayNotificationsContentService opening the notification bar creates the notification bar elements and appends them to the body within a shadow root 1`] = `
<div
id="bit-notification-bar"
style="height: 400px !important; width: 430px !important; max-width: calc(100% - 20px) !important; min-height: initial !important; top: 10px !important; right: 0px !important; padding: 0px !important; position: fixed !important; z-index: 2147483647 !important; visibility: visible !important; border-radius: 4px !important; background-color: transparent !important; overflow: hidden !important; transition: box-shadow 0.15s ease !important; transition-delay: 0.15s;"

View File

@@ -16,10 +16,13 @@ describe("OverlayNotificationsContentService", () => {
let domElementVisibilityService: DomElementVisibilityService;
let autofillInit: AutofillInit;
let bodyAppendChildSpy: jest.SpyInstance;
let postMessageSpy: jest.SpyInstance<void, Parameters<Window["postMessage"]>>;
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(utils, "sendExtensionMessage").mockImplementation(jest.fn());
jest.spyOn(HTMLIFrameElement.prototype, "contentWindow", "get").mockReturnValue(window);
postMessageSpy = jest.spyOn(window, "postMessage").mockImplementation(jest.fn());
domQueryService = mock<DomQueryService>();
domElementVisibilityService = new DomElementVisibilityService();
overlayNotificationsContentService = new OverlayNotificationsContentService();
@@ -48,7 +51,7 @@ describe("OverlayNotificationsContentService", () => {
});
it("closes the notification bar if the notification bar type has changed", async () => {
overlayNotificationsContentService["currentNotificationBarType"] = "add";
overlayNotificationsContentService["currentNotificationBarType"] = NotificationType.AddLogin;
const closeNotificationBarSpy = jest.spyOn(
overlayNotificationsContentService as any,
"closeNotificationBar",
@@ -66,7 +69,7 @@ describe("OverlayNotificationsContentService", () => {
expect(closeNotificationBarSpy).toHaveBeenCalled();
});
it("creates the notification bar elements and appends them to the body", async () => {
it("creates the notification bar elements and appends them to the body within a shadow root", async () => {
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
@@ -77,6 +80,13 @@ describe("OverlayNotificationsContentService", () => {
await flushPromises();
expect(overlayNotificationsContentService["notificationBarElement"]).toMatchSnapshot();
const rootElement = overlayNotificationsContentService["notificationBarRootElement"];
expect(bodyAppendChildSpy).toHaveBeenCalledWith(rootElement);
expect(rootElement?.tagName).toBe("BIT-NOTIFICATION-BAR-ROOT");
expect(document.getElementById("bit-notification-bar")).toBeNull();
expect(document.querySelector("#bit-notification-bar-iframe")).toBeNull();
});
it("sets up a slide in animation when the notification is fresh", async () => {
@@ -116,6 +126,8 @@ describe("OverlayNotificationsContentService", () => {
});
it("sends an initialization message to the notification bar iframe", async () => {
const addEventListenerSpy = jest.spyOn(globalThis, "addEventListener");
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
@@ -124,10 +136,7 @@ describe("OverlayNotificationsContentService", () => {
},
});
await flushPromises();
const postMessageSpy = jest.spyOn(
overlayNotificationsContentService["notificationBarIframeElement"].contentWindow,
"postMessage",
);
expect(addEventListenerSpy).toHaveBeenCalledWith("message", expect.any(Function));
globalThis.dispatchEvent(
new MessageEvent("message", {
@@ -142,7 +151,6 @@ describe("OverlayNotificationsContentService", () => {
);
await flushPromises();
expect(postMessageSpy).toHaveBeenCalledTimes(1);
expect(postMessageSpy).toHaveBeenCalledWith(
{
command: "initNotificationBar",
@@ -158,7 +166,7 @@ describe("OverlayNotificationsContentService", () => {
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
type: NotificationType.ChangePassword,
typeData: mock<NotificationTypeData>(),
},
});
@@ -242,20 +250,15 @@ describe("OverlayNotificationsContentService", () => {
});
it("sends a message to the notification bar iframe indicating that the save attempt completed", () => {
jest.spyOn(
overlayNotificationsContentService["notificationBarIframeElement"].contentWindow,
"postMessage",
);
sendMockExtensionMessage({
command: "saveCipherAttemptCompleted",
data: { error: undefined },
});
expect(
overlayNotificationsContentService["notificationBarIframeElement"].contentWindow
.postMessage,
).toHaveBeenCalledWith({ command: "saveCipherAttemptCompleted", error: undefined }, "*");
expect(postMessageSpy).toHaveBeenCalledWith(
{ command: "saveCipherAttemptCompleted", error: undefined },
"*",
);
});
});
@@ -271,9 +274,10 @@ describe("OverlayNotificationsContentService", () => {
await flushPromises();
});
it("triggers a closure of the notification bar", () => {
it("triggers a closure of the notification bar and cleans up all shadow DOM elements", () => {
overlayNotificationsContentService.destroy();
expect(overlayNotificationsContentService["notificationBarRootElement"]).toBeNull();
expect(overlayNotificationsContentService["notificationBarElement"]).toBeNull();
expect(overlayNotificationsContentService["notificationBarIframeElement"]).toBeNull();
});

View File

@@ -17,8 +17,10 @@ import {
export class OverlayNotificationsContentService
implements OverlayNotificationsContentServiceInterface
{
private notificationBarRootElement: HTMLElement | null = null;
private notificationBarElement: HTMLElement | null = null;
private notificationBarIframeElement: HTMLIFrameElement | null = null;
private notificationBarShadowRoot: ShadowRoot | null = null;
private currentNotificationBarType: NotificationType | null = null;
private notificationBarContainerStyles: Partial<CSSStyleDeclaration> = {
height: "400px",
@@ -158,12 +160,12 @@ export class OverlayNotificationsContentService
* @private
*/
private openNotificationBar(initData: NotificationBarIframeInitData) {
if (!this.notificationBarElement && !this.notificationBarIframeElement) {
if (!this.notificationBarRootElement && !this.notificationBarIframeElement) {
this.createNotificationBarIframeElement(initData);
this.createNotificationBarElement();
this.setupInitNotificationBarMessageListener(initData);
globalThis.document.body.appendChild(this.notificationBarElement);
globalThis.document.body.appendChild(this.notificationBarRootElement);
}
}
@@ -213,15 +215,25 @@ export class OverlayNotificationsContentService
};
/**
* Creates the container for the notification bar iframe.
* Creates the container for the notification bar iframe with shadow DOM.
*/
private createNotificationBarElement() {
if (this.notificationBarIframeElement) {
this.notificationBarRootElement = globalThis.document.createElement(
"bit-notification-bar-root",
);
this.notificationBarShadowRoot = this.notificationBarRootElement.attachShadow({
mode: "closed",
delegatesFocus: true,
});
this.notificationBarElement = globalThis.document.createElement("div");
this.notificationBarElement.id = "bit-notification-bar";
setElementStyles(this.notificationBarElement, this.notificationBarContainerStyles, true);
this.notificationBarShadowRoot.appendChild(this.notificationBarElement);
this.notificationBarElement.appendChild(this.notificationBarIframeElement);
}
}
@@ -258,7 +270,7 @@ export class OverlayNotificationsContentService
* @param closedByUserAction - Whether the notification bar was closed by the user.
*/
private closeNotificationBar(closedByUserAction: boolean = false) {
if (!this.notificationBarElement && !this.notificationBarIframeElement) {
if (!this.notificationBarRootElement && !this.notificationBarIframeElement) {
return;
}
@@ -267,6 +279,9 @@ export class OverlayNotificationsContentService
this.notificationBarElement.remove();
this.notificationBarElement = null;
this.notificationBarShadowRoot = null;
this.notificationBarRootElement.remove();
this.notificationBarRootElement = null;
const removableNotificationTypes = new Set([
NotificationTypes.Add,

View File

@@ -26,7 +26,6 @@ const eventsToTest = [
EVENTS.CHANGE,
EVENTS.INPUT,
EVENTS.KEYDOWN,
EVENTS.KEYPRESS,
EVENTS.KEYUP,
"blur",
"click",
@@ -1044,13 +1043,13 @@ describe("InsertAutofillContentService", () => {
});
describe("simulateUserKeyboardEventInteractions", () => {
it("will trigger `keydown`, `keypress`, and `keyup` events on the passed element", () => {
it("will trigger `keydown` and `keyup` events on the passed element", () => {
const inputElement = document.querySelector('input[type="text"]') as HTMLInputElement;
jest.spyOn(inputElement, "dispatchEvent");
insertAutofillContentService["simulateUserKeyboardEventInteractions"](inputElement);
[EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP].forEach((eventName) => {
[EVENTS.KEYDOWN, EVENTS.KEYUP].forEach((eventName) => {
expect(inputElement.dispatchEvent).toHaveBeenCalledWith(
new KeyboardEvent(eventName, { bubbles: true }),
);

View File

@@ -136,7 +136,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
setTimeout(() => {
this.autofillInsertActions[action]({ opid, value });
resolve();
}, delayActionsInMilliseconds * actionIndex),
}, delayActionsInMilliseconds),
);
};
@@ -349,7 +349,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
* @private
*/
private simulateUserKeyboardEventInteractions(element: FormFieldElement): void {
const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYPRESS, EVENTS.KEYUP];
const simulatedKeyboardEvents = [EVENTS.KEYDOWN, EVENTS.KEYUP];
for (let index = 0; index < simulatedKeyboardEvents.length; index++) {
element.dispatchEvent(new KeyboardEvent(simulatedKeyboardEvents[index], { bubbles: true }));
}

View File

@@ -21,6 +21,8 @@ import {
import { PhishingDetectionService } from "../services/phishing-detection.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "dirt-phishing-warning",
standalone: true,

View File

@@ -6,6 +6,8 @@ import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule, LinkModule } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "dirt-phishing-protected-by",
standalone: true,

View File

@@ -7,12 +7,16 @@ import { IconButtonModule } from "@bitwarden/components";
import BrowserPopupUtils from "../../browser/browser-popup-utils";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-pop-out",
templateUrl: "pop-out.component.html",
imports: [CommonModule, JslibModule, IconButtonModule],
})
export class PopOutComponent implements OnInit {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() show = true;
constructor(private platformUtilsService: PlatformUtilsService) {}

View File

@@ -15,23 +15,9 @@ export class BrowserFileDownloadService implements FileDownloadService {
download(request: FileDownloadRequest): void {
const builder = new FileDownloadBuilder(request);
if (BrowserApi.isSafariApi) {
let data: BlobPart = null;
if (builder.blobOptions.type === "text/plain" && typeof request.blobData === "string") {
data = request.blobData;
} else {
data = Utils.fromBufferToB64(request.blobData as ArrayBuffer);
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
SafariApp.sendMessageToApp(
"downloadFile",
JSON.stringify({
blobData: data,
blobOptions: request.blobOptions,
fileName: request.fileName,
}),
true,
);
// Handle Safari download asynchronously to allow Blob conversion
// This function can't be async because the interface is not async
void this.downloadSafari(request, builder);
} else {
const a = window.document.createElement("a");
a.href = URL.createObjectURL(builder.blob);
@@ -41,4 +27,31 @@ export class BrowserFileDownloadService implements FileDownloadService {
window.document.body.removeChild(a);
}
}
private async downloadSafari(
request: FileDownloadRequest,
builder: FileDownloadBuilder,
): Promise<void> {
let data: string = null;
if (builder.blobOptions.type === "text/plain" && typeof request.blobData === "string") {
data = request.blobData;
} else if (request.blobData instanceof Blob) {
// Convert Blob to ArrayBuffer first, then to Base64
const arrayBuffer = await request.blobData.arrayBuffer();
data = Utils.fromBufferToB64(arrayBuffer);
} else {
// Already an ArrayBuffer
data = Utils.fromBufferToB64(request.blobData as ArrayBuffer);
}
await SafariApp.sendMessageToApp(
"downloadFile",
JSON.stringify({
blobData: data,
blobOptions: request.blobOptions,
fileName: request.fileName,
}),
true,
);
}
}

View File

@@ -13,6 +13,8 @@ import { PopupRouterCacheService, popupRouterCacheGuard } from "./popup-router-c
const flushPromises = async () => await new Promise(process.nextTick);
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,

View File

@@ -19,12 +19,16 @@ import {
import { PopupViewCacheService } from "./popup-view-cache.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,
})
export class EmptyComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
standalone: false,

View File

@@ -2,6 +2,7 @@ import { Injectable, NgModule } from "@angular/core";
import { ActivatedRouteSnapshot, RouteReuseStrategy, RouterModule, Routes } from "@angular/router";
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
import { AuthRoute } from "@bitwarden/angular/auth/constants";
import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/environment-selector/environment-selector.component";
import {
activeAuthGuard,
@@ -45,6 +46,7 @@ import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/co
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant";
import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component";
@@ -148,7 +150,7 @@ const routes: Routes = [
component: ExtensionAnonLayoutWrapperComponent,
children: [
{
path: "authentication-timeout",
path: AuthRoute.AuthenticationTimeout,
canActivate: [unauthGuardFn(unauthRouteOverrides)],
children: [
{
@@ -167,7 +169,7 @@ const routes: Routes = [
],
},
{
path: "device-verification",
path: AuthRoute.NewDeviceVerification,
component: ExtensionAnonLayoutWrapperComponent,
canActivate: [unauthGuardFn(), activeAuthGuard()],
children: [{ path: "", component: NewDeviceVerificationComponent }],
@@ -259,13 +261,13 @@ const routes: Routes = [
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "account-security",
path: AuthExtensionRoute.AccountSecurity,
component: AccountSecurityComponent,
canActivate: [authGuard],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "device-management",
path: AuthExtensionRoute.DeviceManagement,
component: ExtensionDeviceManagementComponent,
canActivate: [authGuard],
data: { elevation: 1 } satisfies RouteDataProperties,
@@ -341,7 +343,7 @@ const routes: Routes = [
component: ExtensionAnonLayoutWrapperComponent,
children: [
{
path: "signup",
path: AuthRoute.SignUp,
canActivate: [unauthGuardFn()],
data: {
elevation: 1,
@@ -361,13 +363,13 @@ const routes: Routes = [
component: RegistrationStartSecondaryComponent,
outlet: "secondary",
data: {
loginRoute: "/login",
loginRoute: `/${AuthRoute.Login}`,
} satisfies RegistrationStartSecondaryComponentData,
},
],
},
{
path: "finish-signup",
path: AuthRoute.FinishSignUp,
canActivate: [unauthGuardFn()],
data: {
pageIcon: LockIcon,
@@ -382,7 +384,7 @@ const routes: Routes = [
],
},
{
path: "set-initial-password",
path: AuthRoute.SetInitialPassword,
canActivate: [authGuard],
component: SetInitialPasswordComponent,
data: {
@@ -390,7 +392,7 @@ const routes: Routes = [
} satisfies RouteDataProperties,
},
{
path: "login",
path: AuthRoute.Login,
canActivate: [unauthGuardFn(unauthRouteOverrides), IntroCarouselGuard],
data: {
pageIcon: VaultIcon,
@@ -411,7 +413,7 @@ const routes: Routes = [
],
},
{
path: "login-with-passkey",
path: AuthRoute.LoginWithPasskey,
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageIcon: TwoFactorAuthSecurityKeyIcon,
@@ -434,7 +436,7 @@ const routes: Routes = [
],
},
{
path: "sso",
path: AuthRoute.Sso,
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageIcon: VaultIcon,
@@ -456,7 +458,7 @@ const routes: Routes = [
],
},
{
path: "login-with-device",
path: AuthRoute.LoginWithDevice,
canActivate: [redirectToVaultIfUnlockedGuard()],
data: {
pageIcon: DevicesIcon,
@@ -479,7 +481,7 @@ const routes: Routes = [
],
},
{
path: "hint",
path: AuthRoute.PasswordHint,
canActivate: [unauthGuardFn(unauthRouteOverrides)],
data: {
pageTitle: {
@@ -502,7 +504,7 @@ const routes: Routes = [
],
},
{
path: "admin-approval-requested",
path: AuthRoute.AdminApprovalRequested,
canActivate: [redirectToVaultIfUnlockedGuard()],
data: {
pageIcon: DevicesIcon,
@@ -519,7 +521,7 @@ const routes: Routes = [
children: [{ path: "", component: LoginViaAuthRequestComponent }],
},
{
path: "login-initiated",
path: AuthRoute.LoginInitiated,
canActivate: [tdeDecryptionRequiredGuard()],
data: {
pageIcon: DevicesIcon,
@@ -557,7 +559,7 @@ const routes: Routes = [
],
},
{
path: "2fa",
path: AuthRoute.TwoFactor,
canActivate: [unauthGuardFn(unauthRouteOverrides), TwoFactorAuthGuard],
children: [
{
@@ -576,7 +578,7 @@ const routes: Routes = [
} satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData,
},
{
path: "change-password",
path: AuthRoute.ChangePassword,
data: {
elevation: 1,
hideFooter: true,
@@ -698,7 +700,7 @@ const routes: Routes = [
canActivate: [authGuard, canAccessAtRiskPasswords, hasAtRiskPasswords],
},
{
path: "account-switcher",
path: AuthExtensionRoute.AccountSwitcher,
component: AccountSwitcherComponent,
data: { elevation: 4, doNotSaveUrl: true } satisfies RouteDataProperties,
},

View File

@@ -67,6 +67,8 @@ import { initPopupClosedListener } from "../platform/services/popup-view-cache-b
import { routerTransition } from "./app-routing.animations";
import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-root",
styles: [],

View File

@@ -15,6 +15,8 @@ export type DesktopSyncVerificationDialogParams = {
fingerprint: string[];
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "desktop-sync-verification-dialog.component.html",
imports: [JslibModule, ButtonModule, DialogModule],

View File

@@ -17,6 +17,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { NavButton } from "../platform/popup/layout/popup-tab-navigation.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-tabs-v2",
templateUrl: "./tabs-v2.component.html",

View File

@@ -69,8 +69,8 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
if let url = panel.url {
do {
let fileManager = FileManager.default
if !fileManager.fileExists(atPath: url.absoluteString) {
fileManager.createFile(atPath: url.absoluteString, contents: Data(),
if !fileManager.fileExists(atPath: url.path) {
fileManager.createFile(atPath: url.path, contents: Data(),
attributes: nil)
}
try data.write(to: url)

View File

@@ -10,6 +10,8 @@ import { AnchorLinkDirective, CalloutModule, BannerModule } from "@bitwarden/com
import { I18nPipe } from "@bitwarden/ui-common";
import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwarden/vault";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "vault-at-risk-password-callout",
imports: [

View File

@@ -17,6 +17,8 @@ export const AtRiskCarouselDialogResult = {
type AtRiskCarouselDialogResult = UnionOfValues<typeof AtRiskCarouselDialogResult>;
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "vault-at-risk-carousel-dialog",
templateUrl: "./at-risk-carousel-dialog.component.html",
@@ -32,6 +34,8 @@ type AtRiskCarouselDialogResult = UnionOfValues<typeof AtRiskCarouselDialogResul
export class AtRiskCarouselDialogComponent {
private dialogRef = inject(DialogRef);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected dismissBtnEnabled = signal(false);
protected async dismiss() {

View File

@@ -1,4 +1,4 @@
import { Component, Input } from "@angular/core";
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
@@ -40,26 +40,29 @@ import { AtRiskPasswordsComponent } from "./at-risk-passwords.component";
@Component({
selector: "popup-header",
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupHeaderComponent {
@Input() pageTitle: string | undefined;
@Input() backAction: (() => void) | undefined;
readonly pageTitle = input<string | undefined>(undefined);
readonly backAction = input<(() => void) | undefined>(undefined);
}
@Component({
selector: "popup-page",
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockPopupPageComponent {
@Input() loading: boolean | undefined;
readonly loading = input<boolean | undefined>(undefined);
}
@Component({
selector: "app-vault-icon",
template: `<ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MockAppIcon {
@Input() cipher: CipherView | undefined;
readonly cipher = input<CipherView | undefined>(undefined);
}
describe("AtRiskPasswordsComponent", () => {
@@ -95,11 +98,15 @@ describe("AtRiskPasswordsComponent", () => {
id: "cipher",
organizationId: "org",
name: "Item 1",
edit: true,
viewPassword: true,
} as CipherView,
{
id: "cipher2",
organizationId: "org",
name: "Item 2",
edit: true,
viewPassword: true,
} as CipherView,
]);
mockOrgs$ = new BehaviorSubject<Organization[]>([
@@ -221,6 +228,38 @@ describe("AtRiskPasswordsComponent", () => {
organizationId: "org",
name: "Item 1",
isDeleted: true,
edit: true,
viewPassword: true,
} as CipherView,
]);
const items = await firstValueFrom(component["atRiskItems$"]);
expect(items).toHaveLength(0);
});
it("should not show tasks when cipher does not have edit permission", async () => {
mockCiphers$.next([
{
id: "cipher",
organizationId: "org",
name: "Item 1",
edit: false,
viewPassword: true,
} as CipherView,
]);
const items = await firstValueFrom(component["atRiskItems$"]);
expect(items).toHaveLength(0);
});
it("should not show tasks when cipher does not have viewPassword permission", async () => {
mockCiphers$.next([
{
id: "cipher",
organizationId: "org",
name: "Item 1",
edit: true,
viewPassword: false,
} as CipherView,
]);
@@ -274,11 +313,15 @@ describe("AtRiskPasswordsComponent", () => {
id: "cipher",
organizationId: "org",
name: "Item 1",
edit: true,
viewPassword: true,
} as CipherView,
{
id: "cipher2",
organizationId: "org2",
name: "Item 2",
edit: true,
viewPassword: true,
} as CipherView,
]);

View File

@@ -1,5 +1,12 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
import {
Component,
DestroyRef,
inject,
OnInit,
signal,
ChangeDetectionStrategy,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
import {
@@ -80,6 +87,7 @@ import { AtRiskPasswordPageService } from "./at-risk-password-page.service";
],
selector: "vault-at-risk-passwords",
templateUrl: "./at-risk-passwords.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AtRiskPasswordsComponent implements OnInit {
private taskService = inject(TaskService);
@@ -156,6 +164,8 @@ export class AtRiskPasswordsComponent implements OnInit {
t.type === SecurityTaskType.UpdateAtRiskCredential &&
t.cipherId != null &&
ciphers[t.cipherId] != null &&
ciphers[t.cipherId].edit &&
ciphers[t.cipherId].viewPassword &&
!ciphers[t.cipherId].isDeleted,
)
.map((t) => ciphers[t.cipherId!]),

View File

@@ -131,6 +131,8 @@ class QueryParams {
export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-add-edit-v2",
templateUrl: "add-edit-v2.component.html",

View File

@@ -28,6 +28,8 @@ import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-assign-collections",
templateUrl: "./assign-collections.component.html",

View File

@@ -25,20 +25,30 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach
import { AttachmentsV2Component } from "./attachments-v2.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-header",
template: `<ng-content></ng-content>`,
})
class MockPopupHeaderComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() pageTitle: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() backAction: () => void;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-footer",
template: `<ng-content></ng-content>`,
})
class MockPopupFooterComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() pageTitle: string;
}

View File

@@ -17,6 +17,8 @@ import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-attachments-v2",
templateUrl: "./attachments-v2.component.html",

View File

@@ -25,6 +25,8 @@ import { CipherFormContainer } from "@bitwarden/vault";
import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils";
import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-open-attachments",
templateUrl: "./open-attachments.component.html",
@@ -39,6 +41,8 @@ import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/f
})
export class OpenAttachmentsComponent implements OnInit {
/** Cipher `id` */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) cipherId: CipherId;
/** True when the attachments window should be opened in a popout */

View File

@@ -0,0 +1,68 @@
<bit-dialog>
<span bitDialogTitle>{{ "confirmAutofill" | i18n }}</span>
<div bitDialogContent>
<p bitTypography="body2">
{{ "confirmAutofillDesc" | i18n }}
</p>
@if (savedUrls.length === 1) {
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-4 tw-font-semibold">
{{ "savedWebsite" | i18n }}
</p>
<bit-callout [title]="null" type="success" icon="bwi-globe">
<div class="tw-font-mono tw-line-clamp-1 tw-break-all" [appA11yTitle]="savedUrls[0]">
{{ savedUrls[0] }}
</div>
</bit-callout>
}
@if (savedUrls.length > 1) {
<div class="tw-flex tw-justify-between tw-items-center tw-mt-4 tw-mb-1 tw-pt-2">
<p class="tw-text-muted tw-text-xs tw-uppercase tw-font-semibold">
{{ "savedWebsites" | i18n: savedUrls.length }}
</p>
<button
*ngIf="!savedUrlsExpanded"
type="button"
bitLink
class="tw-text-sm tw-font-bold tw-cursor-pointer"
(click)="viewAllSavedUrls()"
>
{{ "viewAll" | i18n }}
</button>
</div>
<div class="tw-pt-2" [ngClass]="savedUrlsListClass">
<div class="-tw-mt-2" *ngFor="let url of savedUrls">
<bit-callout [title]="null" type="success" icon="bwi-globe">
<div class="tw-font-mono tw-line-clamp-1 tw-break-all" [appA11yTitle]="url">
{{ url }}
</div>
</bit-callout>
</div>
</div>
}
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-5 tw-font-semibold">
{{ "currentWebsite" | i18n }}
</p>
<bit-callout [title]="null" type="warning" icon="bwi-globe">
<div [appA11yTitle]="currentUrl" class="tw-font-mono tw-line-clamp-1 tw-break-all">
{{ currentUrl }}
</div>
</bit-callout>
<div class="tw-flex tw-justify-center tw-flex-col tw-gap-3 tw-mt-6">
<button type="button" bitButton buttonType="primary" (click)="autofillAndAddUrl()">
{{ "autofillAndAddWebsite" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" (click)="autofillOnly()">
{{ "autofillWithoutAdding" | i18n }}
</button>
<button
type="button"
bitLink
linkType="secondary"
(click)="close()"
class="tw-mt-2 tw-font-bold tw-text-sm tw-justify-center tw-text-center"
>
{{ "doNotAutofill" | i18n }}
</button>
</div>
</div>
</bit-dialog>

View File

@@ -0,0 +1,192 @@
import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { DIALOG_DATA, DialogRef, DialogService } from "@bitwarden/components";
import {
AutofillConfirmationDialogComponent,
AutofillConfirmationDialogResult,
AutofillConfirmationDialogParams,
} from "./autofill-confirmation-dialog.component";
describe("AutofillConfirmationDialogComponent", () => {
let fixture: ComponentFixture<AutofillConfirmationDialogComponent>;
let component: AutofillConfirmationDialogComponent;
const dialogRef = {
close: jest.fn(),
} as unknown as DialogRef;
const params: AutofillConfirmationDialogParams = {
currentUrl: "https://example.com/path?q=1",
savedUrls: ["https://one.example.com/a", "https://two.example.com/b", "not-a-url.example"],
};
beforeEach(async () => {
jest.spyOn(Utils, "getHostname").mockImplementation((value: string | null | undefined) => {
if (typeof value !== "string" || !value) {
return "";
}
try {
// handle non-URL host strings gracefully
if (!value.includes("://")) {
return value;
}
return new URL(value).hostname;
} catch {
return "";
}
});
await TestBed.configureTestingModule({
imports: [AutofillConfirmationDialogComponent],
providers: [
provideNoopAnimations(),
{ provide: DIALOG_DATA, useValue: params },
{ provide: DialogRef, useValue: dialogRef },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: DialogService, useValue: {} },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
fixture = TestBed.createComponent(AutofillConfirmationDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
jest.resetAllMocks();
});
it("normalizes currentUrl and savedUrls via Utils.getHostname", () => {
expect(Utils.getHostname).toHaveBeenCalledTimes(1 + (params.savedUrls?.length ?? 0));
// current
expect(component.currentUrl).toBe("example.com");
// saved
expect(component.savedUrls).toEqual([
"one.example.com",
"two.example.com",
"not-a-url.example",
]);
});
it("renders normalized values into the template (shallow check)", () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain("example.com");
expect(text).toContain("one.example.com");
expect(text).toContain("two.example.com");
expect(text).toContain("not-a-url.example");
});
it("emits Canceled on close()", () => {
const spy = jest.spyOn(dialogRef, "close");
component["close"]();
expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.Canceled);
});
it("emits AutofillAndUrlAdded on autofillAndAddUrl()", () => {
const spy = jest.spyOn(dialogRef, "close");
component["autofillAndAddUrl"]();
expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
});
it("emits AutofilledOnly on autofillOnly()", () => {
const spy = jest.spyOn(dialogRef, "close");
component["autofillOnly"]();
expect(spy).toHaveBeenCalledWith(AutofillConfirmationDialogResult.AutofilledOnly);
});
it("applies collapsed list gradient class by default, then clears it after viewAllSavedUrls()", () => {
const initial = component["savedUrlsListClass"];
expect(initial).toContain("gradient");
component["viewAllSavedUrls"]();
fixture.detectChanges();
const expanded = component["savedUrlsListClass"];
expect(expanded).toBe("");
});
it("handles empty savedUrls gracefully", async () => {
const newParams: AutofillConfirmationDialogParams = {
currentUrl: "https://bitwarden.com/help",
savedUrls: [],
};
const newFixture = TestBed.createComponent(AutofillConfirmationDialogComponent);
const newInstance = newFixture.componentInstance;
(newInstance as any).params = newParams;
const fresh = new AutofillConfirmationDialogComponent(
newParams as any,
dialogRef,
) as AutofillConfirmationDialogComponent;
expect(fresh.savedUrls).toEqual([]);
expect(fresh.currentUrl).toBe("bitwarden.com");
});
it("handles undefined savedUrls by defaulting to [] and empty strings from Utils.getHostname", () => {
const localParams: AutofillConfirmationDialogParams = {
currentUrl: "https://sub.domain.tld/x",
};
const local = new AutofillConfirmationDialogComponent(localParams as any, dialogRef);
expect(local.savedUrls).toEqual([]);
expect(local.currentUrl).toBe("sub.domain.tld");
});
it("filters out falsy/invalid values from Utils.getHostname in savedUrls", () => {
(Utils.getHostname as jest.Mock).mockImplementationOnce(() => "example.com");
(Utils.getHostname as jest.Mock)
.mockImplementationOnce(() => "ok.example")
.mockImplementationOnce(() => "")
.mockImplementationOnce(() => undefined as unknown as string);
const edgeParams: AutofillConfirmationDialogParams = {
currentUrl: "https://example.com",
savedUrls: ["https://ok.example", "://bad", "%%%"],
};
const edge = new AutofillConfirmationDialogComponent(edgeParams as any, dialogRef);
expect(edge.currentUrl).toBe("example.com");
expect(edge.savedUrls).toEqual(["ok.example"]);
});
it("renders one current-url callout and N saved-url callouts", () => {
const callouts = Array.from(
fixture.nativeElement.querySelectorAll("bit-callout"),
) as HTMLElement[];
expect(callouts.length).toBe(1 + params.savedUrls!.length);
});
it("renders normalized hostnames into the DOM text", () => {
const text = (fixture.nativeElement.textContent as string).replace(/\s+/g, " ");
expect(text).toContain("example.com");
expect(text).toContain("one.example.com");
expect(text).toContain("two.example.com");
});
it("shows the 'view all' button when savedUrls > 1 and hides it after click", () => {
const findViewAll = () =>
fixture.nativeElement.querySelector(
"button.tw-text-sm.tw-font-bold.tw-cursor-pointer",
) as HTMLButtonElement | null;
let btn = findViewAll();
expect(btn).toBeTruthy();
btn!.click();
fixture.detectChanges();
btn = findViewAll();
expect(btn).toBeFalsy();
expect(component.savedUrlsExpanded).toBe(true);
});
});

View File

@@ -0,0 +1,101 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, Inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
ButtonModule,
DialogService,
DialogModule,
TypographyModule,
CalloutComponent,
LinkModule,
} from "@bitwarden/components";
export interface AutofillConfirmationDialogParams {
savedUrls?: string[];
currentUrl: string;
}
export const AutofillConfirmationDialogResult = Object.freeze({
AutofillAndUrlAdded: "added",
AutofilledOnly: "autofilled",
Canceled: "canceled",
} as const);
export type AutofillConfirmationDialogResultType = UnionOfValues<
typeof AutofillConfirmationDialogResult
>;
@Component({
templateUrl: "./autofill-confirmation-dialog.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ButtonModule,
CalloutComponent,
CommonModule,
DialogModule,
LinkModule,
TypographyModule,
JslibModule,
],
})
export class AutofillConfirmationDialogComponent {
AutofillConfirmationDialogResult = AutofillConfirmationDialogResult;
currentUrl: string = "";
savedUrls: string[] = [];
savedUrlsExpanded = false;
constructor(
@Inject(DIALOG_DATA) protected params: AutofillConfirmationDialogParams,
private dialogRef: DialogRef,
) {
this.currentUrl = Utils.getHostname(params.currentUrl);
this.savedUrls =
params.savedUrls?.map((url) => Utils.getHostname(url) ?? "").filter(Boolean) ?? [];
}
protected get savedUrlsListClass(): string {
return this.savedUrlsExpanded
? ""
: `tw-relative
tw-max-h-24
tw-overflow-hidden
after:tw-pointer-events-none after:tw-content-['']
after:tw-absolute after:tw-inset-x-0 after:tw-bottom-0
after:tw-h-8 after:tw-bg-gradient-to-t
after:tw-from-background after:tw-to-transparent
`;
}
protected viewAllSavedUrls() {
this.savedUrlsExpanded = true;
}
protected close() {
this.dialogRef.close(AutofillConfirmationDialogResult.Canceled);
}
protected autofillAndAddUrl() {
this.dialogRef.close(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
}
protected autofillOnly() {
this.dialogRef.close(AutofillConfirmationDialogResult.AutofilledOnly);
}
static open(
dialogService: DialogService,
config: DialogConfig<AutofillConfirmationDialogParams>,
) {
return dialogService.open<AutofillConfirmationDialogResultType>(
AutofillConfirmationDialogComponent,
{ ...config },
);
}
}

View File

@@ -15,6 +15,8 @@ import { VaultPopupItemsService } from "../../../services/vault-popup-items.serv
import { PopupCipherViewLike } from "../../../views/popup-cipher.view";
import { VaultListItemsContainerComponent } from "../vault-list-items-container/vault-list-items-container.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
imports: [
CommonModule,
@@ -46,7 +48,7 @@ export class AutofillVaultListItemsComponent {
startWith(true), // Start with true to avoid flashing the fill button on first load
);
protected groupByType = toSignal(
protected readonly groupByType = toSignal(
this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => !hasFilter)),
);

View File

@@ -15,6 +15,8 @@ import { VaultPopupAutofillService } from "../../../services/vault-popup-autofil
const blockedURISettingsRoute = "/blocked-domains";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
imports: [
BannerModule,

View File

@@ -9,6 +9,8 @@ import { VaultCarouselModule } from "@bitwarden/vault";
import { IntroCarouselService } from "../../../services/intro-carousel.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-intro-carousel",
templateUrl: "./intro-carousel.component.html",

View File

@@ -21,6 +21,8 @@ type CipherItem = {
field: CopyAction;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-item-copy-actions",
templateUrl: "item-copy-actions.component.html",
@@ -35,6 +37,8 @@ type CipherItem = {
})
export class ItemCopyActionsComponent {
protected showQuickCopyActions$ = inject(VaultPopupCopyButtonsService).showQuickCopyActions$;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) cipher!: CipherViewLike;
protected CipherViewLikeUtils = CipherViewLikeUtils;

View File

@@ -13,9 +13,17 @@
<button type="button" bitMenuItem (click)="doAutofill()">
{{ "autofill" | i18n }}
</button>
<button type="button" bitMenuItem *ngIf="canEdit && isLogin" (click)="doAutofillAndSave()">
{{ "fillAndSave" | i18n }}
</button>
<!-- Autofill confirmation handles both 'autofill' and 'autofill and save' so no need to show both -->
@if (!(showAutofillConfirmation$ | async)) {
<button
type="button"
bitMenuItem
*ngIf="canEdit && isLogin"
(click)="doAutofillAndSave()"
>
{{ "fillAndSave" | i18n }}
</button>
}
</ng-container>
</ng-container>
<ng-container *ngIf="showViewOption">

View File

@@ -0,0 +1,241 @@
import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import {
UriMatchStrategy,
UriMatchStrategySetting,
} from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
import {
AutofillConfirmationDialogComponent,
AutofillConfirmationDialogResult,
} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component";
import { ItemMoreOptionsComponent } from "./item-more-options.component";
describe("ItemMoreOptionsComponent", () => {
let fixture: ComponentFixture<ItemMoreOptionsComponent>;
let component: ItemMoreOptionsComponent;
const dialogService = {
openSimpleDialog: jest.fn().mockResolvedValue(true),
open: jest.fn(),
};
const featureFlag$ = new BehaviorSubject<boolean>(false);
const configService = {
getFeatureFlag$: jest.fn().mockImplementation(() => featureFlag$.asObservable()),
};
const cipherService = {
getFullCipherView: jest.fn(),
encrypt: jest.fn(),
updateWithServer: jest.fn(),
softDeleteWithServer: jest.fn(),
};
const autofillSvc = {
doAutofill: jest.fn(),
doAutofillAndSave: jest.fn(),
currentAutofillTab$: new BehaviorSubject<{ url?: string | null } | null>(null),
autofillAllowed$: new BehaviorSubject(true),
};
const uriMatchStrategy$ = new BehaviorSubject<UriMatchStrategySetting>(UriMatchStrategy.Domain);
const domainSettingsService = {
resolvedDefaultUriMatchStrategy$: uriMatchStrategy$.asObservable(),
};
const hasSearchText$ = new BehaviorSubject(false);
const vaultPopupItemsService = {
hasSearchText$: hasSearchText$.asObservable(),
};
const baseCipher = {
id: "cipher-1",
login: {
uris: [
{ uri: "https://one.example.com" },
{ uri: "" },
{ uri: undefined as unknown as string },
{ uri: "https://two.example.com/a" },
],
username: "user",
},
favorite: false,
reprompt: 0,
type: CipherType.Login,
viewPassword: true,
edit: true,
} as any;
beforeEach(waitForAsync(async () => {
jest.clearAllMocks();
cipherService.getFullCipherView.mockImplementation(async (c) => ({ ...baseCipher, ...c }));
TestBed.configureTestingModule({
imports: [ItemMoreOptionsComponent, NoopAnimationsModule],
providers: [
{ provide: ConfigService, useValue: configService },
{ provide: CipherService, useValue: cipherService },
{ provide: VaultPopupAutofillService, useValue: autofillSvc },
{ provide: I18nService, useValue: { t: (k: string) => k } },
{ provide: AccountService, useValue: { activeAccount$: of({ id: "UserId" }) } },
{ provide: OrganizationService, useValue: { hasOrganizations: () => of(false) } },
{
provide: CipherAuthorizationService,
useValue: { canDeleteCipher$: () => of(true), canCloneCipher$: () => of(true) },
},
{ provide: CollectionService, useValue: { decryptedCollections$: () => of([]) } },
{ provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } },
{ provide: CipherArchiveService, useValue: { userCanArchive$: () => of(true) } },
{ provide: ToastService, useValue: { showToast: () => {} } },
{ provide: Router, useValue: { navigate: () => Promise.resolve(true) } },
{ provide: PasswordRepromptService, useValue: mock<PasswordRepromptService>() },
{
provide: DomainSettingsService,
useValue: domainSettingsService,
},
{
provide: VaultPopupItemsService,
useValue: vaultPopupItemsService,
},
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
});
TestBed.overrideProvider(DialogService, { useValue: dialogService });
await TestBed.compileComponents();
fixture = TestBed.createComponent(ItemMoreOptionsComponent);
component = fixture.componentInstance;
component.cipher = baseCipher;
}));
afterEach(() => {
jest.restoreAllMocks();
});
function mockConfirmDialogResult(result: string) {
const openSpy = jest
.spyOn(AutofillConfirmationDialogComponent, "open")
.mockReturnValue({ closed: of(result) } as any);
return openSpy;
}
it("calls doAutofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
await component.doAutofill();
expect(cipherService.getFullCipherView).toHaveBeenCalled();
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofill).toHaveBeenCalledWith(
expect.objectContaining({ id: "cipher-1" }),
false,
);
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
});
it("opens the confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => {
featureFlag$.next(true);
hasSearchText$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" });
const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
await component.doAutofill();
expect(openSpy).toHaveBeenCalledTimes(1);
const args = openSpy.mock.calls[0][1];
expect(args.data.currentUrl).toBe("https://page.example.com/path");
expect(args.data.savedUrls).toEqual(["https://one.example.com", "https://two.example.com/a"]);
});
it("does nothing when the user cancels the autofill confirmation dialog", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
await component.doAutofill();
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
it("autofills the item without adding the URL when the user selects 'AutofilledOnly'", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
await component.doAutofill();
expect(autofillSvc.doAutofill).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
it("autofills the item and adds the URL when the user selects 'AutofillAndUrlAdded'", async () => {
featureFlag$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofillAndUrlAdded);
await component.doAutofill();
expect(autofillSvc.doAutofillAndSave).toHaveBeenCalledTimes(1);
expect(autofillSvc.doAutofillAndSave.mock.calls[0][1]).toBe(false);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
});
it("only shows the exact match dialog when the uri match strategy is Exact and no URIs match", async () => {
featureFlag$.next(true);
uriMatchStrategy$.next(UriMatchStrategy.Exact);
hasSearchText$.next(true);
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
await component.doAutofill();
expect(dialogService.openSimpleDialog).toHaveBeenCalledTimes(1);
expect(dialogService.openSimpleDialog).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.objectContaining({ key: "cannotAutofill" }),
content: expect.objectContaining({ key: "cannotAutofillExactMatch" }),
type: "info",
}),
);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
it("hides the 'Fill and Save' button when showAutofillConfirmation$ is true", async () => {
// Enable both feature flag and search text → makes showAutofillConfirmation$ true
featureFlag$.next(true);
hasSearchText$.next(true);
fixture.detectChanges();
await fixture.whenStable();
const fillAndSaveButton = fixture.nativeElement.querySelector(
"button[bitMenuItem]:not([disabled])",
);
const buttonText = fillAndSaveButton?.textContent?.trim().toLowerCase() ?? "";
expect(buttonText.includes("fillAndSave".toLowerCase())).toBe(false);
});
});

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
@@ -11,8 +9,12 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
@@ -32,16 +34,25 @@ import {
import { PasswordRepromptService } from "@bitwarden/vault";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
import {
AutofillConfirmationDialogComponent,
AutofillConfirmationDialogResult,
} from "../autofill-confirmation-dialog/autofill-confirmation-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-item-more-options",
templateUrl: "./item-more-options.component.html",
imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule],
})
export class ItemMoreOptionsComponent {
private _cipher$ = new BehaviorSubject<CipherViewLike>(undefined);
private _cipher$ = new BehaviorSubject<CipherViewLike>({} as CipherViewLike);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({
required: true,
})
@@ -57,18 +68,29 @@ export class ItemMoreOptionsComponent {
* Flag to show view item menu option. Used when something else is
* assigned as the primary action for the item, such as autofill.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
showViewOption: boolean;
showViewOption = false;
/**
* Flag to hide the autofill menu options. Used for items that are
* already in the autofill list suggestion.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
hideAutofillOptions: boolean;
hideAutofillOptions = false;
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;
protected showAutofillConfirmation$ = combineLatest([
this.configService.getFeatureFlag$(FeatureFlag.AutofillConfirmation),
this.vaultPopupItemsService.hasSearchText$,
]).pipe(map(([isFeatureFlagEnabled, hasSearchText]) => isFeatureFlagEnabled && hasSearchText));
protected uriMatchStrategy$ = this.domainSettingsService.resolvedDefaultUriMatchStrategy$;
/**
* Observable that emits a boolean value indicating if the user is authorized to clone the cipher.
* @protected
@@ -138,6 +160,9 @@ export class ItemMoreOptionsComponent {
private collectionService: CollectionService,
private restrictedItemTypesService: RestrictedItemTypesService,
private cipherArchiveService: CipherArchiveService,
private configService: ConfigService,
private vaultPopupItemsService: VaultPopupItemsService,
private domainSettingsService: DomainSettingsService,
) {}
get canEdit() {
@@ -169,14 +194,63 @@ export class ItemMoreOptionsComponent {
return this.cipher.favorite ? "unfavorite" : "favorite";
}
async doAutofill() {
const cipher = await this.cipherService.getFullCipherView(this.cipher);
await this.vaultPopupAutofillService.doAutofill(cipher);
}
async doAutofillAndSave() {
const cipher = await this.cipherService.getFullCipherView(this.cipher);
await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false);
await this.vaultPopupAutofillService.doAutofillAndSave(cipher);
}
async doAutofill() {
const cipher = await this.cipherService.getFullCipherView(this.cipher);
const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$);
if (!showAutofillConfirmation) {
await this.vaultPopupAutofillService.doAutofill(cipher, false);
return;
}
const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$);
if (uriMatchStrategy === UriMatchStrategy.Exact) {
await this.dialogService.openSimpleDialog({
title: { key: "cannotAutofill" },
content: { key: "cannotAutofillExactMatch" },
type: "info",
acceptButtonText: { key: "okay" },
cancelButtonText: null,
});
return;
}
const currentTab = await firstValueFrom(this.vaultPopupAutofillService.currentAutofillTab$);
if (!currentTab?.url) {
await this.dialogService.openSimpleDialog({
title: { key: "error" },
content: { key: "errorGettingAutoFillData" },
type: "danger",
});
return;
}
const ref = AutofillConfirmationDialogComponent.open(this.dialogService, {
data: {
currentUrl: currentTab?.url || "",
savedUrls: cipher.login?.uris?.filter((u) => u.uri).map((u) => u.uri!) ?? [],
},
});
const result = await firstValueFrom(ref.closed);
switch (result) {
case AutofillConfirmationDialogResult.Canceled:
return;
case AutofillConfirmationDialogResult.AutofilledOnly:
await this.vaultPopupAutofillService.doAutofill(cipher);
return;
case AutofillConfirmationDialogResult.AutofillAndUrlAdded:
await this.vaultPopupAutofillService.doAutofillAndSave(cipher, false);
return;
}
}
async onView() {
@@ -196,15 +270,14 @@ export class ItemMoreOptionsComponent {
const cipher = await this.cipherService.getFullCipherView(this.cipher);
cipher.favorite = !cipher.favorite;
const activeUserId = await firstValueFrom(
const activeUserId = (await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
)) as UserId;
const encryptedCipher = await this.cipherService.encrypt(cipher, activeUserId);
await this.cipherService.updateWithServer(encryptedCipher);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t(
this.cipher.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites",
),

View File

@@ -23,6 +23,8 @@ export interface NewItemInitialValues {
collectionId?: CollectionId;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-new-item-dropdown",
templateUrl: "new-item-dropdown-v2.component.html",
@@ -34,6 +36,8 @@ export class NewItemDropdownV2Component implements OnInit {
/**
* Optional initial values to pass to the add cipher form
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
initialValues: NewItemInitialValues;

View File

@@ -18,14 +18,24 @@ import {
VaultGeneratorDialogComponent,
} from "./vault-generator-dialog.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "vault-cipher-form-generator",
template: "",
})
class MockCipherFormGenerator {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() type: "password" | "username" = "password";
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() algorithmSelected: EventEmitter<AlgorithmInfo> = new EventEmitter();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() uri: string = "";
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() valueGenerated = new EventEmitter<string>();
}

View File

@@ -38,6 +38,8 @@ export const GeneratorDialogAction = {
type GeneratorDialogAction = UnionOfValues<typeof GeneratorDialogAction>;
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-vault-generator-dialog",
templateUrl: "./vault-generator-dialog.component.html",

View File

@@ -17,6 +17,8 @@ import { VaultPopupListFiltersService } from "../../../../../vault/popup/service
import { VaultListFiltersComponent } from "../vault-list-filters/vault-list-filters.component";
import { VaultV2SearchComponent } from "../vault-search/vault-v2-search.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-vault-header-v2",
templateUrl: "vault-header-v2.component.html",
@@ -31,6 +33,8 @@ import { VaultV2SearchComponent } from "../vault-search/vault-v2-search.componen
],
})
export class VaultHeaderV2Component {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(DisclosureComponent) disclosure: DisclosureComponent;
/** Emits the visibility status of the disclosure component. */

View File

@@ -8,6 +8,8 @@ import { ChipSelectComponent } from "@bitwarden/components";
import { VaultPopupListFiltersService } from "../../../services/vault-popup-list-filters.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-vault-list-filters",
templateUrl: "./vault-list-filters.component.html",

View File

@@ -90,12 +90,18 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
private vaultPopupSectionService = inject(VaultPopupSectionService);
protected CipherViewLikeUtils = CipherViewLikeUtils;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(CdkVirtualScrollViewport, { static: false }) viewPort!: CdkVirtualScrollViewport;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(DisclosureComponent) disclosure!: DisclosureComponent;
/**
* Indicates whether the section should be open or closed if collapsibleKey is provided
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected sectionOpenState: Signal<boolean> = computed(() => {
if (!this.collapsibleKey()) {
return true;
@@ -130,17 +136,23 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
*/
private viewCipherTimeout?: number;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
ciphers = input<PopupCipherViewLike[]>([]);
/**
* If true, we will group ciphers by type (Login, Card, Identity)
* within subheadings in a single container, converted to a WritableSignal.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
groupByType = input<boolean | undefined>(false);
/**
* Computed signal for a grouped list of ciphers with an optional header
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherGroups = computed<
{
subHeaderKey?: string;
@@ -183,6 +195,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Title for the vault list item section.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
title = input<string | undefined>(undefined);
/**
@@ -191,33 +205,45 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
* The key must be added to the state definition in `vault-popup-section.service.ts` since the
* collapsed state is stored locally.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
collapsibleKey = input<keyof PopupSectionOpen | undefined>(undefined);
/**
* Optional description for the vault list item section. Will be shown below the title even when
* no ciphers are available.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
description = input<string | undefined>(undefined);
/**
* Option to show a refresh button in the section header.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showRefresh = input(false, { transform: booleanAttribute });
/**
* Event emitted when the refresh button is clicked.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output()
onRefresh = new EventEmitter<void>();
/**
* Flag indicating that the current tab location is blocked
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
currentURIIsBlocked = toSignal(this.vaultPopupAutofillService.currentTabIsOnBlocklist$);
/**
* Resolved i18n key to use for suggested cipher items
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
cipherItemTitleKey = computed(() => {
return (cipher: CipherViewLike) => {
const login = CipherViewLikeUtils.getLogin(cipher);
@@ -233,11 +259,15 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Option to show the autofill button for each item.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
showAutofillButton = input(false, { transform: booleanAttribute });
/**
* Flag indicating whether the suggested cipher item autofill button should be shown or not
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
hideAutofillButton = computed(
() => !this.showAutofillButton() || this.currentURIIsBlocked() || this.primaryActionAutofill(),
);
@@ -245,22 +275,30 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
/**
* Flag indicating whether the cipher item autofill menu options should be shown or not
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
hideAutofillMenuOptions = computed(() => this.currentURIIsBlocked() || this.showAutofillButton());
/**
* Option to perform autofill operation as the primary action for autofill suggestions.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
primaryActionAutofill = input(false, { transform: booleanAttribute });
/**
* Remove the bottom margin from the bit-section in this component
* (used for containers at the end of the page where bottom margin is not needed)
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableSectionMargin = input(false, { transform: booleanAttribute });
/**
* Remove the description margin
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
disableDescriptionMargin = input(false, { transform: booleanAttribute });
/**
@@ -275,6 +313,8 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
return collections[0]?.name;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected autofillShortcutTooltip = signal<string | undefined>(undefined);
constructor(

View File

@@ -18,6 +18,8 @@ import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "vault-password-history-v2",
templateUrl: "vault-password-history-v2.component.html",

View File

@@ -10,6 +10,8 @@ import { SearchModule } from "@bitwarden/components";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
imports: [CommonModule, SearchModule, JslibModule, FormsModule],
selector: "app-vault-v2-search",

View File

@@ -64,6 +64,8 @@ const VaultState = {
type VaultState = UnionOfValues<typeof VaultState>;
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-vault",
templateUrl: "vault-v2.component.html",
@@ -89,6 +91,8 @@ type VaultState = UnionOfValues<typeof VaultState>;
],
})
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement;
NudgeType = NudgeType;

View File

@@ -76,6 +76,8 @@ type LoadAction =
| typeof COPY_VERIFICATION_CODE_ID
| typeof UPDATE_PASSWORD;
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-view-v2",
templateUrl: "view-v2.component.html",

View File

@@ -0,0 +1,77 @@
import { TestBed } from "@angular/core/testing";
import { RouterStateSnapshot } from "@angular/router";
import { VaultV2Component } from "../components/vault-v2/vault-v2.component";
import { VaultPopupItemsService } from "../services/vault-popup-items.service";
import { VaultPopupListFiltersService } from "../services/vault-popup-list-filters.service";
import { clearVaultStateGuard } from "./clear-vault-state.guard";
describe("clearVaultStateGuard", () => {
let applyFilterSpy: jest.Mock;
let resetFilterFormSpy: jest.Mock;
beforeEach(() => {
applyFilterSpy = jest.fn();
resetFilterFormSpy = jest.fn();
TestBed.configureTestingModule({
providers: [
{
provide: VaultPopupItemsService,
useValue: { applyFilter: applyFilterSpy },
},
{
provide: VaultPopupListFiltersService,
useValue: { resetFilterForm: resetFilterFormSpy },
},
],
});
});
afterEach(() => {
jest.clearAllMocks();
});
it.each([
"/view-cipher?cipherId=123",
"/edit-cipher?cipherId=123",
"/clone-cipher?cipherId=123",
"/assign-collections?cipherId=123",
])("should not clear vault state when viewing or editing a cipher: %s", (url) => {
const nextState = { url } as RouterStateSnapshot;
const result = TestBed.runInInjectionContext(() =>
clearVaultStateGuard({} as VaultV2Component, null, null, nextState),
);
expect(result).toBe(true);
expect(applyFilterSpy).not.toHaveBeenCalled();
expect(resetFilterFormSpy).not.toHaveBeenCalled();
});
it.each(["/settings", "/tabs/settings"])(
"should clear vault state when navigating to non-cipher routes: %s",
(url) => {
const nextState = { url } as RouterStateSnapshot;
const result = TestBed.runInInjectionContext(() =>
clearVaultStateGuard({} as VaultV2Component, null, null, nextState),
);
expect(result).toBe(true);
expect(applyFilterSpy).toHaveBeenCalledWith("");
expect(resetFilterFormSpy).toHaveBeenCalled();
},
);
it("should not clear vault state when not changing states", () => {
const result = TestBed.runInInjectionContext(() =>
clearVaultStateGuard({} as VaultV2Component, null, null, null),
);
expect(result).toBe(true);
expect(applyFilterSpy).not.toHaveBeenCalled();
expect(resetFilterFormSpy).not.toHaveBeenCalled();
});
});

View File

@@ -7,7 +7,8 @@ import { VaultPopupListFiltersService } from "../services/vault-popup-list-filte
/**
* Guard to clear the vault state (search and filter) when navigating away from the vault view.
* This ensures the search and filter state is reset when navigating between different tabs, except viewing a cipher.
* This ensures the search and filter state is reset when navigating between different tabs,
* except viewing or editing a cipher.
*/
export const clearVaultStateGuard: CanDeactivateFn<VaultV2Component> = (
component: VaultV2Component,
@@ -17,7 +18,7 @@ export const clearVaultStateGuard: CanDeactivateFn<VaultV2Component> = (
) => {
const vaultPopupItemsService = inject(VaultPopupItemsService);
const vaultPopupListFiltersService = inject(VaultPopupListFiltersService);
if (nextState && !isViewingCipher(nextState.url)) {
if (nextState && !isCipherOpen(nextState.url)) {
vaultPopupItemsService.applyFilter("");
vaultPopupListFiltersService.resetFilterForm();
}
@@ -25,4 +26,8 @@ export const clearVaultStateGuard: CanDeactivateFn<VaultV2Component> = (
return true;
};
const isViewingCipher = (url: string): boolean => url.includes("view-cipher");
const isCipherOpen = (url: string): boolean =>
url.includes("view-cipher") ||
url.includes("assign-collections") ||
url.includes("edit-cipher") ||
url.includes("clone-cipher");

View File

@@ -261,6 +261,13 @@ export class VaultPopupItemsService {
this.remainingCiphers$.pipe(map(() => false)),
).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 }));
/** Observable that indicates whether there is search text present.
*/
hasSearchText$: Observable<boolean> = this._hasSearchText.pipe(
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: true }),
);
/**
* Observable that indicates whether a filter or search text is currently applied to the ciphers.
*/

View File

@@ -31,7 +31,7 @@ export class VaultPopupSectionService {
private vaultPopupItemsService = inject(VaultPopupItemsService);
private stateProvider = inject(StateProvider);
private hasFilterOrSearchApplied = toSignal(
private readonly hasFilterOrSearchApplied = toSignal(
this.vaultPopupItemsService.hasFilterApplied$.pipe(map((hasFilter) => hasFilter)),
);
@@ -40,7 +40,7 @@ export class VaultPopupSectionService {
* application-applied overrides.
* `null` means there is no current override
*/
private temporaryStateOverride = signal<Partial<PopupSectionOpen> | null>(null);
private readonly temporaryStateOverride = signal<Partial<PopupSectionOpen> | null>(null);
constructor() {
effect(
@@ -71,7 +71,7 @@ export class VaultPopupSectionService {
* Stored disk state for the open/close state of the sections, with an initial value provided
* if the stored disk state does not yet exist.
*/
private sectionOpenStoredState = toSignal<PopupSectionOpen | null>(
private readonly sectionOpenStoredState = toSignal<PopupSectionOpen | null>(
this.sectionOpenStateProvider.state$.pipe(map((sectionOpen) => sectionOpen ?? INITIAL_OPEN)),
// Indicates that the state value is loading
{ initialValue: null },
@@ -81,7 +81,7 @@ export class VaultPopupSectionService {
* Indicates the current open/close display state of each section, accounting for temporary
* non-persisted overrides.
*/
sectionOpenDisplayState: Signal<Partial<PopupSectionOpen>> = computed(() => ({
readonly sectionOpenDisplayState: Signal<Partial<PopupSectionOpen>> = computed(() => ({
...this.sectionOpenStoredState(),
...this.temporaryStateOverride(),
}));

View File

@@ -22,20 +22,30 @@ import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-butto
import { AppearanceV2Component } from "./appearance-v2.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-header",
template: `<ng-content></ng-content>`,
})
class MockPopupHeaderComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() pageTitle: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() backAction: () => void;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-page",
template: `<ng-content></ng-content>`,
})
class MockPopupPageComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() loading: boolean;
}

View File

@@ -33,6 +33,8 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
import { PopupSizeService } from "../../../platform/popup/layout/popup-size.service";
import { VaultPopupCopyButtonsService } from "../services/vault-popup-copy-buttons.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./appearance-v2.component.html",
imports: [

View File

@@ -33,6 +33,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "archive.component.html",
standalone: true,

View File

@@ -13,6 +13,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "download-bitwarden.component.html",
imports: [

View File

@@ -21,20 +21,30 @@ import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-heade
import { FoldersV2Component } from "./folders-v2.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-header",
template: `<ng-content></ng-content>`,
})
class MockPopupHeaderComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() pageTitle: string = "";
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() backAction: () => void = () => {};
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-footer",
template: `<ng-content></ng-content>`,
})
class MockPopupFooterComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() pageTitle: string = "";
}

View File

@@ -22,6 +22,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./folders-v2.component.html",
imports: [

View File

@@ -17,6 +17,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "more-from-bitwarden-page-v2.component.html",
imports: [

View File

@@ -53,9 +53,13 @@ export class TrashListItemsContainerComponent {
/**
* The list of trashed items to display.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
ciphers: PopupCipherViewLike[] = [];
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
headerText: string;

View File

@@ -19,6 +19,8 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "vault-settings-v2.component.html",
imports: [
@@ -37,12 +39,12 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy {
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
// Check if user is premium user, they will be able to archive items
protected userCanArchive = toSignal(
protected readonly userCanArchive = toSignal(
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))),
);
// Check if user has archived items (does not check if user is premium)
protected showArchiveFilter = toSignal(
protected readonly showArchiveFilter = toSignal(
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.showArchiveVault$(userId))),
);

View File

@@ -36,7 +36,8 @@ const DEFAULT_PARAMS = {
* outputPath?: string;
* mode?: string;
* env?: string;
* additionalEntries?: { [outputPath: string]: string }
* additionalEntries?: { [outputPath: string]: string };
* importAliases?: import("webpack").ResolveOptions["alias"];
* }} params - The input parameters for building the config.
*/
module.exports.buildConfig = function buildConfig(params) {
@@ -362,6 +363,7 @@ module.exports.buildConfig = function buildConfig(params) {
path: require.resolve("path-browserify"),
},
cache: true,
alias: params.importAliases,
},
output: {
filename: "[name].js",
@@ -482,6 +484,7 @@ module.exports.buildConfig = function buildConfig(params) {
path: require.resolve("path-browserify"),
},
cache: true,
alias: params.importAliases,
},
dependencies: ["main"],
plugins: [...requiredPlugins, new AngularCheckPlugin()],

View File

@@ -31,6 +31,7 @@ const DEFAULT_PARAMS = {
* localesPath?: string;
* externalsModulesDir?: string;
* watch?: boolean;
* importAliases?: import("webpack").ResolveOptions["alias"];
* }} params
*/
module.exports.buildConfig = function buildConfig(params) {
@@ -95,6 +96,7 @@ module.exports.buildConfig = function buildConfig(params) {
symlinks: false,
modules: params.modulesPath,
plugins: [new TsconfigPathsPlugin({ configFile: params.tsConfig })],
alias: params.importAliases,
},
output: {
filename: "[name].js",

View File

@@ -440,33 +440,6 @@ dependencies = [
"tokio-util",
]
[[package]]
name = "bitwarden_chromium_importer"
version = "0.0.0"
dependencies = [
"aes",
"aes-gcm",
"anyhow",
"async-trait",
"base64",
"cbc",
"hex",
"homedir",
"napi",
"napi-derive",
"oo7",
"pbkdf2",
"rand 0.9.1",
"rusqlite",
"security-framework",
"serde",
"serde_json",
"sha1",
"tokio",
"winapi",
"windows 0.61.1",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -606,6 +579,31 @@ dependencies = [
"zeroize",
]
[[package]]
name = "chromium_importer"
version = "0.0.0"
dependencies = [
"aes",
"aes-gcm",
"anyhow",
"async-trait",
"base64",
"cbc",
"hex",
"homedir",
"oo7",
"pbkdf2",
"rand 0.9.1",
"rusqlite",
"security-framework",
"serde",
"serde_json",
"sha1",
"tokio",
"winapi",
"windows 0.61.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -968,7 +966,7 @@ dependencies = [
"anyhow",
"autotype",
"base64",
"bitwarden_chromium_importer",
"chromium_importer",
"desktop_core",
"hex",
"napi",
@@ -3982,7 +3980,7 @@ dependencies = [
"windows-collections",
"windows-core 0.61.0",
"windows-future",
"windows-link",
"windows-link 0.1.3",
"windows-numerics",
]
@@ -4015,9 +4013,9 @@ checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
dependencies = [
"windows-implement 0.60.0",
"windows-interface 0.59.1",
"windows-link",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings",
"windows-strings 0.4.2",
]
[[package]]
@@ -4027,7 +4025,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32"
dependencies = [
"windows-core 0.61.0",
"windows-link",
"windows-link 0.1.3",
]
[[package]]
@@ -4080,6 +4078,12 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.2.0"
@@ -4087,18 +4091,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.61.0",
"windows-link",
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result 0.3.4",
"windows-strings",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
@@ -4116,7 +4120,16 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link",
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
@@ -4125,7 +4138,16 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link",
"windows-link 0.1.3",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
@@ -4216,7 +4238,7 @@ version = "0.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
dependencies = [
"windows-link",
"windows-link 0.1.3",
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",

View File

@@ -2,7 +2,7 @@
resolver = "2"
members = [
"autotype",
"bitwarden_chromium_importer",
"chromium_importer",
"core",
"macos_provider",
"napi",
@@ -68,14 +68,14 @@ tokio = "=1.45.0"
tokio-stream = "=0.1.15"
tokio-util = "=0.7.13"
tracing = "=0.1.41"
tracing-subscriber = { version = "=0.3.20", features = ["fmt", "env-filter"] }
tracing-subscriber = { version = "=0.3.20", features = ["fmt", "env-filter", "tracing-log"] }
typenum = "=1.18.0"
uniffi = "=0.28.3"
widestring = "=1.2.0"
windows = "=0.61.1"
windows-core = "=0.61.0"
windows-future = "=0.2.0"
windows-registry = "=0.5.3"
windows-registry = "=0.6.1"
zbus = "=5.11.0"
zbus_polkit = "=5.0.0"
zeroizing-alloc = "=0.1.0"

View File

@@ -1,48 +0,0 @@
//! Cryptographic primitives used in the SDK
use anyhow::{anyhow, Result};
use aes::cipher::{
block_padding::Pkcs7, generic_array::GenericArray, typenum::U32, BlockDecryptMut, KeyIvInit,
};
pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray<u8, U32>) -> Result<Vec<u8>> {
let iv = GenericArray::from_slice(iv);
let mut data = data.to_vec();
cbc::Decryptor::<aes::Aes256>::new(&key, iv)
.decrypt_padded_mut::<Pkcs7>(&mut data)
.map_err(|_| anyhow!("Failed to decrypt data"))?;
Ok(data)
}
#[cfg(test)]
mod tests {
use aes::cipher::{
generic_array::{sequence::GenericSequence, GenericArray},
ArrayLength,
};
use base64::{engine::general_purpose::STANDARD, Engine};
pub fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec<u8> {
(0..length).map(|i| offset + i as u8 * increment).collect()
}
pub fn generate_generic_array<N: ArrayLength<u8>>(
offset: u8,
increment: u8,
) -> GenericArray<u8, N> {
GenericArray::generate(|i| offset + i as u8 * increment)
}
#[test]
fn test_decrypt_aes256() {
let iv = generate_vec(16, 0, 1);
let iv: &[u8; 16] = iv.as_slice().try_into().unwrap();
let key = generate_generic_array(0, 1);
let data: Vec<u8> = STANDARD.decode("ByUF8vhyX4ddU9gcooznwA==").unwrap();
let decrypted = super::decrypt_aes256(iv, &data, key).unwrap();
assert_eq!(String::from_utf8(decrypted).unwrap(), "EncryptMe!\u{6}\u{6}\u{6}\u{6}\u{6}\u{6}");
}
}

View File

@@ -1,8 +0,0 @@
#[macro_use]
extern crate napi_derive;
pub mod chromium;
pub mod metadata;
pub mod util;
pub use crate::chromium::platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS;

View File

@@ -1,5 +1,5 @@
[package]
name = "bitwarden_chromium_importer"
name = "chromium_importer"
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
@@ -14,8 +14,6 @@ base64 = { workspace = true }
cbc = { workspace = true, features = ["alloc"] }
hex = { workspace = true }
homedir = { workspace = true }
napi = { workspace = true }
napi-derive = { workspace = true }
pbkdf2 = "=0.12.2"
rand = { workspace = true }
rusqlite = { version = "=0.37.0", features = ["bundled"] }
@@ -36,4 +34,3 @@ oo7 = { workspace = true }
[lints]
workspace = true

View File

@@ -1,6 +1,13 @@
# Windows ABE Architecture
# Chromium Direct Importer
## Overview
A rust library that allows you to directly import credentials from Chromium-based browsers.
## Windows ABE Architecture
On Windows chrome has additional protection measurements which needs to be circumvented in order to
get access to the passwords.
### Overview
The Windows Application Bound Encryption (ABE) consists of three main components that work together:
@@ -10,7 +17,7 @@ The Windows Application Bound Encryption (ABE) consists of three main components
_(The names of the binaries will be changed for the released product.)_
## The goal
### The goal
The goal of this subsystem is to decrypt the master encryption key with which the login information
is encrypted on the local system in Windows. This applies to the most recent versions of Chrome and
@@ -24,7 +31,7 @@ Protection API at the system level on top of that. This triply encrypted key is
The next paragraphs describe what is done at each level to decrypt the key.
## 1. Client library
### 1. Client library
This is a Rust module that is part of the Chromium importer. It only compiles and runs on Windows
(see `abe.rs` and `abe_config.rs`). Its main task is to launch `admin.exe` with elevated privileges
@@ -52,7 +59,7 @@ admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXRE
**At this point, the user must permit the action to be performed on the UAC screen.**
## 2. Admin executable
### 2. Admin executable
This executable receives the full path of `service.exe` and the data to be decrypted.
@@ -67,7 +74,7 @@ is sent to the named pipe server created by the user. The user responds with `ok
After that, the executable stops and uninstalls the service and then exits.
## 3. System service
### 3. System service
The service starts and creates a named pipe server for communication between `admin.exe` and the
system service. Please note that it is not possible to communicate between the user and the system
@@ -83,7 +90,7 @@ removed from the system. Even though we send only one request, the service is de
many clients with as many messages as needed and could be installed on the system permanently if
necessary.
## 4. Back to client library
### 4. Back to client library
The decrypted base64-encoded string comes back from the admin executable to the named pipe server at
the user level. At this point, it has been decrypted only once at the system level.
@@ -99,7 +106,7 @@ itself), it's either AES-256-GCM or ChaCha20Poly1305 encryption scheme. The deta
After all of these steps, we have the master key which can be used to decrypt the password
information stored in the local database.
## Summary
### Summary
The Windows ABE decryption process involves a three-tier architecture with named pipe communication:

View File

@@ -7,11 +7,9 @@ use hex::decode;
use homedir::my_home;
use rusqlite::{params, Connection};
// Platform-specific code
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
pub mod platform;
mod platform;
pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS;
//
// Public API
@@ -22,10 +20,7 @@ pub struct ProfileInfo {
pub name: String,
pub folder: String,
#[allow(dead_code)]
pub account_name: Option<String>,
#[allow(dead_code)]
pub account_email: Option<String>,
}
@@ -113,12 +108,12 @@ pub async fn import_logins(
//
#[derive(Debug, Clone, Copy)]
pub struct BrowserConfig {
pub(crate) struct BrowserConfig {
pub name: &'static str,
pub data_dir: &'static str,
}
pub static SUPPORTED_BROWSER_MAP: LazyLock<
pub(crate) static SUPPORTED_BROWSER_MAP: LazyLock<
std::collections::HashMap<&'static str, &'static BrowserConfig>,
> = LazyLock::new(|| {
platform::SUPPORTED_BROWSERS
@@ -140,12 +135,12 @@ fn get_browser_data_dir(config: &BrowserConfig) -> Result<PathBuf> {
//
#[async_trait]
pub trait CryptoService: Send {
pub(crate) trait CryptoService: Send {
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String>;
}
#[derive(serde::Deserialize, Clone)]
pub struct LocalState {
pub(crate) struct LocalState {
profile: AllProfiles,
#[allow(dead_code)]
os_crypt: Option<OsCrypt>,
@@ -198,16 +193,17 @@ fn load_local_state(browser_dir: &Path) -> Result<LocalState> {
}
fn get_profile_info(local_state: &LocalState) -> Vec<ProfileInfo> {
let mut profile_infos = Vec::new();
for (name, info) in local_state.profile.info_cache.iter() {
profile_infos.push(ProfileInfo {
local_state
.profile
.info_cache
.iter()
.map(|(name, info)| ProfileInfo {
name: info.name.clone(),
folder: name.clone(),
account_name: info.gaia_name.clone(),
account_email: info.user_name.clone(),
});
}
profile_infos
})
.collect()
}
struct EncryptedLogin {
@@ -264,17 +260,16 @@ fn hex_to_bytes(hex: &str) -> Vec<u8> {
decode(hex).unwrap_or_default()
}
fn does_table_exist(conn: &Connection, table_name: &str) -> Result<bool, rusqlite::Error> {
let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")?;
let exists = stmt.exists(params![table_name])?;
Ok(exists)
fn table_exist(conn: &Connection, table_name: &str) -> Result<bool, rusqlite::Error> {
conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")?
.exists(params![table_name])
}
fn query_logins(db_path: &str) -> Result<Vec<EncryptedLogin>, rusqlite::Error> {
let conn = Connection::open(db_path)?;
let have_logins = does_table_exist(&conn, "logins")?;
let have_password_notes = does_table_exist(&conn, "password_notes")?;
let have_logins = table_exist(&conn, "logins")?;
let have_password_notes = table_exist(&conn, "password_notes")?;
if !have_logins || !have_password_notes {
return Ok(vec![]);
}
@@ -308,10 +303,7 @@ fn query_logins(db_path: &str) -> Result<Vec<EncryptedLogin>, rusqlite::Error> {
})
})?;
let mut logins = Vec::new();
for login in logins_iter {
logins.push(login?);
}
let logins = logins_iter.collect::<Result<Vec<_>, _>>()?;
Ok(logins)
}

View File

@@ -13,7 +13,7 @@ use crate::util;
//
// TODO: It's possible that there might be multiple possible data directories, depending on the installation method (e.g., snap, flatpak, etc.).
pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
BrowserConfig {
name: "Chrome",
data_dir: ".config/google-chrome",
@@ -32,7 +32,7 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [
},
];
pub fn get_crypto_service(
pub(crate) fn get_crypto_service(
browser_name: &String,
_local_state: &LocalState,
) -> Result<Box<dyn CryptoService>> {

View File

@@ -10,7 +10,7 @@ use crate::util;
// Public API
//
pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
BrowserConfig {
name: "Chrome",
data_dir: "Library/Application Support/Google/Chrome",
@@ -41,7 +41,7 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [
},
];
pub fn get_crypto_service(
pub(crate) fn get_crypto_service(
browser_name: &String,
_local_state: &LocalState,
) -> Result<Box<dyn CryptoService>> {

View File

@@ -0,0 +1,7 @@
// Platform-specific code
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
mod native;
pub(crate) use native::*;

View File

@@ -15,8 +15,7 @@ use crate::util;
// Public API
//
// IMPORTANT adjust array size when enabling / disabling chromium importers here
pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
BrowserConfig {
name: "Brave",
data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data",
@@ -43,7 +42,7 @@ pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [
},
];
pub fn get_crypto_service(
pub(crate) fn get_crypto_service(
_browser_name: &str,
local_state: &LocalState,
) -> Result<Box<dyn CryptoService>> {

View File

@@ -0,0 +1,5 @@
#![doc = include_str!("../README.md")]
pub mod chromium;
pub mod metadata;
mod util;

View File

@@ -1,8 +1,7 @@
use std::collections::{HashMap, HashSet};
use crate::{chromium::InstalledBrowserRetriever, PLATFORM_SUPPORTED_BROWSERS};
use crate::chromium::{InstalledBrowserRetriever, PLATFORM_SUPPORTED_BROWSERS};
#[napi(object)]
/// Mechanisms that load data into the importer
pub struct NativeImporterMetadata {
/// Identifies the importer
@@ -24,7 +23,7 @@ pub fn get_supported_importers<T: InstalledBrowserRetriever>(
// Check for installed browsers
let installed_browsers = T::get_installed_browsers().unwrap_or_default();
const IMPORTERS: [(&str, &str); 6] = [
const IMPORTERS: &[(&str, &str)] = &[
("chromecsv", "Chrome"),
("chromiumcsv", "Chromium"),
("bravecsv", "Brave"),
@@ -57,9 +56,7 @@ pub fn get_supported_importers<T: InstalledBrowserRetriever>(
map
}
/*
Tests are cfg-gated based upon OS, and must be compiled/run on each OS for full coverage
*/
// Tests are cfg-gated based upon OS, and must be compiled/run on each OS for full coverage
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,9 +1,6 @@
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
use anyhow::{anyhow, Result};
use pbkdf2::{hmac::Hmac, pbkdf2};
use sha1::Sha1;
pub fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> {
fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> {
if encrypted.len() < 3 {
return Err(anyhow!(
"Corrupted entry: invalid encrypted string length, expected at least 3 bytes, got {}",
@@ -15,7 +12,14 @@ pub fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> {
Ok((std::str::from_utf8(version)?, password))
}
pub fn split_encrypted_string_and_validate<'a>(
/// A Chromium password consists of three parts:
/// - Version (3 bytes): "v10", "v11", etc.
/// - Cipher text (chunks of 16 bytes)
/// - Padding (1-15 bytes)
///
/// This function splits the encrypted byte slice into version and cipher text.
/// Padding is included and handled by the underlying cryptographic library.
pub(crate) fn split_encrypted_string_and_validate<'a>(
encrypted: &'a [u8],
supported_versions: &[&str],
) -> Result<(&'a str, &'a [u8])> {
@@ -27,15 +31,22 @@ pub fn split_encrypted_string_and_validate<'a>(
Ok((version, password))
}
pub fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
let decryptor = cbc::Decryptor::<aes::Aes128>::new_from_slices(key, iv)?;
let plaintext: Vec<u8> = decryptor
/// Decrypt using AES-128 in CBC mode.
#[cfg(any(target_os = "linux", target_os = "macos", test))]
pub(crate) fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
cbc::Decryptor::<aes::Aes128>::new_from_slices(key, iv)?
.decrypt_padded_vec_mut::<Pkcs7>(ciphertext)
.map_err(|e| anyhow!("Failed to decrypt: {}", e))?;
Ok(plaintext)
.map_err(|e| anyhow!("Failed to decrypt: {}", e))
}
pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result<Vec<u8>> {
/// Derives a PBKDF2 key from the static "saltysalt" salt with the given password and iteration count.
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn derive_saltysalt(password: &[u8], iterations: u32) -> Result<Vec<u8>> {
use pbkdf2::{hmac::Hmac, pbkdf2};
use sha1::Sha1;
let mut key = vec![0u8; 16];
pbkdf2::<Hmac<Sha1>>(password, b"saltysalt", iterations, &mut key)
.map_err(|e| anyhow!("Failed to derive master key: {}", e))?;
@@ -44,16 +55,6 @@ pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result<Vec<u8>> {
#[cfg(test)]
mod tests {
pub fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec<u8> {
(0..length).map(|i| offset + i as u8 * increment).collect()
}
pub fn generate_generic_array<N: ArrayLength<u8>>(
offset: u8,
increment: u8,
) -> GenericArray<u8, N> {
GenericArray::generate(|i| offset + i as u8 * increment)
}
use aes::cipher::{
block_padding::Pkcs7,
generic_array::{sequence::GenericSequence, GenericArray},
@@ -64,6 +65,17 @@ mod tests {
const LENGTH10: usize = 10;
const LENGTH0: usize = 0;
fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec<u8> {
(0..length).map(|i| offset + i as u8 * increment).collect()
}
fn generate_generic_array<N: ArrayLength<u8>>(
offset: u8,
increment: u8,
) -> GenericArray<u8, N> {
GenericArray::generate(|i| offset + i as u8 * increment)
}
fn run_split_encrypted_string_test<'a, const N: usize>(
successfully_split: bool,
plaintext_to_encrypt: &'a str,

View File

@@ -0,0 +1,141 @@
//! This file implements Polkit based system unlock.
//!
//! # Security
//! This section describes the assumed security model and security guarantees achieved. In the required security
//! guarantee is that a locked vault - a running app - cannot be unlocked when the device (user-space)
//! is compromised in this state.
//!
//! When first unlocking the app, the app sends the user-key to this module, which holds it in secure memory,
//! protected by memfd_secret. This makes it inaccessible to other processes, even if they compromise root, a kernel compromise
//! has circumventable best-effort protections. While the app is running this key is held in memory, even if locked.
//! When unlocking, the app will prompt the user via `polkit` to get a yes/no decision on whether to release the key to the app.
use anyhow::{anyhow, Result};
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, warn};
use zbus::Connection;
use zbus_polkit::policykit1::{AuthorityProxy, CheckAuthorizationFlags, Subject};
use crate::secure_memory::*;
pub struct BiometricLockSystem {
// The userkeys that are held in memory MUST be protected from memory dumping attacks, to ensure
// locked vaults cannot be unlocked
secure_memory: Arc<Mutex<crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore>>,
}
impl BiometricLockSystem {
pub fn new() -> Self {
Self {
secure_memory: Arc::new(Mutex::new(
crate::secure_memory::encrypted_memory_store::EncryptedMemoryStore::new(),
)),
}
}
}
impl Default for BiometricLockSystem {
fn default() -> Self {
Self::new()
}
}
impl super::BiometricTrait for BiometricLockSystem {
async fn authenticate(&self, _hwnd: Vec<u8>, _message: String) -> Result<bool> {
polkit_authenticate_bitwarden_policy().await
}
async fn authenticate_available(&self) -> Result<bool> {
polkit_is_bitwarden_policy_available().await
}
async fn enroll_persistent(&self, _user_id: &str, _key: &[u8]) -> Result<()> {
// Not implemented
Ok(())
}
async fn provide_key(&self, user_id: &str, key: &[u8]) {
self.secure_memory
.lock()
.await
.put(user_id.to_string(), key);
}
async fn unlock(&self, user_id: &str, _hwnd: Vec<u8>) -> Result<Vec<u8>> {
if !polkit_authenticate_bitwarden_policy().await? {
return Err(anyhow!("Authentication failed"));
}
self.secure_memory
.lock()
.await
.get(user_id)
.ok_or(anyhow!("No key found"))
}
async fn unlock_available(&self, user_id: &str) -> Result<bool> {
Ok(self.secure_memory.lock().await.has(user_id))
}
async fn has_persistent(&self, _user_id: &str) -> Result<bool> {
Ok(false)
}
async fn unenroll(&self, user_id: &str) -> Result<(), anyhow::Error> {
self.secure_memory.lock().await.remove(user_id);
Ok(())
}
}
/// Perform a polkit authorization against the bitwarden unlock policy. Note: This relies on no custom
/// rules in the system skipping the authorization check, in which case this counts as UV / authentication.
async fn polkit_authenticate_bitwarden_policy() -> Result<bool> {
debug!("[Polkit] Authenticating / performing UV");
let connection = Connection::system().await?;
let proxy = AuthorityProxy::new(&connection).await?;
let subject = Subject::new_for_owner(std::process::id(), None, None)?;
let details = std::collections::HashMap::new();
let authorization_result = proxy
.check_authorization(
&subject,
"com.bitwarden.Bitwarden.unlock",
&details,
CheckAuthorizationFlags::AllowUserInteraction.into(),
"",
)
.await;
match authorization_result {
Ok(result) => Ok(result.is_authorized),
Err(e) => {
warn!("[Polkit] Error performing authentication: {:?}", e);
Ok(false)
}
}
}
async fn polkit_is_bitwarden_policy_available() -> Result<bool> {
let connection = Connection::system().await?;
let proxy = AuthorityProxy::new(&connection).await?;
let actions = proxy.enumerate_actions("en").await?;
for action in actions {
if action.action_id == "com.bitwarden.Bitwarden.unlock" {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore]
async fn test_polkit_authenticate() {
let result = polkit_authenticate_bitwarden_policy().await;
assert!(result.is_ok());
}
}

View File

@@ -1,7 +1,7 @@
use anyhow::Result;
#[allow(clippy::module_inception)]
#[cfg_attr(target_os = "linux", path = "unimplemented.rs")]
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
mod biometric_v2;

View File

@@ -1,7 +1,7 @@
#[cfg(target_os = "windows")]
pub(crate) mod dpapi;
mod encrypted_memory_store;
pub(crate) mod encrypted_memory_store;
mod secure_key;
/// The secure memory store provides an ephemeral key-value store for sensitive data.

View File

@@ -17,7 +17,7 @@ manual_test = []
anyhow = { workspace = true }
autotype = { path = "../autotype" }
base64 = { workspace = true }
bitwarden_chromium_importer = { path = "../bitwarden_chromium_importer" }
chromium_importer = { path = "../chromium_importer" }
desktop_core = { path = "../core" }
hex = { workspace = true }
napi = { workspace = true, features = ["async"] }

View File

@@ -3,15 +3,6 @@
/* auto-generated by NAPI-RS */
/** Mechanisms that load data into the importer */
export interface NativeImporterMetadata {
/** Identifies the importer */
id: string
/** Describes the strategies used to obtain imported data */
loaders: Array<string>
/** Identifies the instructions for the importer */
instructions: string
}
export declare namespace passwords {
/** The error message returned when a password is not found during retrieval or deletion. */
export const PASSWORD_NOT_FOUND: string
@@ -249,9 +240,13 @@ export declare namespace chromium_importer {
login?: Login
failure?: LoginImportFailure
}
export interface NativeImporterMetadata {
id: string
loaders: Array<string>
instructions: string
}
/** Returns OS aware metadata describing supported Chromium based importers as a JSON string. */
export function getMetadata(): Record<string, NativeImporterMetadata>
export function getInstalledBrowsers(): Array<string>
export function getAvailableProfiles(browser: string): Array<ProfileInfo>
export function importLogins(browser: string, profileId: string): Promise<Array<LoginImportResult>>
}

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