Merge branch 'main' of https://github.com/bitwarden/clients into ac/pm-16694-ac-integration-page-background-fill-missing

This commit is contained in:
JaredScar 2026-02-03 14:13:40 -05:00
commit 84e8b45917
537 changed files with 17670 additions and 5093 deletions

9
.github/CODEOWNERS vendored
View File

@ -15,6 +15,10 @@ apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-managemen
apps/desktop/desktop_native/Cargo.lock
apps/desktop/desktop_native/Cargo.toml
# Web connectors
apps/web/src/connectors @bitwarden/team-auth-dev
apps/web/src/connectors/platform @bitwarden/team-platform-dev
## Auth team files ##
apps/browser/src/auth @bitwarden/team-auth-dev
apps/cli/src/auth @bitwarden/team-auth-dev
@ -22,8 +26,6 @@ apps/desktop/src/auth @bitwarden/team-auth-dev
apps/web/src/app/auth @bitwarden/team-auth-dev
libs/auth @bitwarden/team-auth-dev
libs/user-core @bitwarden/team-auth-dev
# web connectors used for auth
apps/web/src/connectors @bitwarden/team-auth-dev
bitwarden_license/bit-web/src/app/auth @bitwarden/team-auth-dev
libs/angular/src/auth @bitwarden/team-auth-dev
libs/common/src/auth @bitwarden/team-auth-dev
@ -154,6 +156,9 @@ apps/desktop/macos/autofill-extension @bitwarden/team-autofill-desktop-dev
apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/windows_plugin_authenticator @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/autotype @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/napi/src/autofill.rs @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/napi/src/autotype.rs @bitwarden/team-autofill-desktop-dev
apps/desktop/desktop_native/napi/src/sshagent.rs @bitwarden/team-autofill-desktop-dev
# DuckDuckGo integration
apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-desktop-dev
apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-desktop-dev

View File

@ -73,7 +73,7 @@ jobs:
_MONITORED_FILES: ${{ steps.changed-files.outputs.monitored_files }}
with:
script: |
const changedFiles = `$_MONITORED_FILES`.split(' ').filter(file => file.trim() !== '');
const changedFiles = process.env._MONITORED_FILES.split(' ').filter(file => file.trim() !== '');
const message = `<!-- comment_tag: ddg-test-warning -->
⚠️🦆 **DuckDuckGo Integration files have been modified in this PR:**

View File

@ -236,7 +236,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@ -399,7 +399,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@ -562,7 +562,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@ -827,7 +827,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@ -1032,14 +1032,14 @@ jobs:
- name: Cache Build
id: build-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/desktop/build
key: ${{ runner.os }}-${{ github.run_id }}-build
- name: Cache Safari
id: safari-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
@ -1185,7 +1185,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@ -1272,14 +1272,14 @@ jobs:
- name: Get Build Cache
id: build-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/desktop/build
key: ${{ runner.os }}-${{ github.run_id }}-build
- name: Setup Safari Cache
id: safari-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
@ -1409,7 +1409,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |
@ -1547,14 +1547,14 @@ jobs:
- name: Get Build Cache
id: build-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/desktop/build
key: ${{ runner.os }}-${{ github.run_id }}-build
- name: Setup Safari Cache
id: safari-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: apps/browser/dist/Safari
key: ${{ runner.os }}-${{ github.run_id }}-safari-extension
@ -1692,7 +1692,7 @@ jobs:
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
id: cache
with:
path: |

View File

@ -63,6 +63,11 @@ jobs:
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
steps:
- name: Log inputs to job summary
uses: bitwarden/ios/.github/actions/log-inputs@main
with:
inputs: "${{ toJson(inputs) }}"
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
@ -181,6 +186,19 @@ jobs:
ref: ${{ steps.set-server-ref.outputs.server_ref }}
persist-credentials: false
- name: Download SDK Artifacts
if: ${{ inputs.sdk_branch != '' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
artifacts: sdk-internal
repo: bitwarden/sdk-internal
path: sdk-internal
if_no_artifact_found: fail
- name: Check Branch to Publish
env:
PUBLISH_BRANCHES: "main,rc,hotfix-rc-web"

View File

@ -65,7 +65,7 @@ jobs:
- name: Cache NPM
id: npm-cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: "~/.npm"
key: ${{ runner.os }}-npm-chromatic-${{ hashFiles('**/package-lock.json') }}

View File

@ -14,13 +14,11 @@ permissions: {}
jobs:
testing:
name: Run tests
typecheck:
name: Run typechecking
runs-on: ubuntu-22.04
permissions:
checks: write
contents: read
pull-requests: write
steps:
- name: Check out repo
@ -56,17 +54,81 @@ jobs:
- name: Run typechecking
run: npm run test:types
- name: Run tests
testing:
name: Run tests - ${{ matrix.test-group.name }}
runs-on: ubuntu-22.04
permissions:
checks: write
contents: read
pull-requests: write
strategy:
fail-fast: false
matrix:
test-group:
- name: Browser
paths: apps/browser bitwarden_license/bit-browser
artifact: jest-coverage-browser
junit: junit-browser.xml
- name: Web
paths: apps/web bitwarden_license/bit-web
artifact: jest-coverage-web
junit: junit-web.xml
- name: Desktop
paths: apps/desktop
artifact: jest-coverage-desktop
junit: junit-desktop.xml
- name: CLI
paths: apps/cli bitwarden_license/bit-cli
artifact: jest-coverage-cli
junit: junit-cli.xml
- name: Libs
paths: libs bitwarden_license/bit-common
artifact: jest-coverage-libs
junit: junit-libs.xml
steps:
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Get Node Version
id: retrieve-node-version
run: |
NODE_NVMRC=$(cat .nvmrc)
NODE_VERSION=${NODE_NVMRC/v/''}
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
- name: Print environment
run: |
node --version
npm --version
- name: Install Node dependencies
run: npm ci
- name: Run tests - ${{ matrix.test-group.name }}
# maxWorkers is a workaround for a memory leak that crashes tests in CI:
# https://github.com/facebook/jest/issues/9430#issuecomment-1149882002
run: npm test -- --coverage --maxWorkers=3
# Reduced to 2 workers and split tests across parallel jobs to prevent OOM kills
run: npm test -- ${{ matrix.test-group.paths }} --coverage --maxWorkers=2
env:
JEST_JUNIT_OUTPUT_NAME: ${{ matrix.test-group.junit }}
- name: Report test results
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
path: "junit.xml"
name: Test Results - ${{ matrix.test-group.name }}
path: ${{ matrix.test-group.junit }}
reporter: jest-junit
fail-on-error: true
@ -78,7 +140,7 @@ jobs:
- name: Upload test coverage
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: jest-coverage
name: ${{ matrix.test-group.artifact }}
path: ./coverage/lcov.info
rust:
@ -183,11 +245,35 @@ jobs:
with:
persist-credentials: false
- name: Download jest coverage
- name: Download Browser coverage
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: jest-coverage
path: ./
name: jest-coverage-browser
path: ./jest-coverage-browser
- name: Download Web coverage
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: jest-coverage-web
path: ./jest-coverage-web
- name: Download Desktop coverage
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: jest-coverage-desktop
path: ./jest-coverage-desktop
- name: Download CLI coverage
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: jest-coverage-cli
path: ./jest-coverage-cli
- name: Download Libs coverage
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: jest-coverage-libs
path: ./jest-coverage-libs
- name: Download rust coverage
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
@ -199,5 +285,40 @@ jobs:
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
files: |
./lcov.info
./jest-coverage-browser/lcov.info
./jest-coverage-web/lcov.info
./jest-coverage-desktop/lcov.info
./jest-coverage-cli/lcov.info
./jest-coverage-libs/lcov.info
./apps/desktop/desktop_native/lcov.info
run-tests: # Verifies all required tests complete successfully
name: Run tests
runs-on: ubuntu-24.04
if: always()
needs:
- typecheck
- testing
- rust
- rust-coverage
- upload-codecov
permissions:
contents: read
steps:
- name: Check job results
env:
NEEDS: ${{ toJSON(needs) }}
run: |
# Print status of all jobs
echo "$NEEDS" | jq -r 'to_entries[] | "\(.key): \(.value.result)"'
# Collect failed jobs
failed_jobs=$(echo "$NEEDS" | jq -r 'to_entries[] | select(.value.result != "success") | .key' | tr '\n' ' ')
if [ -n "$failed_jobs" ]; then
echo "::error::The following jobs failed:$failed_jobs"
exit 1
fi
echo "All required jobs passed successfully!"

View File

@ -52,7 +52,7 @@ foreach ($subBuildPath in $subBuildPaths) {
"--verbose",
"--force",
"--sign",
"588E3F1724AE018EBA762E42279DAE85B313E3ED",
"A579B6AE496B360642D05B8AB1B650C1B143B770",
"--entitlements",
$entitlementsPath
)

View File

@ -990,6 +990,12 @@
"no": {
"message": "لا"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "الموقع"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "البريد الإلكتروني"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "الهاتف"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -29,7 +29,7 @@
"message": "Keçid açarı ilə giriş et"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "Kilidi keçid açarı ilə aç"
},
"useSingleSignOn": {
"message": "Vahid daxil olma üsulunu istifadə et"
@ -990,6 +990,12 @@
"no": {
"message": "Xeyr"
},
"noAuth": {
"message": "Keçidə sahib olan hər kəs"
},
"anyOneWithPassword": {
"message": "Sizin təyin etdiyiniz parola sahib hər kəs"
},
"location": {
"message": "Yerləşmə"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-poçt"
},
"emails": {
"message": "E-poçtlar"
},
"phone": {
"message": "Telefon"
},
@ -2477,7 +2486,7 @@
"message": "Element birdəfəlik silindi"
},
"archivedItemRestored": {
"message": "Archived item restored"
"message": "Arxivlənmiş element bərpa edildi"
},
"restoreItem": {
"message": "Elementi bərpa et"
@ -3371,10 +3380,10 @@
"message": "Xəta"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
"message": "Kilid keçid açarı ilə açılmadı. Lütfən yenidən sınayın, ya da başqa kilid açma üsulunu sınayın."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
"message": "Kilidi açmaq üçün PRF dəstəkli keçid açarı yoxdur. Lütfən əvvəlcə keçid açarı ilə giriş edin."
},
"decryptionError": {
"message": "Şifrə açma xətası"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Qoşmanı endir"
},
"downloadBitwarden": {
"message": "Bitwarden-i endir"
},
@ -5683,7 +5695,7 @@
"message": "Ekstra enli"
},
"narrow": {
"message": "Narrow"
"message": "Dar"
},
"sshKeyWrongPassword": {
"message": "Daxil etdiyiniz parol yanlışdır."
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Yan naviqasiyanı yeni. ölçüləndir"
},
"whoCanView": {
"message": "Kimlər baxa bilər"
},
"specificPeople": {
"message": "Xüsusi insanlar"
},
"emailVerificationDesc": {
"message": "Bu Send keçidini paylaşdıqdan sonra, bu \"Send\"ə baxması üçün insanlar e-poçtlarını bir kodla doğrulamalıdırlar."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Birdən çox e-poçtu daxil edərkən vergül istifadə edin."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Не"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Электронная пошта"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Тэлефон"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -29,7 +29,7 @@
"message": "Вписване със секретен ключ"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "Отключване със секретен ключ"
},
"useSingleSignOn": {
"message": "Използване на еднократна идентификация"
@ -990,6 +990,12 @@
"no": {
"message": "Не"
},
"noAuth": {
"message": "Всеки с връзката"
},
"anyOneWithPassword": {
"message": "Всеки с парола, зададена от Вас"
},
"location": {
"message": "Местоположение"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Електронна поща"
},
"emails": {
"message": "Е-пощи"
},
"phone": {
"message": "Телефон"
},
@ -3371,10 +3380,10 @@
"message": "Грешка"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
"message": "Отключването със секретен ключ не беше успешно. Опитайте отново или използвайте друг начин за отключване."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
"message": "Няма секретни ключове с включено PRF, налични за отключване. Първо се впишете със секретен ключ."
},
"decryptionError": {
"message": "Грешка при дешифриране"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Сваляне на прикачения файл"
},
"downloadBitwarden": {
"message": "Сваляне на Битуорден"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Преоразмеряване на страничната навигация"
},
"whoCanView": {
"message": "Кой може да преглежда"
},
"specificPeople": {
"message": "Определени хора"
},
"emailVerificationDesc": {
"message": "След като споделите тази връзка към Изпращане, хората ще трябва да потвърдят е-пощата си чрез код, за да могат да видят това Изпращане."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Можете да въведете повече е-пощи, като ги разделите със запетая."
},
"emailPlaceholder": {
"message": "потребител@bitwarden.com , потребител@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "না"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "ই-মেইল"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "ফোন"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefon"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Ubicació"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Correu electrònic"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telèfon"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Ne"
},
"noAuth": {
"message": "Kdokoli s odkazem"
},
"anyOneWithPassword": {
"message": "Kdokoli s heslem od Vás"
},
"location": {
"message": "Umístění"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-mail"
},
"emails": {
"message": "E-maily"
},
"phone": {
"message": "Telefon"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Stáhnout přílohu"
},
"downloadBitwarden": {
"message": "Stáhnout Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Změnit velikost boční navigace"
},
"whoCanView": {
"message": "Kdo může zobrazit"
},
"specificPeople": {
"message": "Vybraní lidé"
},
"emailVerificationDesc": {
"message": "Po sdílení tohoto odkazu Send budou muset jednotlivci ověřit svůj e-mail pomocí kódu pro zobrazení tohoto Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Zadejte více e-mailů oddělených čárkou."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Na"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Lleoliad"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Ebost"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Ffôn"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Nej"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-mail"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefon"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -29,7 +29,7 @@
"message": "Mit Passkey anmelden"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "Mit Passkey entsperren"
},
"useSingleSignOn": {
"message": "Single Sign-On verwenden"
@ -990,6 +990,12 @@
"no": {
"message": "Nein"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Standort"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-Mail"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefon"
},
@ -3371,10 +3380,10 @@
"message": "Fehler"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
"message": "Entsperren mit Passkey fehlgeschlagen. Bitte versuche es erneut oder verwende eine andere Entsperrmethode."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
"message": "Es sind keine PRF-fähigen Passkeys zum Entsperren verfügbar. Bitte melde dich zuerst mit einem Passkey an."
},
"decryptionError": {
"message": "Entschlüsselungsfehler"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Anhang herunterladen"
},
"downloadBitwarden": {
"message": "Bitwarden herunterladen"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Größe der Seitennavigation ändern"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Όχι"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Τοποθεσία"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Τηλέφωνο"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Λήψη του Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Ubicación"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Correo electrónico"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Teléfono"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Descargar Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Ei"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-post"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefoninumber"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Ez"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Emaila"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefonoa"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "خیر"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "موقعیت"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "ایمیل"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "تلفن"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "بارگیری Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "En"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Sijainti"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Sähköposti"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Puhelinnumero"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Lataa Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Hindi"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Mag-email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telepono"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Non"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Emplacement"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Courriel"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Téléphone"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Télécharger Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Non"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Correo electrónico"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Teléfono"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "לא"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "מיקום"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "אימייל"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "טלפון"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "הורד את Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -3,7 +3,7 @@
"message": "bitwarden"
},
"appLogoLabel": {
"message": "Bitwarden logo"
"message": "बिटवार्डन लोगो"
},
"extName": {
"message": "बिटवार्डन पासवर्ड मैनेजर",
@ -26,16 +26,16 @@
"message": "बिटवार्डन का परिचय"
},
"logInWithPasskey": {
"message": "Log in with passkey"
"message": "पासकी से लॉग इन करें"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "पासकी से अनलॉक करें"
},
"useSingleSignOn": {
"message": "सिंगल साइन-ऑन प्रयोग करें"
},
"yourOrganizationRequiresSingleSignOn": {
"message": "Your organization requires single sign-on."
"message": "आपके संगठन को सिंगल साइन-ऑन करना आवश्यक है।"
},
"welcomeBack": {
"message": "आपका पुन: स्वागत है!"
@ -71,7 +71,7 @@
"message": "मास्टर पासवर्ड संकेत आपको भूल जाने की अवस्था में पासवर्ड को याद करने में सहायता करता है।"
},
"masterPassHintText": {
"message": "If you forget your password, the password hint can be sent to your email. $CURRENT$/$MAXIMUM$ character maximum.",
"message": "अगर आप अपना पासवर्ड भूल गए हैं, तो पासवर्ड संकेत आपके ईमेल पर भेजा जा सकता है। $CURRENT$/$MAXIMUM$ अक्षर अधिकतम।",
"placeholders": {
"current": {
"content": "$1",
@ -90,7 +90,7 @@
"message": "Master Password Hint (optional)"
},
"passwordStrengthScore": {
"message": "Password strength score $SCORE$",
"message": "पासवर्ड की मज़बूती का स्कोर $SCORE$",
"placeholders": {
"score": {
"content": "$1",
@ -99,10 +99,10 @@
}
},
"joinOrganization": {
"message": "Join organization"
"message": "ऑर्गनाइज़ेशन में शामिल हों"
},
"joinOrganizationName": {
"message": "Join $ORGANIZATIONNAME$",
"message": "$ORGANIZATIONNAME$ से जुड़ें",
"placeholders": {
"organizationName": {
"content": "$1",
@ -111,7 +111,7 @@
}
},
"finishJoiningThisOrganizationBySettingAMasterPassword": {
"message": "Finish joining this organization by setting a master password."
"message": "मास्टर पासवर्ड सेट करके इस ऑर्गनाइज़ेशन से जुड़ने की प्रक्रिया पूरी करें।"
},
"tab": {
"message": "टैब"
@ -138,7 +138,7 @@
"message": "Copy Password"
},
"copyPassphrase": {
"message": "Copy passphrase"
"message": "पासफ़्रेज़ कॉपी करें"
},
"copyNote": {
"message": "Copy Note"
@ -162,22 +162,22 @@
"message": "कंपनी के नाम को कॉपी करें"
},
"copySSN": {
"message": "Copy Social Security number"
"message": "सामाजिक सुरक्षा संख्या या आधारकार्ड संख्या कॉपी करें"
},
"copyPassportNumber": {
"message": "Copy passport number"
"message": "पासपोर्ट नंबर कॉपी करें"
},
"copyLicenseNumber": {
"message": "Copy license number"
},
"copyPrivateKey": {
"message": "Copy private key"
"message": "प्राइवेट की कॉपी करें"
},
"copyPublicKey": {
"message": "Copy public key"
},
"copyFingerprint": {
"message": "Copy fingerprint"
"message": "फिंगरप्रिंट कॉपी करें"
},
"copyCustomField": {
"message": "Copy $FIELD$",
@ -195,11 +195,11 @@
"message": "Copy notes"
},
"copy": {
"message": "Copy",
"message": "कॉपी करें",
"description": "Copy to clipboard"
},
"fill": {
"message": "Fill",
"message": "भरें",
"description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible."
},
"autoFill": {
@ -215,7 +215,7 @@
"message": "स्वचालित पहचान विवरण"
},
"fillVerificationCode": {
"message": "Fill verification code"
"message": "सत्यापन कोड भरें"
},
"fillVerificationCodeAria": {
"message": "Fill Verification Code",
@ -261,16 +261,16 @@
"message": "Add Item"
},
"accountEmail": {
"message": "Account email"
"message": "अकाउंट का ईमेल"
},
"requestHint": {
"message": "Request hint"
"message": "संकेत का अनुरोध करें"
},
"requestPasswordHint": {
"message": "Request password hint"
"message": "पासवर्ड संकेत का अनुरोध करें"
},
"enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": {
"message": "Enter your account email address and your password hint will be sent to you"
"message": "अपना अकाउंट ईमेल पता डालें और आपको आपका पासवर्ड संकेत भेज दिया जाएगा"
},
"getMasterPasswordHint": {
"message": "मास्टर पासवर्ड संकेत प्राप्त करें"
@ -297,7 +297,7 @@
"message": "Change Master Password"
},
"continueToWebApp": {
"message": "Continue to web app?"
"message": "वेब ऐप पर जारी रखें?"
},
"continueToWebAppDesc": {
"message": "Explore more features of your Bitwarden account on the web app."
@ -368,7 +368,7 @@
"message": "Free Bitwarden Families"
},
"freeBitwardenFamiliesPageDesc": {
"message": "You are eligible for Free Bitwarden Families. Redeem this offer today in the web app."
"message": "आप फ्री बिटवर्डन फैमिलीज़ के लिए एलिजिबल हैं। इस ऑफर को आज ही वेब ऐप में रिडीम करें।"
},
"version": {
"message": "संस्करण"
@ -990,6 +990,12 @@
"no": {
"message": "नहीं"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "ईमेल"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "फोन"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "अटैचमेंट डाउनलोड करें"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Ne"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Lokacija"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-pošta"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefon"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Preuzmi Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Nem"
},
"noAuth": {
"message": "Bárki ezzel a hivatkozással"
},
"anyOneWithPassword": {
"message": "Bárki az általam beállított jelszóval"
},
"location": {
"message": "Hely"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-mail"
},
"emails": {
"message": "Email címek"
},
"phone": {
"message": "Telefonszám"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Melléklet letöltése"
},
"downloadBitwarden": {
"message": "Bitwarden letöltése"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Oldalnavigáció átméretezés"
},
"whoCanView": {
"message": "Ki láthatja"
},
"specificPeople": {
"message": "Adott személyek"
},
"emailVerificationDesc": {
"message": "A Send hivatkozás megosztása után a személyeknek ellenőrizniük kell email címüket egy kóddal a Send megtekintéséhez."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Írjunk be több email címet vesszővel elválasztva."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -228,7 +228,7 @@
"message": "Salin Nama Kolom Pilihan"
},
"noMatchingLogins": {
"message": "Tidak ada info masuk yang cocok."
"message": "Tidak ada log masuk yang cocok"
},
"noCards": {
"message": "Tanpa kartu"
@ -440,7 +440,7 @@
"message": "Sinkronisasi"
},
"syncNow": {
"message": "Sync now"
"message": "Selaraskan sekarang"
},
"lastSync": {
"message": "Sinkronisasi Terakhir:"
@ -554,27 +554,27 @@
"message": "Atur ulang pencarian"
},
"archiveNoun": {
"message": "Archive",
"message": "Arsip",
"description": "Noun"
},
"archiveVerb": {
"message": "Archive",
"message": "Arsip",
"description": "Verb"
},
"unArchive": {
"message": "Unarchive"
},
"itemsInArchive": {
"message": "Items in archive"
"message": "Butir dalam arsip"
},
"noItemsInArchive": {
"message": "No items in archive"
"message": "Tidak ada butir dalam arsip"
},
"noItemsInArchiveDesc": {
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
"message": "Butir yang diarsipkan akan muncul di sini dan akan dikecualikan dari hasil pencarian umum dan saran isi otomatis."
},
"itemWasSentToArchive": {
"message": "Item was sent to archive"
"message": "Butir dikirim ke arsip"
},
"itemWasUnarchived": {
"message": "Item was unarchived"
@ -583,7 +583,7 @@
"message": "Item was unarchived"
},
"archiveItem": {
"message": "Archive item"
"message": "Arsipkan butir"
},
"archiveItemDialogContent": {
"message": "Once archived, this item will be excluded from search results and autofill suggestions."
@ -990,6 +990,12 @@
"no": {
"message": "Tidak"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Lokasi"
},
@ -1480,7 +1486,7 @@
"message": "Tidak ada lampiran."
},
"attachmentSaved": {
"message": "Lampiran telah disimpan."
"message": "Lampiran disimpan"
},
"fixEncryption": {
"message": "Fix encryption"
@ -1498,7 +1504,7 @@
"message": "Berkas untuk dibagikan"
},
"selectFile": {
"message": "Pilih berkas."
"message": "Pilih berkas"
},
"itemsTransferred": {
"message": "Items transferred"
@ -1646,7 +1652,7 @@
"message": "Buka dalam tab baru"
},
"webAuthnAuthenticate": {
"message": "Autentikasi dengan WebAuthn."
"message": "Autentikasikan WebAuthn"
},
"readSecurityKey": {
"message": "Baca kunci keamanan"
@ -1755,7 +1761,7 @@
"message": "URL Server Ikon"
},
"environmentSaved": {
"message": "URL dari semua lingkungan telah disimpan."
"message": "URL lingkungan disimpan"
},
"showAutoFillMenuOnFormFields": {
"message": "Tampilkan menu isi otomatis pada kolom formulir",
@ -1855,7 +1861,7 @@
"message": "Pelajari lebih lanjut tentang isi otomatis"
},
"defaultAutoFillOnPageLoad": {
"message": "Konfigurasi autofill standard untuk item login."
"message": "Pengaturan isian otomatis baku bagi butir log masuk"
},
"defaultAutoFillOnPageLoadDesc": {
"message": "Setelah mengaktifkan Auto-Fill waktu website terbuka, kamu dapat mengaktifkan atau meng-nonaktifkan feature ini untuk setiap item. Ini adalah konfigurasi standard untuk item yang tidak dikonfigurasi terpisah."
@ -1885,7 +1891,7 @@
"message": "Isi otomatis identitas yang terakhir digunakan untuk situs web saat ini"
},
"commandGeneratePasswordDesc": {
"message": "Buat dan salin kata sandi acak baru ke papan klip."
"message": "Buat dan salin kata sandi acak baru ke papan klip"
},
"commandLockVaultDesc": {
"message": "Kunci brankas"
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telepon"
},
@ -2504,10 +2513,10 @@
"message": "Item yang Diisi Otomatis dan URI Tersimpan"
},
"autoFillSuccess": {
"message": "Item Terisi Otomatis"
"message": "Butir terisi otomatis "
},
"insecurePageWarning": {
"message": "Peringatan: Ini adalah halaman HTTP yang tidak aman, dan setiap informasi yang Anda kirim dapat berpotensi terlihat dan diubah oleh orang lain. Login ini awalnya disimpan di halaman aman (HTTPS) "
"message": "Peringatan: Ini adalah halaman HTTP yang tidak aman, dan setiap informasi yang Anda kirim dapat berpotensi terlihat dan diubah oleh orang lain. Log masuk ini awalnya disimpan di halaman aman (HTTPS)."
},
"insecurePageWarningFillPrompt": {
"message": "Anda masih ingin mengisi login ini?"
@ -3210,7 +3219,7 @@
"message": "Persyaratan kebijakan perusahaan telah diterapkan ke pilihan batas waktu Anda"
},
"vaultTimeoutPolicyInEffect": {
"message": "Kebijakan organisasi Anda memengaruhi waktu tunggu brankas Anda. Batas maksimal Waktu Tunggu Brankas yang diizinkan adalah $HOURS$ jam dan $MINUTES$ menit",
"message": "Kebijakan organisasi Anda telah menata waktu tunggu brankas maksimum yang diizinkan milik Anda ke $HOURS$ jam $MINUTES$ menit.",
"placeholders": {
"hours": {
"content": "$1",
@ -3302,7 +3311,7 @@
"message": "Hapus Kata Sandi Utama"
},
"removedMasterPassword": {
"message": "Sandi utama dihapus."
"message": "Sandi utama dihapus"
},
"leaveOrganizationConfirmation": {
"message": "Apakah Anda yakin ingin meninggalkan organisasi ini?"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Unduh Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Luogo"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefono"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Scarica Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Ridimensiona la navigazione laterale"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "いいえ"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "場所"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "メールアドレス"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "電話番号"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Bitwarden をダウンロード"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "არა"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "ელ-ფოსტა"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "ტელეფონი"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "ಇಲ್ಲ"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "ಇಮೇಲ್"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "ಫೋನ್‌"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "아니오"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "이메일"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "전화번호"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Ne"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "El. paštas"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefonas"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -29,7 +29,7 @@
"message": "Pieteikties ar piekļuves atslēgu"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "Atslēgt ar piekļuves atslēgu"
},
"useSingleSignOn": {
"message": "Izmantot vienoto pieteikšanos"
@ -990,6 +990,12 @@
"no": {
"message": "Nē"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Atrašanās vieta"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-pasts"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Tālrunis"
},
@ -3371,10 +3380,10 @@
"message": "Kļūda"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
"message": "Neizdevās atslēgt ar piekļuves atslēgu. Lūgums mēģināt vēlreiz vai izmantot citu atslēgšanas veidu."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
"message": "Atslēgšanai nav pieejama neviena PRF iespējota piekļuves atslēga. Lūgums vispirms pieteikties ar piekļuves atslēgu."
},
"decryptionError": {
"message": "Atšifrēšanas kļūda"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Lejupielādēt pielikumu"
},
"downloadBitwarden": {
"message": "Lejupielādē Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Mainīt sānu pārvietošanās joslas izmēru"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "തെറ്റ്"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "ഇമെയിൽ"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "ഫോൺ"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Nei"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Sted"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-post"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefon"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Last ned Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -29,7 +29,7 @@
"message": "Inloggen met passkey"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "Ontgrendelen met passkey"
},
"useSingleSignOn": {
"message": "Single sign-on gebruiken"
@ -990,6 +990,12 @@
"no": {
"message": "Nee"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Locatie"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-mailadres"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefoonnummer"
},
@ -3371,10 +3380,10 @@
"message": "Fout"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
"message": "Ontgrendelen met passkey mislukt. Probeer het opnieuw of gebruik een andere ontgrendelingsmethode."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
"message": "Er zijn geen PRF-ingeschakelde passkeys beschikbaar om te ontgrendelen. Log eerst in met een passkey."
},
"decryptionError": {
"message": "Ontsleutelingsfout"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Bijlage downloaden"
},
"downloadBitwarden": {
"message": "Bitwarden downloaden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Formaat zijnavigatie wijzigen"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Nie"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Lokalizacja"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Adres e-mail"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Numer telefonu"
},
@ -4845,7 +4854,7 @@
"message": "Konsola administratora"
},
"admin": {
"message": "Admin"
"message": "Administrator"
},
"automaticUserConfirmation": {
"message": "Automatic user confirmation"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Pobierz załącznik"
},
"downloadBitwarden": {
"message": "Pobierz Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Zmień rozmiar nawigacji bocznej"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -29,7 +29,7 @@
"message": "Conectar-se com chave de acesso"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "Desbloquear com chave de acesso"
},
"useSingleSignOn": {
"message": "Usar autenticação única"
@ -990,6 +990,12 @@
"no": {
"message": "Não"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Localização"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-mail"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefone"
},
@ -3371,10 +3380,10 @@
"message": "Erro"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
"message": "Falha no desbloqueio com a chave de acesso. Tente novamente ou use outro método de desbloqueio."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
"message": "Nenhuma chave de acesso com PRF está disponível para desbloqueio. Conecte-se com uma chave de acesso primeiro."
},
"decryptionError": {
"message": "Erro de descriptografia"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Baixar anexo"
},
"downloadBitwarden": {
"message": "Baixar o Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Redimensionar navegação lateral"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Não"
},
"noAuth": {
"message": "Qualquer pessoa com o link"
},
"anyOneWithPassword": {
"message": "Qualquer pessoa com uma palavra-passe definida por si"
},
"location": {
"message": "Localização"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-mail"
},
"emails": {
"message": "E-mails"
},
"phone": {
"message": "Telefone"
},
@ -4067,7 +4076,7 @@
}
},
"inputMinValue": {
"message": "O valor do campo tem de ser, pelo menos, $MIN$ caracteres.",
"message": "O valor introduzido deve ser, no mínimo, $MIN$.",
"placeholders": {
"min": {
"content": "$1",
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Transferir anexo"
},
"downloadBitwarden": {
"message": "Descarregar o Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Redimensionar navegação lateral"
},
"whoCanView": {
"message": "Quem pode ver"
},
"specificPeople": {
"message": "Pessoas específicas"
},
"emailVerificationDesc": {
"message": "Após partilhar este Send através do link, os indivíduos terão de verificar o e-mail com um código para poderem ver este Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Introduza vários e-mails, separados por vírgula."
},
"emailPlaceholder": {
"message": "utilizador@bitwarden.com , utilizador@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Nu"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-mail"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefon"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -29,7 +29,7 @@
"message": "Войти с passkey"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "Разблокировать при помощи passkey"
},
"useSingleSignOn": {
"message": "Использовать единый вход"
@ -990,6 +990,12 @@
"no": {
"message": "Нет"
},
"noAuth": {
"message": "Любой, у кого есть ссылка"
},
"anyOneWithPassword": {
"message": "Любой, у кого есть установленный вами пароль"
},
"location": {
"message": "Местоположение"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Телефон"
},
@ -3371,10 +3380,10 @@
"message": "Ошибка"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
"message": "Не удалось разблокировать с помощью passkey. Пожалуйста, повторите попытку или используйте другой метод разблокировки."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
"message": "Для разблокировки недоступны passkeys с поддержкой PRF. Пожалуйста, сначала авторизуйтесь, используя passkey."
},
"decryptionError": {
"message": "Ошибка расшифровки"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Скачать вложение"
},
"downloadBitwarden": {
"message": "Скачать Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Изменить размер боковой навигации"
},
"whoCanView": {
"message": "Кто может просматривать"
},
"specificPeople": {
"message": "Конкретные пользователи"
},
"emailVerificationDesc": {
"message": "После того, как вы поделитесь ссылкой на Send, пользователю нужно будет подтвердить свой email кодом, чтобы просмотреть эту Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Введите несколько email, разделяя их запятой."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "නැත"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "ඊ-තැපැල්"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "දුරකථන"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Nie"
},
"noAuth": {
"message": "Ktokoľvek s odkazom"
},
"anyOneWithPassword": {
"message": "Ktokoľvek s heslom od vás"
},
"location": {
"message": "Poloha"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "E-maily"
},
"phone": {
"message": "Telefón"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Stiahnuť prílohu"
},
"downloadBitwarden": {
"message": "Stiahnuť Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Zmeniť veľkosť bočnej navigácie"
},
"whoCanView": {
"message": "Kto môže zobraziť"
},
"specificPeople": {
"message": "Konkrétne osoby"
},
"emailVerificationDesc": {
"message": "Po zdieľaní tohto odkazu na Send budú musieť jednotlivci overiť svoju e-mailovú adresu pomocou kódu na zobrazenie tohto Sendu."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Zadajte viacero e-mailových adries oddelených čiarkou."
},
"emailPlaceholder": {
"message": "pouzivate@bitwarden.com, pouzivatel@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Ne"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-pošta"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Telefon"
},
@ -4947,7 +4956,7 @@
"message": "Organization is deactivated"
},
"owner": {
"message": "Owner"
"message": "Lastnik"
},
"selfOwnershipLabel": {
"message": "You",
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Не"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Локација"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Имејл"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Телефон"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Преузети Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -29,7 +29,7 @@
"message": "Logga in med nyckel"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "Lås upp med lösennyckel"
},
"useSingleSignOn": {
"message": "Använd Single Sign-On"
@ -990,6 +990,12 @@
"no": {
"message": "Nej"
},
"noAuth": {
"message": "Vem som helst med länken"
},
"anyOneWithPassword": {
"message": "Alla som har ett lösenord inställt av dig"
},
"location": {
"message": "Plats"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-post"
},
"emails": {
"message": "E-post"
},
"phone": {
"message": "Telefon"
},
@ -3371,10 +3380,10 @@
"message": "Fel"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
"message": "Det gick inte att låsa upp med lösennyckel. Försök igen eller använd en annan upplåsningsmetod."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
"message": "Inga PRF-aktiverade lösennycklar finns tillgängliga för upplåsning. Logga in med en lösennyckel först."
},
"decryptionError": {
"message": "Dekrypteringsfel"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Ladda ned bilaga"
},
"downloadBitwarden": {
"message": "Ladda ner Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Ändra storlek på sidnavigering"
},
"whoCanView": {
"message": "Vem kan se"
},
"specificPeople": {
"message": "Specifika personer"
},
"emailVerificationDesc": {
"message": "Efter att ha delat denna Send-länk kommer individer att behöva verifiera sin e-post med en kod för att visa denna Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Ange flera e-postadresser genom att separera dem med kommatecken."
},
"emailPlaceholder": {
"message": "användare@bitwarden.com , användare@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "இல்லை"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "இருப்பிடம்"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "மின்னஞ்சல்"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "தொலைபேசி"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Bitwarden-ஐப் பதிவிறக்கு"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "No"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Location"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Phone"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Download Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "ไม่"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "ตำแหน่งที่ตั้ง"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "อีเมล"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "โทรศัพท์"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "ดาวน์โหลด Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -29,7 +29,7 @@
"message": "Geçiş anahtarıyla giriş yap"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "Kilidi geçiş anahtarıyla aç"
},
"useSingleSignOn": {
"message": "Çoklu oturum açma kullan"
@ -990,6 +990,12 @@
"no": {
"message": "Hayır"
},
"noAuth": {
"message": "Bağlantıya sahip olan herkes"
},
"anyOneWithPassword": {
"message": "Belirlediğiniz parolaya sahip olan herkes"
},
"location": {
"message": "Konum"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "E-posta"
},
"emails": {
"message": "E-postalar"
},
"phone": {
"message": "Telefon"
},
@ -3371,10 +3380,10 @@
"message": "Hata"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
"message": "Kilit geçiş anahtarıyla açılamadı. Lütfen yeniden deneyin veya başka bir kilit açma yöntemi kullanın."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
"message": "Kilit açma için PRF uyumlu bir geçiş anahtarı bulunamadı. Lütfen önce bir geçiş anahtarıyla giriş yapın."
},
"decryptionError": {
"message": "Şifre çözme sorunu"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Ek dosyayı indir"
},
"downloadBitwarden": {
"message": "Bitwardenı indirin"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Kenar menüsünü yeniden boyutlandır"
},
"whoCanView": {
"message": "Kim görebilir"
},
"specificPeople": {
"message": "Belirli kişiler"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "E-posta adreslerini virgülle ayırarak yazın."
},
"emailPlaceholder": {
"message": "kullanici@bitwarden.com , kullanici@acme.com"
}
}
}

View File

@ -29,7 +29,7 @@
"message": "Увійти з ключем доступу"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "Розблокувати з ключем доступу"
},
"useSingleSignOn": {
"message": "Використати єдиний вхід"
@ -990,6 +990,12 @@
"no": {
"message": "Ні"
},
"noAuth": {
"message": "Будь-хто з посиланням"
},
"anyOneWithPassword": {
"message": "Будь-хто зі встановленим вами паролем"
},
"location": {
"message": "Розташування"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Е-пошта"
},
"emails": {
"message": "Е-пошти"
},
"phone": {
"message": "Телефон"
},
@ -3371,10 +3380,10 @@
"message": "Помилка"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
"message": "Не вдалося розблокувати за допомогою ключа доступу. Повторіть спробу або скористайтеся іншим способом розблокування."
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
"message": "Немає ключів доступу з підтримкою PRF, доступних для розблокування. Спочатку увійдіть з ключем доступу."
},
"decryptionError": {
"message": "Помилка розшифрування"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Завантажити вкладення"
},
"downloadBitwarden": {
"message": "Завантажити Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Змінити розмір бічної панелі"
},
"whoCanView": {
"message": "Хто може переглядати"
},
"specificPeople": {
"message": "Певні люди"
},
"emailVerificationDesc": {
"message": "Після того, як ви поділитеся цим посиланням на відправлення, особам необхідно буде підтвердити свої е-пошти за допомогою коду, щоб переглянути це відправлення."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Введіть декілька адрес е-пошти, розділяючи їх комою."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "Không"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "Vị trí"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "Email"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "Số điện thoại"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "Tải xuống Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "Thay đổi kích thước thanh bên"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -29,7 +29,7 @@
"message": "使用通行密钥登录"
},
"unlockWithPasskey": {
"message": "Unlock with passkey"
"message": "使用通行密钥解锁"
},
"useSingleSignOn": {
"message": "使用单点登录"
@ -990,6 +990,12 @@
"no": {
"message": "否"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "位置"
},
@ -1846,7 +1852,7 @@
"message": "网页加载时如果检测到登录表单,则执行自动填充。"
},
"experimentalFeature": {
"message": "不完整或不信任的网站可以利用页面加载时的自动填充功能。"
"message": "被攻破或不受信任的网站可能会利用页面加载时的自动填充功能。"
},
"learnMoreAboutAutofillOnPageLoadLinkText": {
"message": "进一步了解风险"
@ -2048,6 +2054,9 @@
"email": {
"message": "电子邮箱"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "电话"
},
@ -2243,7 +2252,7 @@
}
},
"passwordSafe": {
"message": "没有在已知的数据泄露中发现此密码,它暂时比较安全。"
"message": "在任何已知的数据泄露中均未发现此密码。它暂时比较安全。"
},
"baseDomain": {
"message": "基础域名",
@ -3054,11 +3063,11 @@
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendExpiresInHoursSingle": {
"message": "在接下来的 1 小时内,任何人都可以通过链接访问此 Send。",
"message": "在接下来的 1 小时内,拥有此链接的任何人都可以访问此 Send。",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendExpiresInHours": {
"message": "在接下来的 $HOURS$ 小时内,任何人都可以通过链接访问此 Send。",
"message": "在接下来的 $HOURS$ 小时内,拥有此链接的任何人都可以访问此 Send。",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.",
"placeholders": {
"hours": {
@ -3068,11 +3077,11 @@
}
},
"sendExpiresInDaysSingle": {
"message": "在接下来的 1 天内,任何人都可以通过链接访问此 Send。",
"message": "在接下来的 1 天内,拥有此链接的任何人都可以访问此 Send。",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendExpiresInDays": {
"message": "在接下来的 $DAYS$ 天内,任何人都可以通过链接访问此 Send。",
"message": "在接下来的 $DAYS$ 天内,拥有此链接的任何人都可以访问此 Send。",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.",
"placeholders": {
"days": {
@ -3371,10 +3380,10 @@
"message": "错误"
},
"prfUnlockFailed": {
"message": "Failed to unlock with passkey. Please try again or use another unlock method."
"message": "使用通行密钥解锁失败。请重试或使用其他解锁方式。"
},
"noPrfCredentialsAvailable": {
"message": "No PRF-enabled passkeys are available for unlock. Please log in with a passkey first."
"message": "没有可用于解锁的 PRF 通行密钥。请先使用通行密钥登录。"
},
"decryptionError": {
"message": "解密错误"
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "下载附件"
},
"downloadBitwarden": {
"message": "下载 Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "调整侧边导航栏大小"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -990,6 +990,12 @@
"no": {
"message": "否"
},
"noAuth": {
"message": "Anyone with the link"
},
"anyOneWithPassword": {
"message": "Anyone with a password set by you"
},
"location": {
"message": "位置"
},
@ -2048,6 +2054,9 @@
"email": {
"message": "電子郵件"
},
"emails": {
"message": "Emails"
},
"phone": {
"message": "電話號碼"
},
@ -5001,6 +5010,9 @@
}
}
},
"downloadAttachmentLabel": {
"message": "Download Attachment"
},
"downloadBitwarden": {
"message": "下載 Bitwarden"
},
@ -6117,5 +6129,20 @@
},
"resizeSideNavigation": {
"message": "調整側邊欄大小"
},
"whoCanView": {
"message": "Who can view"
},
"specificPeople": {
"message": "Specific people"
},
"emailVerificationDesc": {
"message": "After sharing this Send link, individuals will need to verify their email with a code to view this Send."
},
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
}
}
}

View File

@ -2,6 +2,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SecurityTask } from "@bitwarden/common/vault/tasks";
import AutofillPageDetails from "../../models/autofill-page-details";
import { NotificationTypes } from "../../notification/abstractions/notification-bar";
export type NotificationTypeData = {
isVaultLocked?: boolean;
@ -17,10 +18,26 @@ export type LoginSecurityTaskInfo = {
uri: ModifyLoginCipherFormData["uri"];
};
/**
* Distinguished from `NotificationTypes` in that this represents the
* pre-resolved notification scenario, vs the notification component
* (e.g. "Add" and "Change" will be removed
* post-`useUndeterminedCipherScenarioTriggeringLogic` migration)
*/
export const NotificationScenarios = {
...NotificationTypes,
/** represents scenarios handling saving new and updated ciphers after form submit */
Cipher: "cipher",
} as const;
export type NotificationScenario =
(typeof NotificationScenarios)[keyof typeof NotificationScenarios];
export type WebsiteOriginsWithFields = Map<chrome.tabs.Tab["id"], Set<string>>;
export type ActiveFormSubmissionRequests = Set<chrome.webRequest.WebRequestDetails["requestId"]>;
/** This type represents an expectation of nullish values being represented as empty strings */
export type ModifyLoginCipherFormData = {
uri: string;
username: string;

View File

@ -249,6 +249,20 @@ describe("AutoSubmitLoginBackground", () => {
false,
);
});
it("properly cleans up auto-submit workflows when requestInitiator is falsy but active auto-submit hosts exist", async () => {
webRequestDetails.initiator = undefined;
jest
.spyOn(BrowserApi, "getTab")
.mockResolvedValue(mock<chrome.tabs.Tab>({ url: validAutoSubmitUrl, id: 1 }));
triggerWebRequestOnBeforeRequestEvent(webRequestDetails);
await flushPromises();
expect(autoSubmitLoginBackground["validAutoSubmitHosts"].has(validAutoSubmitHost)).toBe(
false,
);
});
});
describe("when the extension is running on a Safari browser", () => {

View File

@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { filter, firstValueFrom, of, switchMap } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@ -64,6 +62,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
this.policyService.policiesByType$(PolicyType.AutomaticAppLogIn, userId),
),
getFirstPolicy,
filter((policy): policy is Policy => policy !== undefined),
)
.subscribe(this.handleAutoSubmitLoginPolicySubscription.bind(this));
}
@ -165,7 +164,11 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
details: chrome.webRequest.OnBeforeRequestDetails,
): undefined => {
const requestInitiator = this.getRequestInitiator(details);
const isValidInitiator = this.isValidInitiator(requestInitiator);
if (!requestInitiator && this.validAutoSubmitHosts.size === 0) {
return;
}
const isValidInitiator = requestInitiator ? this.isValidInitiator(requestInitiator) : false;
if (
this.postRequestEncounteredAfterSubmission(details, isValidInitiator) ||
@ -175,14 +178,20 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
return;
}
if (isValidInitiator && this.shouldRouteTriggerAutoSubmit(details, requestInitiator)) {
if (
requestInitiator &&
isValidInitiator &&
this.shouldRouteTriggerAutoSubmit(details, requestInitiator)
) {
this.setupAutoSubmitFlow(details);
return;
}
this.disableAutoSubmitFlow(requestInitiator, details).catch((error) =>
this.logService.error(error),
);
if (requestInitiator || this.validAutoSubmitHosts.size > 0) {
this.disableAutoSubmitFlow(requestInitiator || "", details).catch((error) =>
this.logService.error(error),
);
}
};
/**
@ -368,8 +377,9 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
}
const tab = await BrowserApi.getTab(details.tabId);
if (this.isValidAutoSubmitHost(tab?.url)) {
this.removeUrlFromAutoSubmitHosts(tab.url);
const tabUrl = tab?.url;
if (tabUrl && this.isValidAutoSubmitHost(tabUrl)) {
this.removeUrlFromAutoSubmitHosts(tabUrl);
}
};
@ -427,7 +437,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
*/
private getUrlHost = (url: string) => {
let parsedUrl = url;
if (!parsedUrl) {
if (!parsedUrl || typeof parsedUrl !== "string") {
return "";
}
@ -495,6 +505,10 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
message: AutoSubmitLoginMessage,
sender: chrome.runtime.MessageSender,
) => {
if (sender.frameId == null || !sender.tab || !message.pageDetails) {
return;
}
await this.autofillService.doAutoFillOnTab(
[
{
@ -515,7 +529,9 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
* @param sender - The message sender.
*/
private handleMultiStepAutoSubmitLoginComplete = (sender: chrome.runtime.MessageSender) => {
this.removeUrlFromAutoSubmitHosts(sender.url);
if (sender.url) {
this.removeUrlFromAutoSubmitHosts(sender.url);
}
};
/**
@ -526,7 +542,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
*/
private async initSafari() {
const currentTab = await BrowserApi.getTabFromCurrentWindow();
if (currentTab) {
if (currentTab?.url && currentTab.id != null && currentTab.id >= 0) {
this.setMostRecentIdpHost(currentTab.url, currentTab.id);
}
@ -558,7 +574,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
}
const tab = await BrowserApi.getTab(activeInfo.tabId);
if (tab) {
if (tab?.url && tab.id != null && tab.id >= 0) {
this.setMostRecentIdpHost(tab.url, tab.id);
}
};
@ -570,7 +586,7 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
* @param changeInfo - The change information of the tab.
*/
private handleSafariTabOnUpdated = (tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo) => {
if (changeInfo) {
if (changeInfo.url) {
this.setMostRecentIdpHost(changeInfo.url, tabId);
}
};
@ -626,13 +642,17 @@ export class AutoSubmitLoginBackground implements AutoSubmitLoginBackgroundAbstr
* @param sender - The message sender.
* @param sendResponse - The response callback.
*/
private handleExtensionMessage = async (
private handleExtensionMessage = (
message: AutoSubmitLoginMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void,
) => {
const { tab, url } = sender;
if (tab?.id !== this.currentAutoSubmitHostData.tabId || !this.isValidAutoSubmitHost(url)) {
if (
!url ||
tab?.id !== this.currentAutoSubmitHostData.tabId ||
!this.isValidAutoSubmitHost(url)
) {
return null;
}

View File

@ -22,6 +22,7 @@ import {
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { ProductTierType } from "@bitwarden/common/billing/enums/product-tier-type.enum";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
@ -79,6 +80,30 @@ import {
} from "./abstractions/overlay-notifications.background";
import { OverlayBackgroundExtensionMessage } from "./abstractions/overlay.background";
const inputScenarios = {
usernamePasswordNewPassword: "usernamePasswordNewPassword",
usernameNewPassword: "usernameNewPassword",
usernamePassword: "usernamePassword",
username: "username",
passwordNewPassword: "passwordNewPassword",
newPassword: "newPassword",
password: "password",
} as const;
type InputScenarioKey = keyof typeof inputScenarios;
type InputScenario = (typeof inputScenarios)[InputScenarioKey];
type CiphersByInputMatchCategory = {
allFieldMatches: CipherView["id"][];
newPasswordOnlyMatches: CipherView["id"][];
noFieldMatches: CipherView["id"][];
passwordNewPasswordMatches: CipherView["id"][];
passwordOnlyMatches: CipherView["id"][];
usernameNewPasswordMatches: CipherView["id"][];
usernameOnlyMatches: CipherView["id"][];
usernamePasswordMatches: CipherView["id"][];
};
export default class NotificationBackground {
private openUnlockPopout = openUnlockPopout;
private openAddEditVaultItemPopout = openAddEditVaultItemPopout;
@ -152,6 +177,10 @@ export default class NotificationBackground {
this.cleanupNotificationQueue();
}
useUndeterminedCipherScenarioTriggeringLogic$ = this.configService.getFeatureFlag$(
FeatureFlag.UseUndeterminedCipherScenarioTriggeringLogic,
);
/**
* Gets the enableChangedPasswordPrompt setting from the user notification settings service.
*/
@ -292,7 +321,7 @@ export default class NotificationBackground {
type: CipherType.Login,
reprompt,
favorite,
...(organizationCategories.length ? { organizationCategories } : {}),
...(organizationCategories.length > 0 ? { organizationCategories } : {}),
icon: buildCipherIcon(iconsServerUrl, view, showFavicons),
login: login && { username: login.username },
};
@ -309,7 +338,7 @@ export default class NotificationBackground {
activeUserId: UserId,
): Promise<LoginSecurityTaskInfo | null> {
const tasks: SecurityTask[] = await this.getSecurityTasks(activeUserId);
if (!tasks?.length) {
if (!(tasks?.length > 0)) {
return null;
}
@ -317,7 +346,7 @@ export default class NotificationBackground {
modifyLoginData.uri,
activeUserId,
);
if (!urlCiphers?.length) {
if (!(urlCiphers?.length > 0)) {
return null;
}
@ -596,6 +625,216 @@ export default class NotificationBackground {
await this.checkNotificationQueue(tab);
}
/**
* Receives filled form values and determines if a notification should be
* triggered, and if so, what kind and with what data.
*
* If an update scenario is identified, a change password message is added to the
* notification queue, prompting the user to update a stored login that has changed.
*
* A new cipher notification is triggered in other defined scenarios
* with the user's form input.
*
* Returns `true` or `false` to indicate if such a notification was
* triggered or not.
*
* For the purposes of this function, form field inputs should be assumed to be
* qualified accurately.
*/
async triggerCipherNotification(
data: ModifyLoginCipherFormData,
tab: chrome.tabs.Tab,
): Promise<boolean> {
const usernameFieldValue: string | null = data.username || null;
const currentPasswordFieldValue = data.password || null;
const newPasswordFieldValue = data.newPassword || null;
// If no values were entered, exit early
if (!usernameFieldValue && !currentPasswordFieldValue && !newPasswordFieldValue) {
return false;
}
// If the entered data doesn't have an associated URI, exit early
const loginDomain = Utils.getDomain(data.uri);
if (loginDomain === null) {
return false;
}
// If no cipher add/update notifications are enabled, we can exit early
const changePasswordNotificationIsEnabled = await this.getEnableChangedPasswordPrompt();
const newLoginNotificationIsEnabled = await this.getEnableAddedLoginPrompt();
if (!changePasswordNotificationIsEnabled && !newLoginNotificationIsEnabled) {
return false;
}
// If there is no account logged in (as opposed to only being locked), exit early
const authStatus = await this.getAuthStatus();
if (authStatus === AuthenticationStatus.LoggedOut) {
return false;
}
// If there is no active user, exit early
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (activeUserId === null) {
return false;
}
const normalizedUsername: string = usernameFieldValue ? usernameFieldValue.toLowerCase() : "";
const currentPasswordFieldHasValue =
typeof currentPasswordFieldValue === "string" && currentPasswordFieldValue.length > 0;
const newPasswordFieldHasValue =
typeof newPasswordFieldValue === "string" && newPasswordFieldValue.length > 0;
const usernameFieldHasValue =
typeof usernameFieldValue === "string" && usernameFieldValue.length > 0;
// If the current and new password inputs both have values and those values
// match, return early, since no change was made
if (
currentPasswordFieldHasValue &&
newPasswordFieldHasValue &&
currentPasswordFieldValue === newPasswordFieldValue
) {
return false;
}
/*
* We only show the unlock notification if a new password field was filled, since
* it's very likely to blindly represent an updated cipher value whereas other
* scenarios below require the vault to be unlocked in order to determine
* if an update has been made.
*/
if (authStatus === AuthenticationStatus.Locked) {
if (!newPasswordFieldHasValue) {
return false;
}
// This needs to be the call that includes the full form data
await this.pushChangePasswordToQueue(null, loginDomain, newPasswordFieldValue, tab, true);
return true;
}
const ciphersForURL: CipherView[] = await this.cipherService.getAllDecryptedForUrl(
data.uri,
activeUserId,
);
// Reducer structured to avoid subsequent array iterations
const ciphersByInputMatchCategory = ciphersForURL.reduce(
(acc, { id, login }) => {
const usernameInputMatchesCipher =
usernameFieldHasValue && login.username?.toLowerCase() === normalizedUsername;
const passwordInputMatchesCipher =
currentPasswordFieldHasValue && login.password === currentPasswordFieldValue;
const newPasswordInputMatchesCipher =
newPasswordFieldHasValue && login.password === newPasswordFieldValue;
if (
!newPasswordInputMatchesCipher &&
!usernameInputMatchesCipher &&
!passwordInputMatchesCipher
) {
return { ...acc, noFieldMatches: [...acc.noFieldMatches, id] };
} else if (
newPasswordInputMatchesCipher &&
usernameInputMatchesCipher &&
passwordInputMatchesCipher
) {
// Note: this case should be unreachable due to the early exit comparing
// the password input values against each other, but leaving this bit here
// as a defense against future changes to the pre-match checks.
return { ...acc, allFieldMatches: [...acc.allFieldMatches, id] };
} else if (
newPasswordInputMatchesCipher &&
!usernameInputMatchesCipher &&
!passwordInputMatchesCipher
) {
return { ...acc, newPasswordOnlyMatches: [...acc.newPasswordOnlyMatches, id] };
} else if (
passwordInputMatchesCipher &&
!usernameInputMatchesCipher &&
!newPasswordInputMatchesCipher
) {
return { ...acc, passwordOnlyMatches: [...acc.passwordOnlyMatches, id] };
} else if (
passwordInputMatchesCipher &&
newPasswordInputMatchesCipher &&
!usernameInputMatchesCipher
) {
// Note: this case should be unreachable due to the early exit comparing
// the password input values against each other, but leaving this bit here
// as a defense against future changes to the pre-match checks.
return { ...acc, passwordNewPasswordMatches: [...acc.passwordNewPasswordMatches, id] };
} else if (
usernameInputMatchesCipher &&
!passwordInputMatchesCipher &&
!newPasswordInputMatchesCipher
) {
return { ...acc, usernameOnlyMatches: [...acc.usernameOnlyMatches, id] };
} else if (
usernameInputMatchesCipher &&
passwordInputMatchesCipher &&
!newPasswordInputMatchesCipher
) {
return { ...acc, usernamePasswordMatches: [...acc.usernamePasswordMatches, id] };
} else if (
usernameInputMatchesCipher &&
newPasswordInputMatchesCipher &&
!passwordInputMatchesCipher
) {
return { ...acc, usernameNewPasswordMatches: [...acc.usernameNewPasswordMatches, id] };
}
return acc;
},
{
allFieldMatches: [],
newPasswordOnlyMatches: [],
noFieldMatches: [],
passwordNewPasswordMatches: [],
passwordOnlyMatches: [],
usernameNewPasswordMatches: [],
usernameOnlyMatches: [],
usernamePasswordMatches: [],
},
);
// Handle different field fill combinations and determine the input scenario
const inputScenariosByKey = {
upn: inputScenarios.usernamePasswordNewPassword,
un: inputScenarios.usernameNewPassword,
up: inputScenarios.usernamePassword,
u: inputScenarios.username,
pn: inputScenarios.passwordNewPassword,
n: inputScenarios.newPassword,
p: inputScenarios.password,
} as const;
type InputScenarioKeys = keyof typeof inputScenariosByKey;
const key = ((usernameFieldHasValue ? "u" : "") +
(currentPasswordFieldHasValue ? "p" : "") +
(newPasswordFieldHasValue ? "n" : "")) as InputScenarioKeys;
const inputScenario = key in inputScenariosByKey ? inputScenariosByKey[key] : null;
if (inputScenario) {
return await this.handleInputMatchScenario({
ciphersByInputMatchCategory,
ciphersForURL,
loginDomain,
tab,
data,
inputScenario,
changePasswordNotificationIsEnabled,
newLoginNotificationIsEnabled,
});
}
return false;
}
/**
* Adds a change password message to the notification queue, prompting the user
* to update the password for a login that has changed.
@ -668,13 +907,14 @@ export default class NotificationBackground {
if (
ciphers.length > 0 &&
currentPasswordFieldValue?.length &&
(currentPasswordFieldValue?.length || 0) > 0 &&
// Only use current password for change if no new password present.
!newPasswordFieldValue
) {
const currentPasswordMatchesAnExistingValue = ciphers.some(
(cipher) =>
cipher.login?.password?.length && cipher.login.password === currentPasswordFieldValue,
(cipher.login?.password?.length || 0) > 0 &&
cipher.login.password === currentPasswordFieldValue,
);
// The password entered matched a stored cipher value with
@ -710,6 +950,213 @@ export default class NotificationBackground {
return false;
}
private async handleInputMatchScenario({
inputScenario,
ciphersByInputMatchCategory,
ciphersForURL,
loginDomain,
tab,
data,
changePasswordNotificationIsEnabled,
newLoginNotificationIsEnabled,
}: {
ciphersByInputMatchCategory: CiphersByInputMatchCategory;
ciphersForURL: CipherView[];
loginDomain: string;
tab: chrome.tabs.Tab;
data: ModifyLoginCipherFormData;
inputScenario: InputScenario;
changePasswordNotificationIsEnabled: boolean;
newLoginNotificationIsEnabled: boolean;
}): Promise<boolean> {
const {
newPasswordOnlyMatches,
noFieldMatches,
passwordOnlyMatches,
usernameNewPasswordMatches,
usernameOnlyMatches,
usernamePasswordMatches,
} = ciphersByInputMatchCategory;
// IMPORTANT! The order of statements matters here; later evaluations
// depend on the assumptions of the early exits in preceding logic
// If no ciphers match any filled input values
// (Note, this block may uniquely exit early since this match scenario
// involves all ciphers, making it mutually exclusive from any other scenario)
if (noFieldMatches.length === ciphersForURL.length) {
// trigger a new cipher notification in these input scenarios
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernameNewPassword,
inputScenarios.usernamePassword,
inputScenarios.username,
inputScenarios.passwordNewPassword,
] as InputScenario[]
).includes(inputScenario) &&
newLoginNotificationIsEnabled
) {
await this.pushAddLoginToQueue(
loginDomain,
{ username: data.username, url: data.uri, password: data.newPassword || data.password },
tab,
);
return true;
}
// Trigger an update cipher notification with all URI ciphers
// in these input scenarios
if (
([inputScenarios.password, inputScenarios.newPassword] as InputScenario[]).includes(
inputScenario,
) &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
ciphersForURL.map((c) => c.id),
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
return false;
}
// If ciphers match entered username and new password values
if (usernameNewPasswordMatches.length > 0) {
// Early exit in these scenarios as they represent "no change"
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernameNewPassword,
] as InputScenario[]
).includes(inputScenario)
) {
return false;
}
}
// If ciphers match entered username and password values
if (usernamePasswordMatches.length > 0) {
// and username, password, and new password values were entered
if (
inputScenario === inputScenarios.usernamePasswordNewPassword &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
usernamePasswordMatches,
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
if (inputScenario === inputScenarios.usernamePassword) {
return false;
}
}
// If ciphers match entered username value (only)
if (usernameOnlyMatches.length > 0) {
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernameNewPassword,
inputScenarios.usernamePassword,
] as InputScenario[]
).includes(inputScenario) &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
usernameOnlyMatches,
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
// Early exit in this scenario as it represents "no change"
if (inputScenario === inputScenarios.username) {
return false;
}
}
// If ciphers match entered new password value (only)
if (newPasswordOnlyMatches.length > 0) {
// Early exit in these scenarios
if (
(
[
inputScenarios.usernameNewPassword, // unclear user expectation
inputScenarios.password, // likely nothing to change
inputScenarios.newPassword, // nothing to change
] as InputScenario[]
).includes(inputScenario)
) {
return false;
}
// and username, password, and new password values were entered
if (
inputScenario === inputScenarios.usernamePasswordNewPassword &&
newLoginNotificationIsEnabled
) {
await this.pushAddLoginToQueue(
loginDomain,
{ username: data.username, url: data.uri, password: data.newPassword || data.password },
tab,
);
return true;
}
}
// If ciphers match entered password value (only)
if (passwordOnlyMatches.length > 0) {
if (
(
[
inputScenarios.usernamePasswordNewPassword,
inputScenarios.usernamePassword,
inputScenarios.passwordNewPassword,
] as InputScenario[]
).includes(inputScenario) &&
changePasswordNotificationIsEnabled
) {
await this.pushChangePasswordToQueue(
passwordOnlyMatches,
loginDomain,
// @TODO handle empty strings / incomplete data structure
data.newPassword || data.password,
tab,
);
return true;
}
// Early exit in this scenario as it represents "no change"
if (inputScenario === inputScenarios.password) {
return false;
}
}
return false;
}
/**
* Sends the page details to the notification bar. Will query all
* forms with a password field and pass them to the notification bar.
@ -730,6 +1177,7 @@ export default class NotificationBackground {
});
}
// @TODO this needs the whole input record, and not just newPassword
private async pushChangePasswordToQueue(
cipherIds: CipherView["id"][],
loginDomain: string,

View File

@ -1,4 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
@ -32,6 +33,7 @@ describe("OverlayNotificationsBackground", () => {
jest.useFakeTimers();
logService = mock<LogService>();
notificationBackground = mock<NotificationBackground>();
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false);
getEnableChangedPasswordPromptSpy = jest
.spyOn(notificationBackground, "getEnableChangedPasswordPrompt")
.mockResolvedValue(true);
@ -323,6 +325,7 @@ describe("OverlayNotificationsBackground", () => {
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
let notificationChangedPasswordSpy: jest.SpyInstance;
let notificationAddLoginSpy: jest.SpyInstance;
let cipherNotificationSpy: jest.SpyInstance;
beforeEach(async () => {
sender = mock<chrome.runtime.MessageSender>({
@ -334,6 +337,7 @@ describe("OverlayNotificationsBackground", () => {
"triggerChangedPasswordNotification",
);
notificationAddLoginSpy = jest.spyOn(notificationBackground, "triggerAddLoginNotification");
cipherNotificationSpy = jest.spyOn(notificationBackground, "triggerCipherNotification");
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
@ -456,6 +460,7 @@ describe("OverlayNotificationsBackground", () => {
const pageDetails = mock<AutofillPageDetails>({ fields: [mock<AutofillField>()] });
beforeEach(async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(false);
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
@ -519,6 +524,44 @@ describe("OverlayNotificationsBackground", () => {
expect(notificationAddLoginSpy).toHaveBeenCalled();
});
it("with `useUndeterminedCipherScenarioTriggeringLogic` on, waits for the tab's navigation to complete using the web navigation API before initializing the notification", async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true);
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "loading",
url: sender.url,
}),
);
});
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.OnCompletedDetails>({
url: sender.url,
tabId: sender.tab.id,
requestId,
}),
);
await flushPromises();
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebNavigationOnCompletedEvent(
mock<chrome.webNavigation.WebNavigationFramedCallbackDetails>({
tabId: sender.tab.id,
url: sender.url,
}),
);
await flushPromises();
expect(cipherNotificationSpy).toHaveBeenCalled();
});
it("initializes the notification immediately when the tab's navigation is complete", async () => {
sendMockExtensionMessage(
{
@ -552,6 +595,40 @@ describe("OverlayNotificationsBackground", () => {
expect(notificationAddLoginSpy).toHaveBeenCalled();
});
it("with `useUndeterminedCipherScenarioTriggeringLogic` on, initializes the notification immediately when the tab's navigation is complete", async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true);
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "username",
password: "password",
newPassword: "newPassword",
},
sender,
);
await flushPromises();
chrome.tabs.get = jest.fn().mockImplementationOnce((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.OnCompletedDetails>({
url: sender.url,
tabId: sender.tab.id,
requestId,
}),
);
await flushPromises();
expect(cipherNotificationSpy).toHaveBeenCalled();
});
it("triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => {
sender.tab = mock<chrome.tabs.Tab>({ id: 4 });
sendMockExtensionMessage(
@ -601,6 +678,57 @@ describe("OverlayNotificationsBackground", () => {
expect(notificationChangedPasswordSpy).toHaveBeenCalled();
});
it("with `useUndeterminedCipherScenarioTriggeringLogic` on, triggers the notification on the beforeRequest listener when a post-submission redirection is encountered", async () => {
notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$ = of(true);
sender.tab = mock<chrome.tabs.Tab>({ id: 4 });
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: pageDetails },
sender,
);
await flushPromises();
sendMockExtensionMessage(
{
command: "formFieldSubmitted",
uri: "example.com",
username: "",
password: "password",
newPassword: "newPassword",
},
sender,
);
await flushPromises();
chrome.tabs.get = jest.fn().mockImplementation((tabId, callback) => {
callback(
mock<chrome.tabs.Tab>({
status: "complete",
url: sender.url,
}),
);
});
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
url: sender.url,
tabId: sender.tab.id,
method: "POST",
requestId,
}),
);
await flushPromises();
triggerWebRequestOnBeforeRequestEvent(
mock<chrome.webRequest.WebRequestDetails>({
url: "https://example.com/redirect",
tabId: sender.tab.id,
method: "GET",
requestId,
}),
);
await flushPromises();
expect(cipherNotificationSpy).toHaveBeenCalled();
});
});
});

View File

@ -1,10 +1,9 @@
import { Subject, switchMap, timer } from "rxjs";
import { firstValueFrom, Subject, switchMap, timer } from "rxjs";
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { BrowserApi } from "../../platform/browser/browser-api";
import { NotificationType, NotificationTypes } from "../notification/abstractions/notification-bar";
import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils";
import {
@ -14,6 +13,8 @@ import {
OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface,
OverlayNotificationsExtensionMessage,
OverlayNotificationsExtensionMessageHandlers,
NotificationScenarios,
NotificationScenario,
WebsiteOriginsWithFields,
} from "./abstractions/overlay-notifications.background";
import NotificationBackground from "./notification.background";
@ -32,7 +33,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
collectPageDetailsResponse: ({ message, sender }) =>
this.handleCollectPageDetailsResponse(message, sender),
};
constructor(
private logService: LogService,
private notificationBackground: NotificationBackground,
@ -281,7 +281,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
const shouldAttemptAddNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Add,
NotificationScenarios.Add,
);
if (shouldAttemptAddNotification) {
@ -290,7 +290,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
const shouldAttemptChangeNotification = this.shouldAttemptNotification(
modifyLoginData,
NotificationTypes.Change,
NotificationScenarios.Change,
);
if (shouldAttemptChangeNotification) {
@ -445,29 +445,45 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
requestId: chrome.webRequest.WebRequestDetails["requestId"],
modifyLoginData: ModifyLoginCipherFormData,
tab: chrome.tabs.Tab,
config: { skippable: NotificationType[] } = { skippable: [] },
config: { skippable: NotificationScenario[] } = { skippable: [] },
) => {
const notificationCandidates = [
{
type: NotificationTypes.Change,
trigger: this.notificationBackground.triggerChangedPasswordNotification,
},
{
type: NotificationTypes.Add,
trigger: this.notificationBackground.triggerAddLoginNotification,
},
{
type: NotificationTypes.AtRiskPassword,
trigger: this.notificationBackground.triggerAtRiskPasswordNotification,
},
].filter(
const useUndeterminedCipherScenarioTriggeringLogic = await firstValueFrom(
this.notificationBackground.useUndeterminedCipherScenarioTriggeringLogic$,
);
const notificationCandidates = useUndeterminedCipherScenarioTriggeringLogic
? [
{
type: NotificationScenarios.Cipher,
trigger: this.notificationBackground.triggerCipherNotification,
},
{
type: NotificationScenarios.AtRiskPassword,
trigger: this.notificationBackground.triggerAtRiskPasswordNotification,
},
]
: [
{
type: NotificationScenarios.Change,
trigger: this.notificationBackground.triggerChangedPasswordNotification,
},
{
type: NotificationScenarios.Add,
trigger: this.notificationBackground.triggerAddLoginNotification,
},
{
type: NotificationScenarios.AtRiskPassword,
trigger: this.notificationBackground.triggerAtRiskPasswordNotification,
},
];
const filteredNotificationCandidates = notificationCandidates.filter(
(candidate) =>
this.shouldAttemptNotification(modifyLoginData, candidate.type) ||
config.skippable.includes(candidate.type),
);
const results: string[] = [];
for (const { trigger, type } of notificationCandidates) {
for (const { trigger, type } of filteredNotificationCandidates) {
const success = await trigger.bind(this.notificationBackground)(modifyLoginData, tab);
if (success) {
results.push(`Success: ${type}`);
@ -489,8 +505,16 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
*/
private shouldAttemptNotification = (
modifyLoginData: ModifyLoginCipherFormData,
notificationType: NotificationType,
notificationType: NotificationScenario,
): boolean => {
if (notificationType === NotificationScenarios.Cipher) {
// The logic after this block pre-qualifies some cipher add/update scenarios
// prematurely (where matching against vault contents is required) and should be
// skipped for this case (these same checks are performed early in the
// notification triggering logic).
return true;
}
// Intentionally not stripping whitespace characters here as they
// represent user entry.
const usernameFieldHasValue = !!(modifyLoginData?.username || "").length;
@ -504,15 +528,15 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
// `Add` case included because all forms with cached usernames (from previous
// visits) will appear to be "password only" and otherwise trigger the new login
// save notification.
case NotificationTypes.Add:
case NotificationScenarios.Add:
// Can be values for nonstored login or account creation
return usernameFieldHasValue && (passwordFieldHasValue || newPasswordFieldHasValue);
case NotificationTypes.Change:
case NotificationScenarios.Change:
// Can be login with nonstored login changes or account password update
return canBeUserLogin || canBePasswordUpdate;
case NotificationTypes.AtRiskPassword:
case NotificationScenarios.AtRiskPassword:
return !newPasswordFieldHasValue;
case NotificationTypes.Unlock:
case NotificationScenarios.Unlock:
// Unlock notifications are handled separately and do not require form data
return false;
default:

View File

@ -27,4 +27,5 @@ export function CipherIcon({ color, size, theme, uri }: CipherIconProps) {
const cipherIconStyle = ({ width }: { width: string }) => css`
width: ${width};
height: fit-content;
max-height: 24px; /* fallback for Safari */
`;

View File

@ -51,6 +51,7 @@ const cipherItemRowStyles = ({ theme }: { theme: Theme }) => css`
background-color: ${themes[theme].background.DEFAULT};
padding: ${spacing["2"]} ${spacing["3"]};
min-height: min-content;
min-height: 36px; /* fallback for Firefox, which doesn't support min-height: min-content on flex items */
max-height: 52px;
overflow-x: hidden;
white-space: nowrap;

View File

@ -1,12 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { WebauthnUtils } from "../utils/webauthn-utils";
import { MessageTypes } from "./messaging/message";
import { Messenger } from "./messaging/messenger";
(function (globalContext) {
if (globalContext.document.currentScript) {
if (globalContext.document.currentScript?.parentNode) {
globalContext.document.currentScript.parentNode.removeChild(
globalContext.document.currentScript,
);
@ -86,7 +84,7 @@ import { Messenger } from "./messaging/messenger";
*/
async function createWebAuthnCredential(
options?: CredentialCreationOptions,
): Promise<Credential> {
): Promise<Credential | null> {
if (!isWebauthnCall(options)) {
return await browserCredentials.create(options);
}
@ -106,13 +104,18 @@ import { Messenger } from "./messaging/messenger";
options?.signal,
);
if (response.type !== MessageTypes.CredentialCreationResponse) {
if (response.type !== MessageTypes.CredentialCreationResponse || !response.result) {
throw new Error("Something went wrong.");
}
return WebauthnUtils.mapCredentialRegistrationResult(response.result);
} catch (error) {
if (error && error.fallbackRequested && fallbackSupported) {
if (
fallbackSupported &&
error instanceof Object &&
"fallbackRequested" in error &&
error.fallbackRequested
) {
await waitForFocus();
return await browserCredentials.create(options);
}
@ -127,7 +130,9 @@ import { Messenger } from "./messaging/messenger";
* @param options Options for creating new credentials.
* @returns Promise that resolves to the new credential object.
*/
async function getWebAuthnCredential(options?: CredentialRequestOptions): Promise<Credential> {
async function getWebAuthnCredential(
options?: CredentialRequestOptions,
): Promise<Credential | null> {
if (!isWebauthnCall(options)) {
return await browserCredentials.get(options);
}
@ -153,7 +158,7 @@ import { Messenger } from "./messaging/messenger";
internalAbortController.signal,
);
internalAbortController.signal.removeEventListener("abort", abortListener);
if (response.type !== MessageTypes.CredentialGetResponse) {
if (response.type !== MessageTypes.CredentialGetResponse || !response.result) {
throw new Error("Something went wrong.");
}
@ -176,7 +181,7 @@ import { Messenger } from "./messaging/messenger";
abortSignal.removeEventListener("abort", abortListener);
internalAbortControllers.forEach((controller) => controller.abort());
return response;
return response ?? null;
}
try {
@ -188,13 +193,18 @@ import { Messenger } from "./messaging/messenger";
options?.signal,
);
if (response.type !== MessageTypes.CredentialGetResponse) {
if (response.type !== MessageTypes.CredentialGetResponse || !response.result) {
throw new Error("Something went wrong.");
}
return WebauthnUtils.mapCredentialAssertResult(response.result);
} catch (error) {
if (error && error.fallbackRequested && fallbackSupported) {
if (
fallbackSupported &&
error instanceof Object &&
"fallbackRequested" in error &&
error.fallbackRequested
) {
await waitForFocus();
return await browserCredentials.get(options);
}
@ -203,8 +213,10 @@ import { Messenger } from "./messaging/messenger";
}
}
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
return options && "publicKey" in options;
function isWebauthnCall(
options?: CredentialCreationOptions | CredentialRequestOptions,
): options is CredentialCreationOptions | CredentialRequestOptions {
return options != null && "publicKey" in options;
}
/**
@ -217,7 +229,7 @@ import { Messenger } from "./messaging/messenger";
*/
async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) {
try {
if (globalContext.top.document.hasFocus()) {
if (globalContext.top?.document.hasFocus()) {
return;
}
} catch {
@ -225,9 +237,14 @@ import { Messenger } from "./messaging/messenger";
return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait));
}
if (!globalContext.top) {
return await new Promise((resolve) => globalContext.setTimeout(resolve, fallbackWait));
}
const topWindow = globalContext.top;
const focusPromise = new Promise<void>((resolve) => {
focusListenerHandler = () => resolve();
globalContext.top.addEventListener("focus", focusListenerHandler);
topWindow.addEventListener("focus", focusListenerHandler);
});
const timeoutPromise = new Promise<void>((_, reject) => {
@ -248,7 +265,7 @@ import { Messenger } from "./messaging/messenger";
}
function clearWaitForFocus() {
globalContext.top.removeEventListener("focus", focusListenerHandler);
globalContext.top?.removeEventListener("focus", focusListenerHandler);
if (waitForFocusTimeout) {
globalContext.clearTimeout(waitForFocusTimeout);
}

View File

@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Message, MessageTypes } from "./message";
const SENDER = "bitwarden-webauthn";
@ -25,7 +23,9 @@ type Handler = (
* handling aborts and exceptions across separate execution contexts.
*/
export class Messenger {
private messageEventListener: (event: MessageEvent<MessageWithMetadata>) => void | null = null;
private messageEventListener:
| ((event: MessageEvent<MessageWithMetadata>) => void | Promise<void>)
| null = null;
private onDestroy = new EventTarget();
/**

View File

@ -8,9 +8,13 @@ import {
} from "../../../autofill/content/components/common-types";
const NotificationTypes = {
/** represents scenarios handling saving new ciphers after form submit */
Add: "add",
/** represents scenarios handling saving updated ciphers after form submit */
Change: "change",
/** represents scenarios where user has interacted with an unlock action prompt or action otherwise requiring unlock as a prerequisite */
Unlock: "unlock",
/** represents scenarios where the user has security tasks after updating ciphers */
AtRiskPassword: "at-risk-password",
} as const;

View File

@ -139,8 +139,6 @@ import { IpcService, IpcSessionRepository } from "@bitwarden/common/platform/ipc
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
// eslint-disable-next-line no-restricted-imports -- Used for dependency creation
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
// eslint-disable-next-line no-restricted-imports -- Needed for service creation
import {
@ -565,36 +563,18 @@ export default class MainBackground {
this.memoryStorageService = this.memoryStorageForStateProviders;
}
this.encryptService = new EncryptServiceImplementation(
this.cryptoFunctionService,
this.logService,
true,
);
if (BrowserApi.isManifestVersion(3)) {
// Creates a session key for mv3 storage of large memory items
const sessionKey = new Lazy(async () => {
// Key already in session storage
const sessionStorage = new BrowserMemoryStorageService();
const existingKey = await sessionStorage.get<SymmetricCryptoKey>("session-key");
if (existingKey) {
if (sessionStorage.valuesRequireDeserialization) {
return SymmetricCryptoKey.fromJSON(existingKey);
}
return existingKey;
}
// New key
const { derivedKey } = await this.keyGenerationService.createKeyWithPurpose(
128,
"ephemeral",
"bitwarden-ephemeral",
);
await sessionStorage.save("session-key", derivedKey.toJSON());
return derivedKey;
});
this.largeObjectMemoryStorageForStateProviders = new LocalBackedSessionStorageService(
sessionKey,
new BrowserMemoryStorageService(),
this.storageService,
// For local backed session storage, we expect that the encrypted data on disk will persist longer than the encryption key in memory
// and failures to decrypt because of that are completely expected. For this reason, we pass in `false` to the `EncryptServiceImplementation`
// so that MAC failures are not logged.
new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false),
this.keyGenerationService,
this.encryptService,
this.platformUtilsService,
this.logService,
);
@ -629,12 +609,6 @@ export default class MainBackground {
storageServiceProvider,
);
this.encryptService = new EncryptServiceImplementation(
this.cryptoFunctionService,
this.logService,
true,
);
this.singleUserStateProvider = new DefaultSingleUserStateProvider(
storageServiceProvider,
stateEventRegistrarService,
@ -1031,6 +1005,8 @@ export default class MainBackground {
this.keyGenerationService,
this.sendStateProvider,
this.encryptService,
this.cryptoFunctionService,
this.configService,
);
this.sendApiService = new SendApiService(
this.apiService,

View File

@ -291,15 +291,21 @@ export default class RuntimeBackground {
}
break;
case "openPopup":
await this.openPopup();
await this.executeMessageActionOrOpenPopup(msg, this.openPopup.bind(this));
break;
case VaultMessages.OpenAtRiskPasswords: {
await this.main.openAtRisksPasswordsPage();
await this.executeMessageActionOrOpenPopup(
msg,
this.main.openAtRisksPasswordsPage.bind(this),
);
this.announcePopupOpen();
break;
}
case VaultMessages.OpenBrowserExtensionToUrl: {
await this.main.openTheExtensionToPage(msg.url);
await this.executeMessageActionOrOpenPopup(
msg,
this.main.openTheExtensionToPage.bind(this, msg.url),
);
this.announcePopupOpen();
break;
}
@ -374,40 +380,55 @@ export default class RuntimeBackground {
* @param message
* @returns true if message fails validation
*/
private async shouldRejectManyOriginMessage(message: {
webExtSender: chrome.runtime.MessageSender;
}): Promise<boolean> {
private async executeMessageActionOrOpenPopup(
message: {
webExtSender: chrome.runtime.MessageSender;
},
messageAction: () => Promise<void>,
): Promise<boolean> {
const hasAccounts = await firstValueFrom(
this.accountService.accounts$.pipe(map((a) => Object.keys(a).length > 0)),
);
// When there are no accounts associated with the extension, only allow opening the popup
if (!hasAccounts) {
await this.openPopup();
return;
}
const isValidVaultReferrer = await this.isValidVaultReferrer(
Utils.getHostname(message?.webExtSender?.origin),
);
if (isValidVaultReferrer) {
return false;
// When the referrer is not a known vault and the message is external, reject the message
if (!isValidVaultReferrer && isExternalMessage(message)) {
return;
}
return isExternalMessage(message);
await messageAction();
}
/**
* Validates a message's referrer matches the configured web vault hostname.
* Validates that a referrer hostname matches any of the available regions' and current environment web vault URLs.
*
* @param referrer - hostname from message source
* @returns true if referrer matches web vault
* @param referrer - hostname from message source (should not include protocol or path)
* @returns true if referrer matches any known vault hostname, false otherwise
*/
private async isValidVaultReferrer(referrer: string | null | undefined): Promise<boolean> {
if (!referrer) {
return false;
}
const env = await firstValueFrom(this.environmentService.environment$);
const vaultUrl = env.getWebVaultUrl();
const vaultHostname = Utils.getHostname(vaultUrl);
const environment = await firstValueFrom(this.environmentService.environment$);
if (!vaultHostname) {
return false;
}
const regions = this.environmentService.availableRegions();
const regionVaultUrls = regions.map((r) => r.urls.webVault ?? r.urls.base);
const environmentWebVaultUrl = environment.getWebVaultUrl();
const messageIsFromKnownVault = [...regionVaultUrls, environmentWebVaultUrl].some(
(webVaultUrl) => Utils.getHostname(webVaultUrl) === referrer,
);
return vaultHostname === referrer;
return messageIsFromKnownVault;
}
private async autofillPage(tabToAutoFill: chrome.tabs.Tab) {

View File

@ -7,8 +7,6 @@ export type PhishingResource = {
todayUrl: string;
/** Matcher used to decide whether a given URL matches an entry from this resource */
match: (url: URL, entry: string) => boolean;
/** Whether to use the custom matcher. If false, only exact hasUrl lookups are used. Default: true */
useCustomMatcher?: boolean;
};
export const PhishingResourceType = Object.freeze({
@ -58,8 +56,6 @@ export const PHISHING_RESOURCES: Record<PhishingResourceType, PhishingResource[]
"https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-links-ACTIVE.txt.md5",
todayUrl:
"https://raw.githubusercontent.com/Phishing-Database/Phishing.Database/refs/heads/master/phishing-links-NEW-today.txt",
// Disabled for performance - cursor search takes 6+ minutes on large databases
useCustomMatcher: false,
match: (url: URL, entry: string) => {
if (!entry) {
return false;

View File

@ -10,7 +10,7 @@ import {
ButtonModule,
CheckboxModule,
FormFieldModule,
IconModule,
SvgModule,
IconTileComponent,
LinkModule,
CalloutComponent,
@ -31,7 +31,7 @@ import {
templateUrl: "phishing-warning.component.html",
imports: [
CommonModule,
IconModule,
SvgModule,
JslibModule,
LinkModule,
FormFieldModule,

View File

@ -186,12 +186,74 @@ describe("PhishingDataService", () => {
expect(result).toBe(false);
expect(logService.error).toHaveBeenCalledWith(
"[PhishingDataService] IndexedDB lookup via hasUrl failed",
"[PhishingDataService] IndexedDB lookup failed",
expect.any(Error),
);
// Custom matcher is disabled, so no custom matcher error is expected
expect(mockIndexedDbService.findMatchingUrl).not.toHaveBeenCalled();
});
it("should use cursor-based search when useCustomMatcher is enabled", async () => {
// Temporarily enable custom matcher for this test
const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER;
(PhishingDataService as any).USE_CUSTOM_MATCHER = true;
try {
// Mock hasUrl to return false (no direct match)
mockIndexedDbService.hasUrl.mockResolvedValue(false);
// Mock findMatchingUrl to return true (custom matcher finds it)
mockIndexedDbService.findMatchingUrl.mockResolvedValue(true);
const url = new URL("http://phish.com/path");
const result = await service.isPhishingWebAddress(url);
expect(result).toBe(true);
expect(mockIndexedDbService.hasUrl).toHaveBeenCalled();
expect(mockIndexedDbService.findMatchingUrl).toHaveBeenCalled();
} finally {
// Restore original value
(PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue;
}
});
it("should return false when custom matcher finds no match (when enabled)", async () => {
const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER;
(PhishingDataService as any).USE_CUSTOM_MATCHER = true;
try {
mockIndexedDbService.hasUrl.mockResolvedValue(false);
mockIndexedDbService.findMatchingUrl.mockResolvedValue(false);
const url = new URL("http://safe.com/path");
const result = await service.isPhishingWebAddress(url);
expect(result).toBe(false);
expect(mockIndexedDbService.findMatchingUrl).toHaveBeenCalled();
} finally {
(PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue;
}
});
it("should handle custom matcher errors gracefully (when enabled)", async () => {
const originalValue = (PhishingDataService as any).USE_CUSTOM_MATCHER;
(PhishingDataService as any).USE_CUSTOM_MATCHER = true;
try {
mockIndexedDbService.hasUrl.mockResolvedValue(false);
mockIndexedDbService.findMatchingUrl.mockRejectedValue(new Error("Cursor error"));
const url = new URL("http://error.com/path");
const result = await service.isPhishingWebAddress(url);
expect(result).toBe(false);
expect(logService.error).toHaveBeenCalledWith(
"[PhishingDataService] Custom matcher failed",
expect.any(Error),
);
} finally {
(PhishingDataService as any).USE_CUSTOM_MATCHER = originalValue;
}
});
});
describe("data updates", () => {

View File

@ -78,6 +78,10 @@ export const PHISHING_DOMAINS_BLOB_KEY = new KeyDefinition<string>(
/** Coordinates fetching, caching, and patching of known phishing web addresses */
export class PhishingDataService {
// Cursor-based search is disabled due to performance (6+ minutes on large databases)
// Enable when performance is optimized via indexing or other improvements
private static readonly USE_CUSTOM_MATCHER = false;
// While background scripts do not necessarily need destroying,
// processes in PhishingDataService are memory intensive.
// We are adding the destroy to guard against accidental leaks.
@ -153,12 +157,8 @@ export class PhishingDataService {
* @returns True if the URL is a known phishing web address, false otherwise
*/
async isPhishingWebAddress(url: URL): Promise<boolean> {
this.logService.debug("[PhishingDataService] isPhishingWebAddress called for: " + url.href);
// Skip non-http(s) protocols - phishing database only contains web URLs
// This prevents expensive fallback checks for chrome://, about:, file://, etc.
if (url.protocol !== "http:" && url.protocol !== "https:") {
this.logService.debug("[PhishingDataService] Skipping non-http(s) protocol: " + url.protocol);
return false;
}
@ -176,69 +176,37 @@ export class PhishingDataService {
const urlHref = url.href;
const urlWithoutTrailingSlash = urlHref.endsWith("/") ? urlHref.slice(0, -1) : null;
this.logService.debug("[PhishingDataService] Checking hasUrl on this string: " + urlHref);
let hasUrl = await this.indexedDbService.hasUrl(urlHref);
// If not found and URL has trailing slash, try without it
if (!hasUrl && urlWithoutTrailingSlash) {
this.logService.debug(
"[PhishingDataService] Checking hasUrl without trailing slash: " +
urlWithoutTrailingSlash,
);
hasUrl = await this.indexedDbService.hasUrl(urlWithoutTrailingSlash);
}
if (hasUrl) {
this.logService.info(
"[PhishingDataService] Found phishing web address through direct lookup: " + urlHref,
);
this.logService.info("[PhishingDataService] Found phishing URL: " + urlHref);
return true;
}
} catch (err) {
this.logService.error("[PhishingDataService] IndexedDB lookup via hasUrl failed", err);
this.logService.error("[PhishingDataService] IndexedDB lookup failed", err);
}
// If a custom matcher is provided and enabled, use cursor-based search.
// This avoids loading all URLs into memory and allows early exit on first match.
// Can be disabled via useCustomMatcher: false for performance reasons.
if (resource && resource.match && resource.useCustomMatcher !== false) {
// Custom matcher is disabled for performance (see USE_CUSTOM_MATCHER)
if (resource && resource.match && PhishingDataService.USE_CUSTOM_MATCHER) {
try {
this.logService.debug(
"[PhishingDataService] Starting cursor-based search for: " + url.href,
);
const startTime = performance.now();
const found = await this.indexedDbService.findMatchingUrl((entry) =>
resource.match(url, entry),
);
const endTime = performance.now();
const duration = (endTime - startTime).toFixed(2);
this.logService.debug(
`[PhishingDataService] Cursor-based search completed in ${duration}ms for: ${url.href} (found: ${found})`,
);
if (found) {
this.logService.info(
"[PhishingDataService] Found phishing web address through custom matcher: " + url.href,
);
} else {
this.logService.debug(
"[PhishingDataService] No match found, returning false for: " + url.href,
);
this.logService.info("[PhishingDataService] Found phishing URL via matcher: " + url.href);
}
return found;
} catch (err) {
this.logService.error("[PhishingDataService] Error running custom matcher", err);
this.logService.debug(
"[PhishingDataService] Returning false due to error for: " + url.href,
);
this.logService.error("[PhishingDataService] Custom matcher failed", err);
return false;
}
}
this.logService.debug(
"[PhishingDataService] No custom matcher, returning false for: " + url.href,
);
return false;
}

View File

@ -1,14 +1,4 @@
import {
distinctUntilChanged,
EMPTY,
filter,
map,
merge,
mergeMap,
Subject,
switchMap,
tap,
} from "rxjs";
import { distinctUntilChanged, EMPTY, filter, map, merge, Subject, switchMap, tap } from "rxjs";
import { PhishingDetectionSettingsServiceAbstraction } from "@bitwarden/common/dirt/services/abstractions/phishing-detection-settings.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@ -43,7 +33,6 @@ export class PhishingDetectionService {
private static _tabUpdated$ = new Subject<PhishingDetectionNavigationEvent>();
private static _ignoredHostnames = new Set<string>();
private static _didInit = false;
private static _activeSearchCount = 0;
static initialize(
logService: LogService,
@ -64,7 +53,7 @@ export class PhishingDetectionService {
tap((message) =>
logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`),
),
mergeMap(async (message) => {
switchMap(async (message) => {
const url = new URL(message.url);
this._ignoredHostnames.add(url.hostname);
await BrowserApi.navigateTabToUrl(message.tabId, url);
@ -89,40 +78,25 @@ export class PhishingDetectionService {
prev.ignored === curr.ignored,
),
tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)),
// Use mergeMap for parallel processing - each tab check runs independently
// Concurrency limit of 5 prevents overwhelming IndexedDB
mergeMap(async ({ tabId, url, ignored }) => {
this._activeSearchCount++;
const searchId = `${tabId}-${Date.now()}`;
logService.debug(
`[PhishingDetectionService] Search STARTED [${searchId}] for ${url.href} (active: ${this._activeSearchCount}/5)`,
);
const startTime = performance.now();
try {
if (ignored) {
// The next time this host is visited, block again
this._ignoredHostnames.delete(url.hostname);
return;
}
const isPhishing = await phishingDataService.isPhishingWebAddress(url);
if (!isPhishing) {
return;
}
const phishingWarningPage = new URL(
BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") +
`?phishingUrl=${url.toString()}`,
);
await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage);
} finally {
this._activeSearchCount--;
const duration = (performance.now() - startTime).toFixed(2);
logService.debug(
`[PhishingDetectionService] Search FINISHED [${searchId}] for ${url.href} in ${duration}ms (active: ${this._activeSearchCount}/5)`,
);
// Use switchMap to cancel any in-progress check when navigating to a new URL
// This prevents race conditions where a stale check redirects the user incorrectly
switchMap(async ({ tabId, url, ignored }) => {
if (ignored) {
// The next time this host is visited, block again
this._ignoredHostnames.delete(url.hostname);
return;
}
}, 5),
const isPhishing = await phishingDataService.isPhishingWebAddress(url);
if (!isPhishing) {
return;
}
const phishingWarningPage = new URL(
BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") +
`?phishingUrl=${url.toString()}`,
);
await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage);
}),
);
const onCancelCommand$ = messageListener

View File

@ -560,7 +560,8 @@ export class BrowserApi {
* @param event - The event in which to remove the listener from.
* @param callback - The callback you want removed from the event.
*/
static removeListener<T extends (...args: readonly unknown[]) => unknown>(
// Chrome's Event.removeListener expects callback args as `any[]` to align with its internal event typings.
static removeListener<T extends (...args: readonly any[]) => any>(
event: chrome.events.Event<T>,
callback: T,
) {

View File

@ -140,11 +140,6 @@ describe("BrowserPopupUtils", () => {
describe("openPopout", () => {
beforeEach(() => {
jest.spyOn(BrowserApi, "getPlatformInfo").mockResolvedValueOnce({
os: "linux",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({
id: 1,
left: 100,
@ -155,8 +150,6 @@ describe("BrowserPopupUtils", () => {
width: PopupWidthOptions.default,
});
jest.spyOn(BrowserApi, "createWindow").mockImplementation();
jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation();
jest.spyOn(BrowserApi, "getPlatformInfo").mockImplementation();
});
it("creates a window with the default window options", async () => {
@ -274,63 +267,6 @@ describe("BrowserPopupUtils", () => {
url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`,
});
});
it("exits fullscreen and focuses popout window if the current window is fullscreen and platform is mac", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({
os: "mac",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockReset().mockResolvedValueOnce({
id: 1,
left: 100,
top: 100,
focused: false,
alwaysOnTop: false,
incognito: false,
width: PopupWidthOptions.default,
state: "fullscreen",
});
jest
.spyOn(BrowserApi, "createWindow")
.mockResolvedValueOnce({ id: 2 } as chrome.windows.Window);
await BrowserPopupUtils.openPopout(url, { senderWindowId: 1 });
expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(1, {
state: "maximized",
});
expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(2, {
focused: true,
});
});
it("doesnt exit fullscreen if the platform is not mac", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({
os: "win",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({
id: 1,
left: 100,
top: 100,
focused: false,
alwaysOnTop: false,
incognito: false,
width: PopupWidthOptions.default,
state: "fullscreen",
});
await BrowserPopupUtils.openPopout(url);
expect(BrowserApi.updateWindowProperties).not.toHaveBeenCalledWith(1, {
state: "maximized",
});
});
});
describe("openCurrentPagePopout", () => {

View File

@ -168,29 +168,8 @@ export default class BrowserPopupUtils {
) {
return;
}
const platform = await BrowserApi.getPlatformInfo();
const isMacOS = platform.os === "mac";
const isFullscreen = senderWindow.state === "fullscreen";
const isFullscreenAndMacOS = isFullscreen && isMacOS;
//macOS specific handling for improved UX when sender in fullscreen aka green button;
if (isFullscreenAndMacOS) {
await BrowserApi.updateWindowProperties(senderWindow.id, {
state: "maximized",
});
//wait for macOS animation to finish
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const newWindow = await BrowserApi.createWindow(popoutWindowOptions);
if (isFullscreenAndMacOS) {
await BrowserApi.updateWindowProperties(newWindow.id, {
focused: true,
});
}
return newWindow;
return await BrowserApi.createWindow(popoutWindowOptions);
}
/**

View File

@ -18,11 +18,11 @@
type="button"
role="link"
>
<bit-icon
[icon]="rla.isActive ? button.iconActive : button.icon"
<bit-svg
[content]="rla.isActive ? button.iconActive : button.icon"
aria-hidden="true"
class="tw-leading-3"
></bit-icon>
></bit-svg>
<span class="tw-text-sm tw-truncate tw-max-w-full">
{{ button.label | i18n }}
</span>

View File

@ -3,15 +3,15 @@ import { Component, Input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Icon } from "@bitwarden/assets/svg";
import { BitSvg } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { IconModule, LinkModule } from "@bitwarden/components";
import { SvgModule, LinkModule } from "@bitwarden/components";
export type NavButton = {
label: string;
page: string;
icon: Icon;
iconActive: Icon;
icon: BitSvg;
iconActive: BitSvg;
showBerry?: boolean;
};
@ -20,7 +20,7 @@ export type NavButton = {
@Component({
selector: "popup-tab-navigation",
templateUrl: "popup-tab-navigation.component.html",
imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule],
imports: [CommonModule, LinkModule, RouterModule, JslibModule, SvgModule],
host: {
class: "tw-block tw-size-full tw-flex tw-flex-col",
},

View File

@ -15,6 +15,22 @@ export default class BrowserLocalStorageService extends AbstractChromeStorageSer
return await this.getWithRetries<T>(key, 0);
}
/**
* Retrieves all storage keys.
*
* Returns all keys stored in local storage when the browser supports the getKeys API (Chrome 130+).
* Returns an empty array on older browser versions where this feature is unavailable.
*
* @returns Array of storage keys, or empty array if the feature is not supported
*/
async getKeys(): Promise<string[]> {
// getKeys function is only available since Chrome 130
if ("getKeys" in this.chromeStorageApi) {
return this.chromeStorageApi.getKeys();
}
return [];
}
private async getWithRetries<T>(key: string, retryNum: number): Promise<T> {
// See: https://github.com/EFForg/privacybadger/pull/2980
const MAX_RETRIES = 5;

View File

@ -1,20 +1,89 @@
import { mock, MockProxy } from "jest-mock-extended";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeStorageService, makeEncString } from "@bitwarden/common/spec";
import { FakeStorageService, makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec";
import { StorageService } from "@bitwarden/storage-core";
import { LocalBackedSessionStorageService } from "./local-backed-session-storage.service";
import BrowserLocalStorageService from "./browser-local-storage.service";
import {
LocalBackedSessionStorageService,
SessionKeyResolveService,
} from "./local-backed-session-storage.service";
describe("SessionKeyResolveService", () => {
let storageService: FakeStorageService;
let keyGenerationService: MockProxy<KeyGenerationService>;
let sut: SessionKeyResolveService;
const mockKey = makeSymmetricCryptoKey();
beforeEach(() => {
storageService = new FakeStorageService();
keyGenerationService = mock<KeyGenerationService>();
sut = new SessionKeyResolveService(storageService, keyGenerationService);
});
describe("get", () => {
it("returns null when no session key exists", async () => {
const result = await sut.get();
expect(result).toBeNull();
});
it("returns the session key from storage", async () => {
await storageService.save("session-key", mockKey);
const result = await sut.get();
expect(result).toEqual(mockKey);
});
it("deserializes the session key when storage requires deserialization", async () => {
const mockStorageService = mock<FakeStorageService>();
Object.defineProperty(mockStorageService, "valuesRequireDeserialization", {
get: () => true,
});
mockStorageService.get.mockResolvedValue(mockKey.toJSON());
const deserializableSut = new SessionKeyResolveService(
mockStorageService,
keyGenerationService,
);
const result = await deserializableSut.get();
expect(result).toBeInstanceOf(SymmetricCryptoKey);
expect(result?.toJSON()).toEqual(mockKey.toJSON());
});
});
describe("create", () => {
it("creates a new session key and saves it to storage", async () => {
keyGenerationService.createKeyWithPurpose.mockResolvedValue({
salt: "salt",
material: new Uint8Array(16) as any,
derivedKey: mockKey,
});
const result = await sut.create();
expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalledWith(
128,
"ephemeral",
"bitwarden-ephemeral",
);
expect(result).toEqual(mockKey);
expect(await storageService.get("session-key")).toEqual(mockKey.toJSON());
});
});
});
describe("LocalBackedSessionStorage", () => {
const sessionKey = new SymmetricCryptoKey(
Utils.fromUtf8ToArray("00000000000000000000000000000000"),
);
let localStorage: FakeStorageService;
const sessionKey = makeSymmetricCryptoKey();
let memoryStorage: MockProxy<StorageService>;
let keyGenerationService: MockProxy<KeyGenerationService>;
let localStorage: MockProxy<BrowserLocalStorageService>;
let encryptService: MockProxy<EncryptService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let logService: MockProxy<LogService>;
@ -22,14 +91,23 @@ describe("LocalBackedSessionStorage", () => {
let sut: LocalBackedSessionStorageService;
beforeEach(() => {
localStorage = new FakeStorageService();
memoryStorage = mock<StorageService>();
keyGenerationService = mock<KeyGenerationService>();
localStorage = mock<BrowserLocalStorageService>();
encryptService = mock<EncryptService>();
platformUtilsService = mock<PlatformUtilsService>();
logService = mock<LogService>();
// Default: session key exists
memoryStorage.get.mockResolvedValue(sessionKey);
Object.defineProperty(memoryStorage, "valuesRequireDeserialization", {
get: () => true,
});
sut = new LocalBackedSessionStorageService(
new Lazy(async () => sessionKey),
memoryStorage,
localStorage,
keyGenerationService,
encryptService,
platformUtilsService,
logService,
@ -37,57 +115,79 @@ describe("LocalBackedSessionStorage", () => {
});
describe("get", () => {
it("return the cached value when one is cached", async () => {
const encString = makeEncString("encrypted");
it("returns the cached value when one is cached", async () => {
sut["cache"]["test"] = "cached";
const result = await sut.get("test");
expect(result).toEqual("cached");
});
it("returns a decrypted value when one is stored in local storage", async () => {
const encrypted = makeEncString("encrypted");
localStorage.internalStore["session_test"] = encrypted.encryptedString;
encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted"));
const result = await sut.get("test");
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey),
expect(result).toEqual("decrypted"));
});
it("returns null when both cache and storage are null", async () => {
sut["cache"]["test"] = null;
localStorage.get.mockResolvedValue(null);
it("caches the decrypted value when one is stored in local storage", async () => {
const encrypted = makeEncString("encrypted");
localStorage.internalStore["session_test"] = encrypted.encryptedString;
encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted"));
await sut.get("test");
expect(sut["cache"]["test"]).toEqual("decrypted");
const result = await sut.get("test");
expect(result).toBeNull();
expect(localStorage.get).toHaveBeenCalledWith("session_test");
});
it("returns a decrypted value when one is stored in local storage", async () => {
const encrypted = makeEncString("encrypted");
localStorage.internalStore["session_test"] = encrypted.encryptedString;
localStorage.get.mockResolvedValue(encString.encryptedString);
encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted"));
const result = await sut.get("test");
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(expect(encryptService.decryptString).toHaveBeenCalledWith(encrypted, sessionKey),
expect(result).toEqual("decrypted"));
expect(encryptService.decryptString).toHaveBeenCalledWith(encString, sessionKey);
expect(result).toEqual("decrypted");
expect(sut["cache"]["test"]).toEqual("decrypted");
});
it("caches the decrypted value when one is stored in local storage", async () => {
const encrypted = makeEncString("encrypted");
localStorage.internalStore["session_test"] = encrypted.encryptedString;
encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted"));
await sut.get("test");
expect(sut["cache"]["test"]).toEqual("decrypted");
it("returns the cached value when cache is populated during storage retrieval", async () => {
localStorage.get.mockImplementation(async () => {
sut["cache"]["test"] = "cached-during-read";
return encString.encryptedString;
});
encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted-from-storage"));
const result = await sut.get("test");
expect(result).toEqual("cached-during-read");
});
it("returns the cached value when storage returns null but cache was filled", async () => {
localStorage.get.mockImplementation(async () => {
sut["cache"]["test"] = "cached-during-read";
return null;
});
const result = await sut.get("test");
expect(result).toEqual("cached-during-read");
});
it("creates new session key, clears old data, and returns null when session key is missing", async () => {
const newSessionKey = makeSymmetricCryptoKey();
const clearSpy = jest.spyOn(sut as any, "clear");
memoryStorage.get.mockResolvedValue(null);
keyGenerationService.createKeyWithPurpose.mockResolvedValue({
salt: "salt",
material: new Uint8Array(16) as any,
derivedKey: newSessionKey,
});
localStorage.get.mockResolvedValue(null);
localStorage.getKeys.mockResolvedValue([]);
const result = await sut.get("test");
expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalled();
expect(clearSpy).toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe("has", () => {
it("returns false when the key is not in cache", async () => {
const result = await sut.has("test");
expect(result).toBe(false);
});
it("returns true when the key is in cache", async () => {
sut["cache"]["test"] = "cached";
const result = await sut.has("test");
@ -95,21 +195,17 @@ describe("LocalBackedSessionStorage", () => {
});
it("returns true when the key is in local storage", async () => {
localStorage.internalStore["session_test"] = makeEncString("encrypted").encryptedString;
const encString = makeEncString("encrypted");
localStorage.get.mockResolvedValue(encString.encryptedString);
encryptService.decryptString.mockResolvedValue(JSON.stringify("decrypted"));
const result = await sut.has("test");
expect(result).toBe(true);
});
it.each([null, undefined])("returns false when %s is cached", async (nullish) => {
sut["cache"]["test"] = nullish;
await expect(sut.has("test")).resolves.toBe(false);
});
it.each([null, undefined])(
"returns false when null is stored in local storage",
async (nullish) => {
localStorage.internalStore["session_test"] = nullish;
"returns false when the key does not exist in local storage (%s)",
async (value) => {
localStorage.get.mockResolvedValue(value);
await expect(sut.has("test")).resolves.toBe(false);
expect(encryptService.decryptString).not.toHaveBeenCalled();
},
@ -118,6 +214,7 @@ describe("LocalBackedSessionStorage", () => {
describe("save", () => {
const encString = makeEncString("encrypted");
beforeEach(() => {
encryptService.encryptString.mockResolvedValue(encString);
});
@ -137,29 +234,44 @@ describe("LocalBackedSessionStorage", () => {
});
it("removes the key when saving a null value", async () => {
const spy = jest.spyOn(sut, "remove");
const removeSpy = jest.spyOn(sut, "remove");
await sut.save("test", null);
expect(spy).toHaveBeenCalledWith("test");
expect(removeSpy).toHaveBeenCalledWith("test");
});
it("saves the value to cache", async () => {
it("uses the session key when encrypting", async () => {
await sut.save("test", "value");
expect(sut["cache"]["test"]).toEqual("value");
});
it("encrypts and saves the value to local storage", async () => {
await sut.save("test", "value");
expect(memoryStorage.get).toHaveBeenCalledWith("session-key");
expect(encryptService.encryptString).toHaveBeenCalledWith(
JSON.stringify("value"),
sessionKey,
);
expect(localStorage.internalStore["session_test"]).toEqual(encString.encryptedString);
});
it("emits an update", async () => {
const spy = jest.spyOn(sut["updatesSubject"], "next");
const updateSpy = jest.spyOn(sut["updatesSubject"], "next");
await sut.save("test", "value");
expect(spy).toHaveBeenCalledWith({ key: "test", updateType: "save" });
expect(updateSpy).toHaveBeenCalledWith({ key: "test", updateType: "save" });
});
it("creates a new session key when session key is missing before saving", async () => {
const newSessionKey = makeSymmetricCryptoKey();
memoryStorage.get.mockResolvedValue(null);
keyGenerationService.createKeyWithPurpose.mockResolvedValue({
salt: "salt",
material: new Uint8Array(16) as any,
derivedKey: newSessionKey,
});
localStorage.getKeys.mockResolvedValue([]);
await sut.save("test", "value");
expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalled();
expect(encryptService.encryptString).toHaveBeenCalledWith(
JSON.stringify("value"),
newSessionKey,
);
});
});
@ -171,15 +283,50 @@ describe("LocalBackedSessionStorage", () => {
});
it("removes the key from local storage", async () => {
localStorage.internalStore["session_test"] = makeEncString("encrypted").encryptedString;
await sut.remove("test");
expect(localStorage.internalStore["session_test"]).toBeUndefined();
expect(localStorage.remove).toHaveBeenCalledWith("session_test");
});
it("emits an update", async () => {
const spy = jest.spyOn(sut["updatesSubject"], "next");
const updateSpy = jest.spyOn(sut["updatesSubject"], "next");
await sut.remove("test");
expect(spy).toHaveBeenCalledWith({ key: "test", updateType: "remove" });
expect(updateSpy).toHaveBeenCalledWith({ key: "test", updateType: "remove" });
});
});
describe("sessionStorageKey", () => {
it("prefixes keys with session_ prefix", () => {
expect(sut["sessionStorageKey"]("test")).toBe("session_test");
});
});
describe("clear", () => {
it("only removes keys with session_ prefix", async () => {
const removeSpy = jest.spyOn(sut, "remove");
localStorage.getKeys.mockResolvedValue([
"session_data1",
"session_data2",
"regular_key",
"another_key",
"session_data3",
"my_session_key",
"mysession",
"sessiondata",
"user_session",
]);
await sut["clear"]();
expect(removeSpy).toHaveBeenCalledWith("data1");
expect(removeSpy).toHaveBeenCalledWith("data2");
expect(removeSpy).toHaveBeenCalledWith("data3");
expect(removeSpy).not.toHaveBeenCalledWith("regular_key");
expect(removeSpy).not.toHaveBeenCalledWith("another_key");
expect(removeSpy).not.toHaveBeenCalledWith("my_session_key");
expect(removeSpy).not.toHaveBeenCalledWith("mysession");
expect(removeSpy).not.toHaveBeenCalledWith("sessiondata");
expect(removeSpy).not.toHaveBeenCalledWith("user_session");
expect(removeSpy).toHaveBeenCalledTimes(3);
});
});
});

View File

@ -2,6 +2,7 @@
// @ts-strict-ignore
import { Subject } from "rxjs";
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@ -12,33 +13,94 @@ import {
StorageUpdate,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { compareValues } from "@bitwarden/common/platform/misc/compare-values";
import { Lazy } from "@bitwarden/common/platform/misc/lazy";
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { StorageService } from "@bitwarden/storage-core";
import { BrowserApi } from "../browser/browser-api";
import { MemoryStoragePortMessage } from "../storage/port-messages";
import { portName } from "../storage/port-name";
import BrowserLocalStorageService from "./browser-local-storage.service";
const SESSION_KEY_PREFIX = "session_";
/**
* Manages an ephemeral session key for encrypting session storage items persisted in local storage.
*
* The session key is stored in session storage and automatically cleared when the browser session ends
* (e.g., browser restart, extension reload). When the session key is unavailable, any encrypted items
* in local storage cannot be decrypted and must be cleared to maintain data consistency.
*
* This provides session-scoped security for sensitive data while using persistent local storage as the backing store.
*
* @internal Internal implementation detail. Exported only for testing purposes.
* Do not use this class directly outside of tests. Use LocalBackedSessionStorageService instead.
*/
export class SessionKeyResolveService {
constructor(
private readonly storageService: StorageService,
private readonly keyGenerationService: KeyGenerationService,
) {}
/**
* Retrieves the session key from storage.
*
* @return session key or null when not in storage
*/
async get(): Promise<SymmetricCryptoKey | null> {
const key = await this.storageService.get<SymmetricCryptoKey>("session-key");
if (key) {
if (this.storageService.valuesRequireDeserialization) {
return SymmetricCryptoKey.fromJSON(key);
}
return key;
}
return null;
}
/**
* Creates new session key and adds it to underlying storage.
*
* @return newly created session key
*/
async create(): Promise<SymmetricCryptoKey> {
const { derivedKey } = await this.keyGenerationService.createKeyWithPurpose(
128,
"ephemeral",
"bitwarden-ephemeral",
);
await this.storageService.save("session-key", derivedKey.toJSON());
return derivedKey;
}
}
export class LocalBackedSessionStorageService
extends AbstractStorageService
implements ObservableStorageService
{
readonly valuesRequireDeserialization = true;
private ports: Set<chrome.runtime.Port> = new Set([]);
private cache: Record<string, unknown> = {};
private updatesSubject = new Subject<StorageUpdate>();
readonly valuesRequireDeserialization = true;
updates$ = this.updatesSubject.asObservable();
private readonly sessionKeyResolveService: SessionKeyResolveService;
constructor(
private readonly sessionKey: Lazy<Promise<SymmetricCryptoKey>>,
private readonly localStorage: AbstractStorageService,
private readonly memoryStorage: StorageService,
private readonly localStorage: BrowserLocalStorageService,
private readonly keyGenerationService: KeyGenerationService,
private readonly encryptService: EncryptService,
private readonly platformUtilsService: PlatformUtilsService,
private readonly logService: LogService,
) {
super();
this.sessionKeyResolveService = new SessionKeyResolveService(
this.memoryStorage,
this.keyGenerationService,
);
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
if (port.name !== portName(chrome.storage.session)) {
return;
@ -70,20 +132,20 @@ export class LocalBackedSessionStorageService
}
async get<T>(key: string, options?: StorageOptions): Promise<T> {
if (this.cache[key] !== undefined) {
if (this.cache[key] != null) {
return this.cache[key] as T;
}
const value = await this.getLocalSessionValue(await this.sessionKey.get(), key);
const value = await this.getLocalSessionValue(await this.getSessionKey(), key);
if (this.cache[key] === undefined && value !== undefined) {
if (this.cache[key] == null && value != null) {
// Cache is still empty and we just got a value from local/session storage, cache it.
this.cache[key] = value;
return value as T;
} else if (this.cache[key] === undefined && value === undefined) {
} else if (this.cache[key] == null && value == null) {
// Cache is still empty and we got nothing from local/session storage, no need to modify cache.
return value as T;
} else if (this.cache[key] !== undefined && value !== undefined) {
} else if (this.cache[key] != null && value != null) {
// Conflict, somebody wrote to the cache while we were reading from storage
// but we also got a value from storage. We assume the cache is more up to date
// and use that value.
@ -91,7 +153,7 @@ export class LocalBackedSessionStorageService
`Conflict while reading from local session storage, both cache and storage have values. Key: ${key}. Using cached value.`,
);
return this.cache[key] as T;
} else if (this.cache[key] !== undefined && value === undefined) {
} else if (this.cache[key] != null && value == null) {
// Cache was filled after the local/session storage read completed. We got null
// from the storage read, but we have a value from the cache, use that.
this.logService.warning(
@ -136,6 +198,44 @@ export class LocalBackedSessionStorageService
this.updatesSubject.next({ key, updateType: "remove" });
}
protected broadcastMessage(data: Omit<MemoryStoragePortMessage, "originator">) {
this.ports.forEach((port) => {
this.sendMessageTo(port, data);
});
}
private async getSessionKey(): Promise<SymmetricCryptoKey> {
const sessionKey = await this.sessionKeyResolveService.get();
if (sessionKey != null) {
return sessionKey;
}
// Session key is missing (browser restart/extension reload), so all stored session data
// cannot be decrypted. Clear all items before creating a new session key.
await this.clear();
return await this.sessionKeyResolveService.create();
}
/**
* Removes all stored session data.
*
* Called when the session key is unavailable (typically after browser restart or extension reload),
* making all encrypted session data unrecoverable. Prevents orphaned encrypted data from accumulating.
*/
private async clear() {
const keys = (await this.localStorage.getKeys()).filter((key) =>
key.startsWith(SESSION_KEY_PREFIX),
);
this.logService.debug(
`[LocalBackedSessionStorageService] Clearing local session storage. Found ${keys}`,
);
for (const key of keys) {
const keyWithoutPrefix = key.substring(SESSION_KEY_PREFIX.length);
await this.remove(keyWithoutPrefix);
}
}
private async getLocalSessionValue(encKey: SymmetricCryptoKey, key: string): Promise<unknown> {
const local = await this.localStorage.get<string>(this.sessionStorageKey(key));
if (local == null) {
@ -159,10 +259,7 @@ export class LocalBackedSessionStorageService
}
const valueJson = JSON.stringify(value);
const encValue = await this.encryptService.encryptString(
valueJson,
await this.sessionKey.get(),
);
const encValue = await this.encryptService.encryptString(valueJson, await this.getSessionKey());
await this.localStorage.save(this.sessionStorageKey(key), encValue.encryptedString);
}
@ -197,12 +294,6 @@ export class LocalBackedSessionStorageService
});
}
protected broadcastMessage(data: Omit<MemoryStoragePortMessage, "originator">) {
this.ports.forEach((port) => {
this.sendMessageTo(port, data);
});
}
private sendMessageTo(
port: chrome.runtime.Port,
data: Omit<MemoryStoragePortMessage, "originator">,
@ -214,7 +305,7 @@ export class LocalBackedSessionStorageService
}
private sessionStorageKey(key: string) {
return `session_${key}`;
return `${SESSION_KEY_PREFIX}${key}`;
}
private compareValues<T>(value1: T, value2: T): boolean {

View File

@ -6,7 +6,7 @@
[pageTitle]="''"
>
<div class="tw-w-32">
<bit-icon *ngIf="showLogo" [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
<bit-svg *ngIf="showLogo" [content]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-svg>
</div>
<ng-container slot="end">

View File

@ -5,10 +5,10 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router";
import { Subject, filter, switchMap, takeUntil, tap } from "rxjs";
import { BitwardenLogo, Icon } from "@bitwarden/assets/svg";
import { BitwardenLogo, BitSvg } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
IconModule,
SvgModule,
Translation,
AnonLayoutComponent,
AnonLayoutWrapperData,
@ -38,7 +38,7 @@ export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData {
CommonModule,
CurrentAccountComponent,
I18nPipe,
IconModule,
SvgModule,
PopOutComponent,
PopupPageComponent,
PopupHeaderComponent,
@ -54,7 +54,7 @@ export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy {
protected pageTitle: string;
protected pageSubtitle: string;
protected pageIcon: Icon;
protected pageIcon: BitSvg;
protected showReadonlyHostname: boolean;
protected maxWidth: "md" | "3xl";
protected hasLoggedInAccount: boolean = false;

View File

@ -14,7 +14,7 @@
class="tw-flex tw-bg-background-alt tw-flex-col tw-justify-center tw-items-center tw-gap-2 tw-h-full tw-px-5"
>
<div class="tw-size-[95px] tw-content-center">
<bit-icon [icon]="sendCreatedIcon"></bit-icon>
<bit-svg [content]="sendCreatedIcon"></bit-svg>
</div>
<h3 tabindex="0" appAutofocus class="tw-font-medium">
{{ "createdSendSuccessfully" | i18n }}

View File

@ -14,7 +14,7 @@ import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/defau
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { ButtonModule, I18nMockService, IconModule, ToastService } from "@bitwarden/components";
import { ButtonModule, I18nMockService, SvgModule, ToastService } from "@bitwarden/components";
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
@ -76,7 +76,7 @@ describe("SendCreatedComponent", () => {
RouterTestingModule,
JslibModule,
ButtonModule,
IconModule,
SvgModule,
PopOutComponent,
PopupHeaderComponent,
PopupPageComponent,

View File

@ -13,7 +13,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { ButtonModule, IconModule, ToastService } from "@bitwarden/components";
import { ButtonModule, SvgModule, ToastService } from "@bitwarden/components";
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
@ -34,7 +34,7 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page
PopupPageComponent,
RouterModule,
PopupFooterComponent,
IconModule,
SvgModule,
],
})
export class SendCreatedComponent {

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