1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 13:40:06 +00:00

Merge branch 'main' into innovation/user-achievements/event-stream-prototype

This commit is contained in:
✨ Audrey ✨
2025-03-24 10:43:38 -04:00
64 changed files with 1374 additions and 552 deletions

View File

@@ -312,6 +312,7 @@ jobs:
cosign sign --yes ${images}
- name: Scan Docker image
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
id: container-scan
uses: anchore/scan-action@869c549e657a088dc0441b08ce4fc0ecdac2bb65 # v5.3.0
with:
@@ -320,9 +321,12 @@ jobs:
output-format: sarif
- name: Upload Grype results to GitHub
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: github/codeql-action/upload-sarif@d68b2d4edb4189fd2a5366ac14e72027bd4b37dd # v3.28.2
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
- name: Log out of Docker
run: docker logout

View File

@@ -49,6 +49,8 @@ jobs:
uses: github/codeql-action/upload-sarif@d68b2d4edb4189fd2a5366ac14e72027bd4b37dd # v3.28.2
with:
sarif_file: cx_result.sarif
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
quality:
name: Quality scan

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2025.3.0",
"version": "2025.3.1",
"scripts": {
"build": "npm run build:chrome",
"build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",

View File

@@ -1668,7 +1668,7 @@
"message": "Zum Sortieren ziehen"
},
"dragToReorder": {
"message": "Ziehen zum umsortieren"
"message": "Ziehen zum Umsortieren"
},
"cfTypeText": {
"message": "Text"

View File

@@ -81,7 +81,7 @@
"message": "Podsjetnik glavne lozinke (neobavezno)"
},
"passwordStrengthScore": {
"message": "Password strength score $SCORE$",
"message": "Ocjena jačine lozinke: $SCORE$",
"placeholders": {
"score": {
"content": "$1",
@@ -186,7 +186,7 @@
"message": "Kopiraj bilješke"
},
"copy": {
"message": "Copy",
"message": "Kopiraj",
"description": "Copy to clipboard"
},
"fill": {
@@ -380,7 +380,7 @@
"message": "Uredi mapu"
},
"editFolderWithName": {
"message": "Edit folder: $FOLDERNAME$",
"message": "Uredi mapu: $FOLDERNAME$",
"placeholders": {
"foldername": {
"content": "$1",
@@ -653,7 +653,7 @@
"message": "Web preglednik ne podržava jednostavno kopiranje međuspremnika. Umjesto toga ručno kopirajte."
},
"verifyYourIdentity": {
"message": "Verify your identity"
"message": "Potvrdi svoj identitet"
},
"weDontRecognizeThisDevice": {
"message": "Ne prepoznajemo ovaj uređaj. Za potvrdu identiteta unesi kôd poslan e-poštom."
@@ -869,19 +869,19 @@
"message": "Prijavi se u Bitwarden"
},
"enterTheCodeSentToYourEmail": {
"message": "Enter the code sent to your email"
"message": "Unesi kôd poslan e-poštom"
},
"enterTheCodeFromYourAuthenticatorApp": {
"message": "Enter the code from your authenticator app"
"message": "Unesi kôd iz svoje aplikacije za autentifikaciju"
},
"pressYourYubiKeyToAuthenticate": {
"message": "Press your YubiKey to authenticate"
"message": "Za autentifikaciju dodirni svoj YubiKey"
},
"duoTwoFactorRequiredPageSubtitle": {
"message": "Duo two-step login is required for your account. Follow the steps below to finish logging in."
"message": "Za tvoj je račun potrebna Duo prijava u dva koraka. Za dovršetak prijave, slijedi daljnje korake."
},
"followTheStepsBelowToFinishLoggingIn": {
"message": "Follow the steps below to finish logging in."
"message": "Prati korake za dovršetak prijave."
},
"restartRegistration": {
"message": "Ponovno pokreni registraciju"
@@ -905,7 +905,7 @@
"message": "Ne"
},
"location": {
"message": "Location"
"message": "Lokacija"
},
"unexpectedError": {
"message": "Došlo je do neočekivane pogreške."
@@ -1040,7 +1040,7 @@
"message": "Klikni stavke za auto-ispunu na prikazu trezora"
},
"clickToAutofill": {
"message": "Click items in autofill suggestion to fill"
"message": "Kliknite stavku u prijedlogu auto-ispune za popunjavanje"
},
"clearClipboard": {
"message": "Očisti međuspremnik",
@@ -1057,7 +1057,7 @@
"message": "Spremi"
},
"loginSaveSuccessDetails": {
"message": "$USERNAME$ saved to Bitwarden.",
"message": "$USERNAME$ spremljeno u Bitwarden.",
"placeholders": {
"username": {
"content": "$1"
@@ -1066,7 +1066,7 @@
"description": "Shown to user after login is saved."
},
"loginUpdatedSuccessDetails": {
"message": "$USERNAME$ updated in Bitwarden.",
"message": "$USERNAME$ ažurirano u Bitwardenu.",
"placeholders": {
"username": {
"content": "$1"
@@ -1075,35 +1075,35 @@
"description": "Shown to user after login is updated."
},
"saveAsNewLoginAction": {
"message": "Save as new login",
"message": "Spremi novu prijavu",
"description": "Button text for saving login details as a new entry."
},
"updateLoginAction": {
"message": "Update login",
"message": "Ažuriraj prijavu",
"description": "Button text for updating an existing login entry."
},
"saveLoginPrompt": {
"message": "Save login?",
"message": "Spremiti prijavu?",
"description": "Prompt asking the user if they want to save their login details."
},
"updateLoginPrompt": {
"message": "Update existing login?",
"message": "Ažurirati postojeću prijavu?",
"description": "Prompt asking the user if they want to update an existing login entry."
},
"loginSaveSuccess": {
"message": "Login saved",
"message": "Prijava spremljena",
"description": "Message displayed when login details are successfully saved."
},
"loginUpdateSuccess": {
"message": "Login updated",
"message": "Prijava ažurirana",
"description": "Message displayed when login details are successfully updated."
},
"saveFailure": {
"message": "Error saving",
"message": "Greška kod spremanja",
"description": "Error message shown when the system fails to save login details."
},
"saveFailureDetails": {
"message": "Oh no! We couldn't save this. Try entering the details manually.",
"message": "Ups! Nismo mogli ovo spasiti. Pokušaj ručno unijeti detalje.",
"description": "Detailed error message shown when saving login details fails."
},
"enableChangedPasswordNotification": {
@@ -1422,7 +1422,7 @@
"message": "Zapamti me"
},
"dontAskAgainOnThisDeviceFor30Days": {
"message": "Don't ask again on this device for 30 days"
"message": "Ne pitaj na ovom uređaju idućih 30 dana"
},
"sendVerificationCodeEmailAgain": {
"message": "Ponovno slanje kontrolnog koda e-poštom"
@@ -1431,11 +1431,11 @@
"message": "Koristiti drugi način prijave dvostrukom autentifikacijom"
},
"selectAnotherMethod": {
"message": "Select another method",
"message": "Odaberi drugi način",
"description": "Select another two-step login method"
},
"useYourRecoveryCode": {
"message": "Use your recovery code"
"message": "Koristi kôd za oporavak"
},
"insertYubiKey": {
"message": "Umetni svoj YubiKey u USB priključak računala, a zatim dodirni njegovu tipku."
@@ -1450,16 +1450,16 @@
"message": "Otvori novu karticu"
},
"openInNewTab": {
"message": "Open in new tab"
"message": "Otvori u novoj kartici"
},
"webAuthnAuthenticate": {
"message": "Ovjeri WebAuthn"
},
"readSecurityKey": {
"message": "Read security key"
"message": "Pročitaj sigurnosni ključ"
},
"awaitingSecurityKeyInteraction": {
"message": "Awaiting security key interaction..."
"message": "Čekanje na interakciju sa sigurnosnim ključem..."
},
"loginUnavailable": {
"message": "Prijava nije dostupna"
@@ -1474,7 +1474,7 @@
"message": "Mogućnosti prijave dvostrukom autentifikacijom"
},
"selectTwoStepLoginMethod": {
"message": "Select two-step login method"
"message": "Odaberi način prijave dvostrukom autentifikacijom"
},
"recoveryCodeDesc": {
"message": "Izgubljen je pristup uređaju za dvostruku autentifikaciju? Koristi svoj kôd za oporavak za onemogućavanje svih pružatelja usluga dvostruke autentifikacije na tvojem računu."
@@ -1668,7 +1668,7 @@
"message": "Povuci za sortiranje"
},
"dragToReorder": {
"message": "Drag to reorder"
"message": "Povuci za premještanje"
},
"cfTypeText": {
"message": "Tekst"
@@ -2164,7 +2164,7 @@
"description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'"
},
"vaultCustomization": {
"message": "Vault customization"
"message": "Prilagodba trezora"
},
"vaultTimeoutAction": {
"message": "Nakon isteka trezora"
@@ -2173,13 +2173,13 @@
"message": "Radnja nakon isteka "
},
"newCustomizationOptionsCalloutTitle": {
"message": "New customization options"
"message": "Nove mogućnosti prilagodbe"
},
"newCustomizationOptionsCalloutContent": {
"message": "Customize your vault experience with quick copy actions, compact mode, and more!"
"message": "Prilagodi svoje iskustvo trezora brzim kopiranjem, kompaktnim načinom rada i više!"
},
"newCustomizationOptionsCalloutLink": {
"message": "View all Appearance settings"
"message": "Pogledaj sve postavke izgleda"
},
"lock": {
"message": "Zaključaj",
@@ -2476,7 +2476,7 @@
"message": "Rizične lozinke"
},
"atRiskPasswordDescSingleOrg": {
"message": "$ORGANIZATION$ is requesting you change one password because it is at-risk.",
"message": "$ORGANIZATION$ traži da promijeniš jednu rizičnu lozinku.",
"placeholders": {
"organization": {
"content": "$1",
@@ -2485,7 +2485,7 @@
}
},
"atRiskPasswordsDescSingleOrgPlural": {
"message": "$ORGANIZATION$ is requesting you change the $COUNT$ passwords because they are at-risk.",
"message": "Broj rizičnih lozinki koje $ORGANIZATION$ traži da promijeniš: $COUNT$.",
"placeholders": {
"organization": {
"content": "$1",
@@ -2498,7 +2498,7 @@
}
},
"atRiskPasswordsDescMultiOrgPlural": {
"message": "Your organizations are requesting you change the $COUNT$ passwords because they are at-risk.",
"message": "Broj rizičnih lozinki koje tvoja orgnaizacija traži da promijeniš: $COUNT$.",
"placeholders": {
"count": {
"content": "$1",
@@ -2525,34 +2525,34 @@
"message": "Ažuriraj svoje postavke kako za brzu auto-ispunu svojih lozinki i generiranje novih"
},
"reviewAtRiskLogins": {
"message": "Review at-risk logins"
"message": "Pregledaj rizične prijave"
},
"reviewAtRiskPasswords": {
"message": "Review at-risk passwords"
"message": "Pregdledaj rizične lozinke"
},
"reviewAtRiskLoginsSlideDesc": {
"message": "Your organization passwords are at-risk because they are weak, reused, and/or exposed.",
"message": "Lozinke tvoje organizacije su rizične jer su slabe, nanovo korištene i/ili iscurile.",
"description": "Description of the review at-risk login slide on the at-risk password page carousel"
},
"reviewAtRiskLoginSlideImgAlt": {
"message": "Illustration of a list of logins that are at-risk"
"message": "Ilustracija liste rizičnih prijava"
},
"generatePasswordSlideDesc": {
"message": "Quickly generate a strong, unique password with the Bitwarden autofill menu on the at-risk site.",
"message": "Brzo generiraj jake, jedinstvene lozinke koristeći Bitwarden dijalog auto-ispune direktno na stranici.",
"description": "Description of the generate password slide on the at-risk password page carousel"
},
"generatePasswordSlideImgAlt": {
"message": "Illustration of the Bitwarden autofill menu displaying a generated password"
"message": "Ilustracija Bitwarden dijalog auto-ispune s prikazom generirane lozinke"
},
"updateInBitwarden": {
"message": "Update in Bitwarden"
"message": "Ažuriraj u Bitwardenu"
},
"updateInBitwardenSlideDesc": {
"message": "Bitwarden will then prompt you to update the password in the password manager.",
"message": "Bitwarden će te pitati treba li ažurirati lozinku u upravitelju lozinki.",
"description": "Description of the update in Bitwarden slide on the at-risk password page carousel"
},
"updateInBitwardenSlideImgAlt": {
"message": "Illustration of a Bitwardens notification prompting the user to update the login"
"message": "Ilustracija Bitwarden upita za ažuriranje prijave"
},
"turnOnAutofill": {
"message": "Uključi auto-ispunu"
@@ -3168,7 +3168,7 @@
}
},
"forwaderInvalidOperation": {
"message": "$SERVICENAME$ refused your request. Please contact your service provider for assistance.",
"message": "$SERVICENAME$ je odbio tvoj zahtjev. Obrati se svom pružatelju usluga za pomoć.",
"description": "Displayed when the user is forbidden from using the API by the forwarding service.",
"placeholders": {
"servicename": {
@@ -3178,7 +3178,7 @@
}
},
"forwaderInvalidOperationWithMessage": {
"message": "$SERVICENAME$ refused your request: $ERRORMESSAGE$",
"message": "$SERVICENAME$ je odbio tvoj zahtjev: $ERRORMESSAGE$",
"description": "Displayed when the user is forbidden from using the API by the forwarding service with an error message.",
"placeholders": {
"servicename": {
@@ -4080,7 +4080,7 @@
"message": "Aktivni račun"
},
"bitwardenAccount": {
"message": "Bitwarden account"
"message": "Bitwarden račun"
},
"availableAccounts": {
"message": "Dostupni računi"
@@ -4281,7 +4281,7 @@
}
},
"copyFieldValue": {
"message": "Copy $FIELD$, $VALUE$",
"message": "Kopiraj $FIELD$, $VALUE$",
"description": "Title for a button that copies a field value to the clipboard.",
"placeholders": {
"field": {
@@ -4671,7 +4671,7 @@
}
},
"reorderWebsiteUriButton": {
"message": "Reorder website URI. Use arrow key to move item up or down."
"message": "Ponovno poredaj URI. Koristi tipke sa strelicom za pomicanje stavke gore ili dolje."
},
"reorderFieldUp": {
"message": "$LABEL$ pomaknut gore, pozicija $INDEX$ od $LENGTH$",
@@ -5096,31 +5096,31 @@
"message": "Ekstra široko"
},
"sshKeyWrongPassword": {
"message": "The password you entered is incorrect."
"message": "Unesena lozinka nije ispravna."
},
"importSshKey": {
"message": "Import"
"message": "Uvoz"
},
"confirmSshKeyPassword": {
"message": "Confirm password"
"message": "Potvrdi lozinku"
},
"enterSshKeyPasswordDesc": {
"message": "Enter the password for the SSH key."
"message": "Unesi lozinku za SSH ključ."
},
"enterSshKeyPassword": {
"message": "Enter password"
"message": "Unesi lozinku"
},
"invalidSshKey": {
"message": "The SSH key is invalid"
"message": "SSH ključ nije valjan"
},
"sshKeyTypeUnsupported": {
"message": "The SSH key type is not supported"
"message": "Tip SSH ključa nije podržan"
},
"importSshKeyFromClipboard": {
"message": "Import key from clipboard"
"message": "Uvezi ključ iz međuspremnika"
},
"sshKeyImported": {
"message": "SSH key imported successfully"
"message": "SSH ključ uspješno uvezen"
},
"cannotRemoveViewOnlyCollections": {
"message": "S dopuštenjima samo za prikaz ne možeš ukloniti zbirke: $COLLECTIONS$",
@@ -5138,6 +5138,6 @@
"message": "Za korištenje biometrijskog otključavanja ažuriraj desktop aplikaciju ili nemogući otključavanje otiskom prsta u desktop aplikaciji."
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
"message": "Promijeni rizičnu lozinku"
}
}

View File

@@ -186,7 +186,7 @@
"message": "Salin catatan"
},
"copy": {
"message": "Copy",
"message": "Salin",
"description": "Copy to clipboard"
},
"fill": {
@@ -380,7 +380,7 @@
"message": "Sunting Folder"
},
"editFolderWithName": {
"message": "Edit folder: $FOLDERNAME$",
"message": "Sunting folder: $FOLDERNAME$",
"placeholders": {
"foldername": {
"content": "$1",
@@ -462,16 +462,16 @@
"message": "Buat frasa sandi"
},
"passwordGenerated": {
"message": "Password generated"
"message": "Kata sandi dibuat"
},
"passphraseGenerated": {
"message": "Passphrase generated"
"message": "Frasa sandi dibuat"
},
"usernameGenerated": {
"message": "Username generated"
"message": "Nama pengguna dibuat"
},
"emailGenerated": {
"message": "Email generated"
"message": "Surel dibuat"
},
"regeneratePassword": {
"message": "Buat Ulang Kata Sandi"
@@ -653,7 +653,7 @@
"message": "Peramban Anda tidak mendukung menyalin clipboard dengan mudah. Salin secara manual."
},
"verifyYourIdentity": {
"message": "Verify your identity"
"message": "Verifikasikan identitas Anda"
},
"weDontRecognizeThisDevice": {
"message": "We don't recognize this device. Enter the code sent to your email to verify your identity."
@@ -905,7 +905,7 @@
"message": "Tidak"
},
"location": {
"message": "Location"
"message": "Lokasi"
},
"unexpectedError": {
"message": "Terjadi kesalahan yang tak diduga."
@@ -2461,7 +2461,7 @@
"message": "Change this in settings"
},
"change": {
"message": "Change"
"message": "Ubah"
},
"changeButtonTitle": {
"message": "Change password - $ITEMNAME$",
@@ -4784,7 +4784,7 @@
"message": "Text Sends"
},
"accountActions": {
"message": "Account actions"
"message": "Tindakan akun"
},
"showNumberOfAutofillSuggestions": {
"message": "Show number of login autofill suggestions on extension icon"
@@ -4793,22 +4793,22 @@
"message": "Show quick copy actions on Vault"
},
"systemDefault": {
"message": "System default"
"message": "Baku sistem"
},
"enterprisePolicyRequirementsApplied": {
"message": "Enterprise policy requirements have been applied to this setting"
"message": "Persyaratan kebijakan perusahaan telah diterapkan ke pengaturan ini"
},
"sshPrivateKey": {
"message": "Private key"
"message": "Kunci privat"
},
"sshPublicKey": {
"message": "Public key"
"message": "Kunci publik"
},
"sshFingerprint": {
"message": "Fingerprint"
"message": "Sidik jari"
},
"sshKeyAlgorithm": {
"message": "Key type"
"message": "Tipe kunci"
},
"sshKeyAlgorithmED25519": {
"message": "ED25519"
@@ -4823,7 +4823,7 @@
"message": "RSA 4096-Bit"
},
"retry": {
"message": "Retry"
"message": "Coba lagi"
},
"vaultCustomTimeoutMinimum": {
"message": "Minimum custom timeout is 1 minute."
@@ -5075,16 +5075,16 @@
}
},
"newDeviceVerificationNoticePageOneEmailAccessNo": {
"message": "No, I do not"
"message": "Tidak"
},
"newDeviceVerificationNoticePageOneEmailAccessYes": {
"message": "Yes, I can reliably access my email"
"message": "Ya, saya dapat mengakses surel saya secara handla"
},
"turnOnTwoStepLogin": {
"message": "Turn on two-step login"
},
"changeAcctEmail": {
"message": "Change account email"
"message": "Ubah surel akun"
},
"extensionWidth": {
"message": "Lebar ekstensi"
@@ -5096,31 +5096,31 @@
"message": "Ekstra lebar"
},
"sshKeyWrongPassword": {
"message": "The password you entered is incorrect."
"message": "Kata sandi yang Anda masukkan tidak benar."
},
"importSshKey": {
"message": "Import"
"message": "Impor"
},
"confirmSshKeyPassword": {
"message": "Confirm password"
"message": "Konfirmasi kata sandi"
},
"enterSshKeyPasswordDesc": {
"message": "Enter the password for the SSH key."
"message": "Masukkan kata sandi untuk kunci SSH."
},
"enterSshKeyPassword": {
"message": "Enter password"
"message": "Masukkan kata sandi"
},
"invalidSshKey": {
"message": "The SSH key is invalid"
"message": "Kunci SSH tidak valid"
},
"sshKeyTypeUnsupported": {
"message": "The SSH key type is not supported"
"message": "Tipe kunci SSH tidak didukung"
},
"importSshKeyFromClipboard": {
"message": "Import key from clipboard"
"message": "Impor kunci dari papan klip"
},
"sshKeyImported": {
"message": "SSH key imported successfully"
"message": "Kunci SSH sukses diimpor"
},
"cannotRemoveViewOnlyCollections": {
"message": "Anda tidak dapat menghapus koleksi dengan izin hanya lihat: $COLLECTIONS$",
@@ -5132,12 +5132,12 @@
}
},
"updateDesktopAppOrDisableFingerprintDialogTitle": {
"message": "Please update your desktop application"
"message": "Harap perbarui aplikasi desktop Anda"
},
"updateDesktopAppOrDisableFingerprintDialogMessage": {
"message": "To use biometric unlock, please update your desktop application, or disable fingerprint unlock in the desktop settings."
"message": "Untuk memakai pembuka kunci biometrik, harap perbarui aplikasi desktop Anda, atau matikan buka kunci sidik jari dalam pengaturan desktop."
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
"message": "Ubah kata sandi yang berrisiko"
}
}

View File

@@ -81,7 +81,7 @@
"message": "Hoofdwachtwoordhint (optioneel)"
},
"passwordStrengthScore": {
"message": "Score wachtwoordsterkte $SCORE$",
"message": "Wachtwoordsterkte score $SCORE$",
"placeholders": {
"score": {
"content": "$1",
@@ -878,10 +878,10 @@
"message": "Druk op je YubiKey om te verifiëren"
},
"duoTwoFactorRequiredPageSubtitle": {
"message": "Jouw account vereist Duo-tweestapsaanmelding. Volg de onderstaande stappen om het inloggen te voltooien."
"message": "Jouw account vereist Duo tweestapslogin. Volg de onderstaande stappen om het inloggen te voltooien."
},
"followTheStepsBelowToFinishLoggingIn": {
"message": "Volg de onderstaande stappen om in te loggen."
"message": "Volg de onderstaande stappen om het inloggen af te ronden."
},
"restartRegistration": {
"message": "Registratie herstarten"
@@ -1040,7 +1040,7 @@
"message": "Klik op items om automatisch in te vullen op de kluisweergave"
},
"clickToAutofill": {
"message": "Klik op gesuggereerde items om deze automatisch invullen"
"message": "Klik items in de automatisch invullen suggestie om in te vullen"
},
"clearClipboard": {
"message": "Klembord wissen",
@@ -1075,7 +1075,7 @@
"description": "Shown to user after login is updated."
},
"saveAsNewLoginAction": {
"message": "Als nieuwe login opslaan",
"message": "Opslaan als nieuwe login",
"description": "Button text for saving login details as a new entry."
},
"updateLoginAction": {
@@ -1422,7 +1422,7 @@
"message": "Mijn gegevens onthouden"
},
"dontAskAgainOnThisDeviceFor30Days": {
"message": "30 dagen niet meer vragen op dit apparaat"
"message": "30 dagen niet opnieuw vragen op dit apparaat"
},
"sendVerificationCodeEmailAgain": {
"message": "E-mail met verificatiecode opnieuw versturen"
@@ -1459,7 +1459,7 @@
"message": "Beveiligingssleutel lezen"
},
"awaitingSecurityKeyInteraction": {
"message": "Wacht op interactie met beveiligingssleutel..."
"message": "Wacht op interactie met beveiligingssleutel"
},
"loginUnavailable": {
"message": "Login niet beschikbaar"
@@ -1474,7 +1474,7 @@
"message": "Opties voor tweestapsaanmelding"
},
"selectTwoStepLoginMethod": {
"message": "Kies methode voor tweestapsaanmelding"
"message": "Kies methode voor tweestapslogin"
},
"recoveryCodeDesc": {
"message": "Ben je de toegang tot al je tweestapsaanbieders verloren? Gebruik dan je herstelcode om alle tweestapsaanbieders op je account uit te schakelen."
@@ -2164,7 +2164,7 @@
"description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'"
},
"vaultCustomization": {
"message": "Kluis-aanpassingen"
"message": "Kluis aanpassingen"
},
"vaultTimeoutAction": {
"message": "Actie bij time-out"
@@ -2176,10 +2176,10 @@
"message": "Nieuwe aanpassingsopties"
},
"newCustomizationOptionsCalloutContent": {
"message": "Personaliseer je kluiservaring met snelle kopieeracties, compacte modus en meer!"
"message": "Pas je kluis ervaring aan met snelle kopieeracties, compacte modus en meer!"
},
"newCustomizationOptionsCalloutLink": {
"message": "Alle personalisatie-instellingen bekijken"
"message": "Alle weergave-instellingen bekijken"
},
"lock": {
"message": "Vergrendelen",
@@ -2498,7 +2498,7 @@
}
},
"atRiskPasswordsDescMultiOrgPlural": {
"message": "Je organisatie(s) vragen je de $COUNT$ wachtwoorden te wijzigen omdat ze een risico vormen.",
"message": "Je organisaties vragen je de $COUNT$ wachtwoorden te wijzigen omdat ze een risico vormen.",
"placeholders": {
"count": {
"content": "$1",
@@ -2531,14 +2531,14 @@
"message": "Risicovolle wachtwoorden bekijken"
},
"reviewAtRiskLoginsSlideDesc": {
"message": "De wachtwoorden van je organisatie zijn in gevaar omdat ze zwak, hergebruikt en/of blootgelegd zijn.",
"message": "De wachtwoorden van je organisatie zijn in gevaar omdat ze zwak, hergebruikt en/of gelekt zijn.",
"description": "Description of the review at-risk login slide on the at-risk password page carousel"
},
"reviewAtRiskLoginSlideImgAlt": {
"message": "Voorbeeld van een lijst van risicovolle logins"
},
"generatePasswordSlideDesc": {
"message": "Genereer snel een sterk, uniek wachtwoord met het automatisch invulmenu van Bitwaren op de risicovolle website.",
"message": "Genereer snel een sterk, uniek wachtwoord met het automatisch invulmenu van Bitwarden op de risicovolle website.",
"description": "Description of the generate password slide on the at-risk password page carousel"
},
"generatePasswordSlideImgAlt": {
@@ -2552,7 +2552,7 @@
"description": "Description of the update in Bitwarden slide on the at-risk password page carousel"
},
"updateInBitwardenSlideImgAlt": {
"message": "Voorbeeld van een Bitwarden-melding die de gebruiker aanspoort tot het bijwerken van de login"
"message": "Voorbeeld van een Bitwarden melding die de gebruiker aanspoort tot het bijwerken van de login"
},
"turnOnAutofill": {
"message": "Automatisch invullen inschakelen"
@@ -4080,7 +4080,7 @@
"message": "Actief account"
},
"bitwardenAccount": {
"message": "Bitwarden-account"
"message": "Bitwarden account"
},
"availableAccounts": {
"message": "Beschikbare accounts"
@@ -5096,7 +5096,7 @@
"message": "Extra breed"
},
"sshKeyWrongPassword": {
"message": "Het door jou ingevoerde wachtwoord is onjuist."
"message": "Het wachtwoord dat je hebt ingevoerd is onjuist."
},
"importSshKey": {
"message": "Importeren"
@@ -5117,7 +5117,7 @@
"message": "Het type SSH-sleutel is niet ondersteund"
},
"importSshKeyFromClipboard": {
"message": "Sleutel van klembord importeren"
"message": "Sleutel importeren van klembord"
},
"sshKeyImported": {
"message": "SSH-sleutel succesvol geïmporteerd"

View File

@@ -2208,7 +2208,7 @@
"message": "项目已恢复"
},
"alreadyHaveAccount": {
"message": "已经有账户了吗?"
"message": "已经有账户了吗?"
},
"vaultTimeoutLogOutConfirmation": {
"message": "超时后注销账户将解除对密码库的所有访问权限,并需要进行在线身份验证。确定使用此设置吗?"
@@ -3628,7 +3628,7 @@
"message": "正在获取选项..."
},
"multiSelectNotFound": {
"message": "未找到任何目"
"message": "未找到任何目"
},
"multiSelectClearAll": {
"message": "清除全部"
@@ -4209,7 +4209,7 @@
"message": "建议的项目"
},
"autofillSuggestionsTip": {
"message": "将此站点保存登录项目以用于自动填充"
"message": "为这个站点保存一个登录项目以自动填充"
},
"yourVaultIsEmpty": {
"message": "您的密码库是空的"

View File

@@ -1,6 +1,7 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/services/policy/policy.service";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -59,6 +60,7 @@ describe("NotificationBackground", () => {
const themeStateService = mock<ThemeStateService>();
const configService = mock<ConfigService>();
const accountService = mock<AccountService>();
const organizationService = mock<OrganizationService>();
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({
id: "testId" as UserId,
@@ -73,18 +75,19 @@ describe("NotificationBackground", () => {
authService.activeAccountStatus$ = activeAccountStatusMock$;
accountService.activeAccount$ = activeAccountSubject;
notificationBackground = new NotificationBackground(
accountService,
authService,
autofillService,
cipherService,
authService,
policyService,
folderService,
userNotificationSettingsService,
configService,
domainSettingsService,
environmentService,
folderService,
logService,
organizationService,
policyService,
themeStateService,
configService,
accountService,
userNotificationSettingsService,
);
});

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -63,46 +64,48 @@ export default class NotificationBackground {
ExtensionCommand.AutofillIdentity,
]);
private readonly extensionMessageHandlers: NotificationBackgroundExtensionMessageHandlers = {
unlockCompleted: ({ message, sender }) => this.handleUnlockCompleted(message, sender),
bgGetFolderData: () => this.getFolderData(),
bgCloseNotificationBar: ({ message, sender }) =>
this.handleCloseNotificationBarMessage(message, sender),
bgAddLogin: ({ message, sender }) => this.addLogin(message, sender),
bgAdjustNotificationBar: ({ message, sender }) =>
this.handleAdjustNotificationBarMessage(message, sender),
bgAddLogin: ({ message, sender }) => this.addLogin(message, sender),
bgChangedPassword: ({ message, sender }) => this.changedPassword(message, sender),
bgRemoveTabFromNotificationQueue: ({ sender }) =>
this.removeTabFromNotificationQueue(sender.tab),
bgSaveCipher: ({ message, sender }) => this.handleSaveCipherMessage(message, sender),
bgNeverSave: ({ sender }) => this.saveNever(sender.tab),
collectPageDetailsResponse: ({ message }) =>
this.handleCollectPageDetailsResponseMessage(message),
bgUnlockPopoutOpened: ({ message, sender }) => this.unlockVault(message, sender.tab),
checkNotificationQueue: ({ sender }) => this.checkNotificationQueue(sender.tab),
bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab),
bgCloseNotificationBar: ({ message, sender }) =>
this.handleCloseNotificationBarMessage(message, sender),
bgGetActiveUserServerConfig: () => this.getActiveUserServerConfig(),
bgGetDecryptedCiphers: () => this.getNotificationCipherData(),
bgGetEnableChangedPasswordPrompt: () => this.getEnableChangedPasswordPrompt(),
bgGetEnableAddedLoginPrompt: () => this.getEnableAddedLoginPrompt(),
bgGetExcludedDomains: () => this.getExcludedDomains(),
bgGetActiveUserServerConfig: () => this.getActiveUserServerConfig(),
bgGetFolderData: () => this.getFolderData(),
bgGetOrgData: () => this.getOrgData(),
bgNeverSave: ({ sender }) => this.saveNever(sender.tab),
bgOpenVault: ({ message, sender }) => this.openVault(message, sender.tab),
bgRemoveTabFromNotificationQueue: ({ sender }) =>
this.removeTabFromNotificationQueue(sender.tab),
bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab),
bgSaveCipher: ({ message, sender }) => this.handleSaveCipherMessage(message, sender),
bgUnlockPopoutOpened: ({ message, sender }) => this.unlockVault(message, sender.tab),
checkNotificationQueue: ({ sender }) => this.checkNotificationQueue(sender.tab),
collectPageDetailsResponse: ({ message }) =>
this.handleCollectPageDetailsResponseMessage(message),
getWebVaultUrlForNotification: () => this.getWebVaultUrl(),
notificationRefreshFlagValue: () => this.getNotificationFlag(),
bgGetDecryptedCiphers: () => this.getNotificationCipherData(),
bgOpenVault: ({ message, sender }) => this.openVault(message, sender.tab),
unlockCompleted: ({ message, sender }) => this.handleUnlockCompleted(message, sender),
};
constructor(
private accountService: AccountService,
private authService: AuthService,
private autofillService: AutofillService,
private cipherService: CipherService,
private authService: AuthService,
private policyService: PolicyService,
private folderService: FolderService,
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
private configService: ConfigService,
private domainSettingsService: DomainSettingsService,
private environmentService: EnvironmentService,
private folderService: FolderService,
private logService: LogService,
private organizationService: OrganizationService,
private policyService: PolicyService,
private themeStateService: ThemeStateService,
private configService: ConfigService,
private accountService: AccountService,
private userNotificationSettingsService: UserNotificationSettingsServiceAbstraction,
) {}
init() {
@@ -744,6 +747,26 @@ export default class NotificationBackground {
);
}
/**
* Returns the first value found from the organization service organizations$ observable.
*/
private async getOrgData() {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
const organizations = await firstValueFrom(
this.organizationService.organizations$(activeUserId),
);
return organizations.map((org) => {
const { id, name, productTierType } = org;
return {
id,
name,
productTierType,
};
});
}
/**
* Handles the unlockCompleted extension message. Will close the notification bar
* after an attempted autofill action, and retry the autofill action if the message

View File

@@ -16,12 +16,12 @@ const { css } = createEmotion({
});
export function NotificationBody({
ciphers,
ciphers = [],
notificationType,
theme = ThemeTypes.Light,
handleEditOrUpdateAction,
}: {
ciphers: NotificationCipherData[];
ciphers?: NotificationCipherData[];
customClasses?: string[];
notificationType?: NotificationType;
theme: Theme;

View File

@@ -60,23 +60,18 @@ export function NotificationButtonRow({
)
: ([] as Option[]);
const noFolderOption: Option = {
default: true,
icon: Folder,
text: "No folder", // @TODO localize
value: "0",
};
const folderOptions: Option[] = folders?.length
? folders.reduce(
? folders.reduce<Option[]>(
(options, { id, name }: FolderView) => [
...options,
{
icon: Folder,
text: name,
value: id,
value: id === null ? "0" : id,
default: id === null,
},
],
[noFolderOption],
[],
)
: [];

View File

@@ -9,6 +9,7 @@ import {
NotificationType,
} from "../../../notification/abstractions/notification-bar";
import { NotificationCipherData } from "../cipher/types";
import { FolderView, OrgView } from "../common-types";
import { themes, spacing } from "../constants/styles";
import { NotificationBody, componentClassPrefix as notificationBodyClassPrefix } from "./body";
@@ -20,20 +21,24 @@ import {
export function NotificationContainer({
handleCloseNotification,
handleEditOrUpdateAction,
handleSaveAction,
ciphers,
folders,
i18n,
organizations,
theme = ThemeTypes.Light,
type,
ciphers,
handleSaveAction,
handleEditOrUpdateAction,
}: NotificationBarIframeInitData & {
handleCloseNotification: (e: Event) => void;
handleSaveAction: (e: Event) => void;
handleEditOrUpdateAction: (e: Event) => void;
} & {
ciphers?: NotificationCipherData[];
folders?: FolderView[];
i18n: { [key: string]: string };
organizations?: OrgView[];
type: NotificationType; // @TODO typing override for generic `NotificationBarIframeInitData.type`
ciphers: NotificationCipherData[];
}) {
const headerMessage = getHeaderMessage(i18n, type);
const showBody = true;
@@ -42,8 +47,8 @@ export function NotificationContainer({
<div class=${notificationContainerStyles(theme)}>
${NotificationHeader({
handleCloseNotification,
standalone: showBody,
message: headerMessage,
standalone: showBody,
theme,
})}
${showBody
@@ -56,9 +61,11 @@ export function NotificationContainer({
: null}
${NotificationFooter({
handleSaveAction,
theme,
notificationType: type,
folders,
i18n,
notificationType: type,
organizations,
theme,
})}
</div>
`;

View File

@@ -880,7 +880,7 @@ async function loadNotificationBar() {
const baseStyle = useComponentBar
? isNotificationFresh
? "height: calc(276px + 25px); width: 450px; right: 0; transform:translateX(100%); opacity:0;"
? "height: calc(276px + 50px); width: 450px; right: 0; transform:translateX(100%); opacity:0;"
: "height: calc(276px + 25px); width: 450px; right: 0; transform:translateX(0%); opacity:1;"
: "height: 42px; width: 100%;";
@@ -910,7 +910,7 @@ async function loadNotificationBar() {
function getFrameStyle(useComponentBar: boolean): string {
return (
(useComponentBar
? "height: calc(276px + 25px); width: 450px; right: 0;"
? "height: calc(276px + 50px); width: 450px; right: 0;"
: "height: 42px; width: 100%; left: 0;") +
" top: 0; padding: 0; position: fixed;" +
" z-index: 2147483647; visibility: visible;"

View File

@@ -1,5 +1,8 @@
import { Theme } from "@bitwarden/common/platform/enums";
import { NotificationCipherData } from "../../../autofill/content/components/cipher/types";
import { FolderView, OrgView } from "../../../autofill/content/components/common-types";
const NotificationTypes = {
Add: "add",
Change: "change",
@@ -9,21 +12,24 @@ const NotificationTypes = {
type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes];
type NotificationBarIframeInitData = {
type?: string; // @TODO use `NotificationType`
isVaultLocked?: boolean;
theme?: Theme;
removeIndividualVault?: boolean;
importType?: string;
applyRedesign?: boolean;
ciphers?: NotificationCipherData[];
folders?: FolderView[];
importType?: string;
isVaultLocked?: boolean;
launchTimestamp?: number;
organizations?: OrgView[];
removeIndividualVault?: boolean;
theme?: Theme;
type?: string; // @TODO use `NotificationType`
};
type NotificationBarWindowMessage = {
cipherId?: string;
command: string;
error?: string;
initData?: NotificationBarIframeInitData;
username?: string;
cipherId?: string;
};
type NotificationBarWindowMessageHandlers = {

View File

@@ -5,6 +5,8 @@ import { ConsoleLogService } from "@bitwarden/common/platform/services/console-l
import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { AdjustNotificationBarMessageData } from "../background/abstractions/notification.background";
import { NotificationCipherData } from "../content/components/cipher/types";
import { OrgView } from "../content/components/common-types";
import { NotificationConfirmationContainer } from "../content/components/notification/confirmation-container";
import { NotificationContainer } from "../content/components/notification/container";
import { buildSvgDomElement } from "../utils";
@@ -115,7 +117,7 @@ function setElementText(template: HTMLTemplateElement, elementId: string, text:
}
}
function initNotificationBar(message: NotificationBarWindowMessage) {
async function initNotificationBar(message: NotificationBarWindowMessage) {
const { initData } = message;
if (!initData) {
return;
@@ -131,7 +133,23 @@ function initNotificationBar(message: NotificationBarWindowMessage) {
// Current implementations utilize a require for scss files which creates the need to remove the node.
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((node) => node.remove());
sendPlatformMessage({ command: "bgGetDecryptedCiphers" }, (cipherData) => {
await Promise.all([
new Promise<OrgView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetOrgData" }, resolve),
),
new Promise<FolderView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetFolderData" }, resolve),
),
new Promise<NotificationCipherData[]>((resolve) =>
sendPlatformMessage({ command: "bgGetDecryptedCiphers" }, resolve),
),
]).then(([organizations, folders, ciphers]) => {
notificationBarIframeInitData = {
...notificationBarIframeInitData,
folders,
ciphers,
organizations,
};
// @TODO use context to avoid prop drilling
return render(
NotificationContainer({
@@ -142,7 +160,6 @@ function initNotificationBar(message: NotificationBarWindowMessage) {
handleSaveAction,
handleEditOrUpdateAction,
i18n,
ciphers: cipherData,
}),
document.body,
);

View File

@@ -1173,18 +1173,19 @@ export default class MainBackground {
() => this.generatePasswordToClipboard(),
);
this.notificationBackground = new NotificationBackground(
this.accountService,
this.authService,
this.autofillService,
this.cipherService,
this.authService,
this.policyService,
this.folderService,
this.userNotificationSettingsService,
this.configService,
this.domainSettingsService,
this.environmentService,
this.folderService,
this.logService,
this.organizationService,
this.policyService,
this.themeStateService,
this.configService,
this.accountService,
this.userNotificationSettingsService,
);
this.overlayNotificationsBackground = new OverlayNotificationsBackground(

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2025.3.0",
"version": "2025.3.1",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2025.3.0",
"version": "2025.3.1",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -440,6 +440,32 @@ const mapAddEditCipherInfoToInitialValues = (
initialValues.name = cipher.name;
}
if (cipher.type === CipherType.Card) {
const card = cipher.card;
if (card != null) {
if (card.cardholderName != null) {
initialValues.cardholderName = card.cardholderName;
}
if (card.number != null) {
initialValues.number = card.number;
}
if (card.expMonth != null) {
initialValues.expMonth = card.expMonth;
}
if (card.expYear != null) {
initialValues.expYear = card.expYear;
}
if (card.code != null) {
initialValues.code = card.code;
}
}
}
if (cipher.type === CipherType.Login) {
const login = cipher.login;

View File

@@ -2,11 +2,22 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, UserVerification};
use crate::{BitwardenError, Callback, Position, UserVerification};
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionRequest {
rp_id: String,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
allowed_credentials: Vec<Vec<u8>>,
window_xy: Position,
//extension_input: Vec<u8>, TODO: Implement support for extensions
}
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
rp_id: String,
credential_id: Vec<u8>,
user_name: String,
@@ -14,6 +25,7 @@ pub struct PasskeyAssertionRequest {
record_identifier: Option<String>,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
window_xy: Position,
}
#[derive(uniffi::Record, Serialize, Deserialize)]

View File

@@ -15,7 +15,10 @@ uniffi::setup_scaffolding!();
mod assertion;
mod registration;
use assertion::{PasskeyAssertionRequest, PreparePasskeyAssertionCallback};
use assertion::{
PasskeyAssertionRequest, PasskeyAssertionWithoutUserInterfaceRequest,
PreparePasskeyAssertionCallback,
};
use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback};
#[derive(uniffi::Enum, Debug, Serialize, Deserialize)]
@@ -26,6 +29,13 @@ pub enum UserVerification {
Discouraged,
}
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Position {
pub x: i32,
pub y: i32,
}
#[derive(Debug, uniffi::Error, Serialize, Deserialize)]
pub enum BitwardenError {
Internal(String),
@@ -141,6 +151,14 @@ impl MacOSProviderClient {
) {
self.send_message(request, Box::new(callback));
}
pub fn prepare_passkey_assertion_without_user_interface(
&self,
request: PasskeyAssertionWithoutUserInterfaceRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
self.send_message(request, Box::new(callback));
}
}
#[derive(Serialize, Deserialize)]

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{BitwardenError, Callback, UserVerification};
use crate::{BitwardenError, Callback, Position, UserVerification};
#[derive(uniffi::Record, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -13,6 +13,7 @@ pub struct PasskeyRegistrationRequest {
client_data_hash: Vec<u8>,
user_verification: UserVerification,
supported_algorithms: Vec<i32>,
window_xy: Position,
}
#[derive(uniffi::Record, Serialize, Deserialize)]

View File

@@ -118,6 +118,10 @@ export declare namespace autofill {
Required = 'required',
Discouraged = 'discouraged'
}
export interface Position {
x: number
y: number
}
export interface PasskeyRegistrationRequest {
rpId: string
userName: string
@@ -125,6 +129,7 @@ export declare namespace autofill {
clientDataHash: Array<number>
userVerification: UserVerification
supportedAlgorithms: Array<number>
windowXy: Position
}
export interface PasskeyRegistrationResponse {
rpId: string
@@ -133,6 +138,13 @@ export declare namespace autofill {
attestationObject: Array<number>
}
export interface PasskeyAssertionRequest {
rpId: string
clientDataHash: Array<number>
userVerification: UserVerification
allowedCredentials: Array<Array<number>>
windowXy: Position
}
export interface PasskeyAssertionWithoutUserInterfaceRequest {
rpId: string
credentialId: Array<number>
userName: string
@@ -140,6 +152,7 @@ export declare namespace autofill {
recordIdentifier?: string
clientDataHash: Array<number>
userVerification: UserVerification
windowXy: Position
}
export interface PasskeyAssertionResponse {
rpId: string
@@ -156,7 +169,7 @@ export declare namespace autofill {
* @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client.
* @param callback This function will be called whenever a message is received from a client.
*/
static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void): Promise<IpcServer>
static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void): Promise<IpcServer>
/** Return the path to the IPC server. */
getPath(): string
/** Stop the IPC server. */

View File

@@ -515,6 +515,14 @@ pub mod autofill {
pub value: Result<T, BitwardenError>,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Position {
pub x: i32,
pub y: i32,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -525,6 +533,7 @@ pub mod autofill {
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub supported_algorithms: Vec<i32>,
pub window_xy: Position,
}
#[napi(object)]
@@ -541,6 +550,18 @@ pub mod autofill {
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionRequest {
pub rp_id: String,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub allowed_credentials: Vec<Vec<u8>>,
pub window_xy: Position,
//extension_input: Vec<u8>, TODO: Implement support for extensions
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionWithoutUserInterfaceRequest {
pub rp_id: String,
pub credential_id: Vec<u8>,
pub user_name: String,
@@ -548,6 +569,7 @@ pub mod autofill {
pub record_identifier: Option<String>,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub window_xy: Position,
}
#[napi(object)]
@@ -592,6 +614,13 @@ pub mod autofill {
(u32, u32, PasskeyAssertionRequest),
ErrorStrategy::CalleeHandled,
>,
#[napi(
ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void"
)]
assertion_without_user_interface_callback: ThreadsafeFunction<
(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest),
ErrorStrategy::CalleeHandled,
>,
) -> napi::Result<Self> {
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(32);
tokio::spawn(async move {
@@ -628,6 +657,25 @@ pub mod autofill {
}
}
match serde_json::from_str::<
PasskeyMessage<PasskeyAssertionWithoutUserInterfaceRequest>,
>(&message)
{
Ok(msg) => {
let value = msg
.value
.map(|value| (client_id, msg.sequence_number, value))
.map_err(|e| napi::Error::from_reason(format!("{e:?}")));
assertion_without_user_interface_callback
.call(value, ThreadsafeFunctionCallMode::NonBlocking);
continue;
}
Err(e) => {
println!("[ERROR] Error deserializing message1: {e}");
}
}
match serde_json::from_str::<PasskeyMessage<PasskeyRegistrationRequest>>(
&message,
) {

View File

@@ -1,22 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="17021" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23504" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="17021"/>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23504"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="CredentialProviderViewController" customModuleProvider="target">
<customObject id="-2" userLabel="File's Owner" customClass="CredentialProviderViewController" customModule="autofill_extension" customModuleProvider="target">
<connections>
<outlet property="view" destination="1" id="2"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customView translatesAutoresizingMaskIntoConstraints="NO" id="1">
<customView hidden="YES" translatesAutoresizingMaskIntoConstraints="NO" id="1">
<rect key="frame" x="0.0" y="0.0" width="378" height="94"/>
<subviews>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="1uM-r7-H1c">
<rect key="frame" x="177" y="3" width="197" height="32"/>
<rect key="frame" x="184" y="3" width="191" height="32"/>
<buttonCell key="cell" type="push" title="Return Example Password" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="2l4-PO-we5">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
@@ -28,10 +29,7 @@
</connections>
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="NVE-vN-dkz">
<rect key="frame" x="99" y="3" width="82" height="32"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="60" id="cP1-hK-9ZX"/>
</constraints>
<rect key="frame" x="114" y="3" width="76" height="32"/>
<buttonCell key="cell" type="push" title="Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="6Up-t3-mwm">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
@@ -39,13 +37,16 @@
Gw
</string>
</buttonCell>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="60" id="cP1-hK-9ZX"/>
</constraints>
<connections>
<action selector="cancel:" target="-2" id="Qav-AK-DGt"/>
</connections>
</button>
<textField verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="aNc-0i-CWK">
<rect key="frame" x="135" y="63" width="108" height="16"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="left" title="autofill-extension" id="0xp-rC-2gr">
<rect key="frame" x="112" y="63" width="154" height="16"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="left" title="autofill-extension hello" id="0xp-rC-2gr">
<font key="font" metaFont="systemBold"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>

View File

@@ -17,17 +17,56 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
//
// If instead I make this a static, the deinit gets called correctly after each request.
// I think we still might want a static regardless, to be able to reuse the connection if possible.
static let client: MacOsProviderClient = {
let instance = MacOsProviderClient.connect()
// setup code
return instance
}()
let client: MacOsProviderClient = {
let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
// Check if the Electron app is running
let workspace = NSWorkspace.shared
let isRunning = workspace.runningApplications.contains { app in
app.bundleIdentifier == "com.bitwarden.desktop"
}
if !isRunning {
logger.log("[autofill-extension] Bitwarden Desktop not running, attempting to launch")
// Try to launch the app
if let appURL = workspace.urlForApplication(withBundleIdentifier: "com.bitwarden.desktop") {
let semaphore = DispatchSemaphore(value: 0)
workspace.openApplication(at: appURL,
configuration: NSWorkspace.OpenConfiguration()) { app, error in
if let error = error {
logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: \(error.localizedDescription)")
} else if let app = app {
logger.log("[autofill-extension] Successfully launched Bitwarden Desktop")
} else {
logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: unknown error")
}
semaphore.signal()
}
// Wait for launch completion with timeout
_ = semaphore.wait(timeout: .now() + 5.0)
// Add a small delay to allow for initialization
Thread.sleep(forTimeInterval: 1.0)
} else {
logger.error("[autofill-extension] Could not find Bitwarden Desktop app")
}
} else {
logger.log("[autofill-extension] Bitwarden Desktop is running")
}
logger.log("[autofill-extension] Connecting to Bitwarden over IPC")
return MacOsProviderClient.connect()
}()
init() {
logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider")
logger.log("[autofill-extension] initializing extension")
super.init(nibName: nil, bundle: nil)
}
@@ -43,40 +82,35 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
@IBAction func cancel(_ sender: AnyObject?) {
self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue))
}
@IBAction func passwordSelected(_ sender: AnyObject?) {
let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234")
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
}
/*
Implement this method if your extension supports showing credentials in the QuickType bar.
When the user selects a credential from your app, this method will be called with the
ASPasswordCredentialIdentity your app has previously saved to the ASCredentialIdentityStore.
Provide the password by completing the extension request with the associated ASPasswordCredential.
If using the credential would require showing custom UI for authenticating the user, cancel
the request with error code ASExtensionError.userInteractionRequired.
*/
// Deprecated
override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) {
logger.log("[autofill-extension] provideCredentialWithoutUserInteraction called \(credentialIdentity)")
logger.log("[autofill-extension] user \(credentialIdentity.user)")
logger.log("[autofill-extension] id \(credentialIdentity.recordIdentifier ?? "")")
logger.log("[autofill-extension] sid \(credentialIdentity.serviceIdentifier.identifier)")
logger.log("[autofill-extension] sidt \(credentialIdentity.serviceIdentifier.type.rawValue)")
private func getWindowPosition() -> Position {
let frame = self.view.window?.frame ?? .zero
let screenHeight = NSScreen.main?.frame.height ?? 0
// let databaseIsUnlocked = true
// if (databaseIsUnlocked) {
let passwordCredential = ASPasswordCredential(user: credentialIdentity.user, password: "example1234")
self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil)
// } else {
// self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code:ASExtensionError.userInteractionRequired.rawValue))
// }
// frame.width and frame.height is always 0. Estimating works OK for now.
let estimatedWidth:CGFloat = 400;
let estimatedHeight:CGFloat = 200;
let centerX = Int32(round(frame.origin.x + estimatedWidth/2))
let centerY = Int32(round(screenHeight - (frame.origin.y + estimatedHeight/2)))
return Position(x: centerX, y:centerY)
}
override func loadView() {
let view = NSView()
// Hide the native window since we only need the IPC connection
view.isHidden = true
self.view = view
}
override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) {
let timeoutTimer = createTimer()
if let request = credentialRequest as? ASPasskeyCredentialRequest {
if let passkeyIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity {
@@ -84,11 +118,16 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
class CallbackImpl: PreparePasskeyAssertionCallback {
let ctx: ASCredentialProviderExtensionContext
required init(_ ctx: ASCredentialProviderExtensionContext) {
let logger: Logger
let timeoutTimer: DispatchWorkItem
required init(_ ctx: ASCredentialProviderExtensionContext,_ logger: Logger, _ timeoutTimer: DispatchWorkItem) {
self.ctx = ctx
self.logger = logger
self.timeoutTimer = timeoutTimer
}
func onComplete(credential: PasskeyAssertionResponse) {
self.timeoutTimer.cancel()
ctx.completeAssertionRequest(using: ASPasskeyAssertionCredential(
userHandle: credential.userHandle,
relyingParty: credential.rpId,
@@ -100,6 +139,8 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
}
func onError(error: BitwardenError) {
logger.error("[autofill-extension] OnError called, cancelling the request \(error)")
self.timeoutTimer.cancel()
ctx.cancelRequest(withError: error)
}
}
@@ -113,55 +154,74 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
UserVerification.discouraged
}
let req = PasskeyAssertionRequest(
let req = PasskeyAssertionWithoutUserInterfaceRequest(
rpId: passkeyIdentity.relyingPartyIdentifier,
credentialId: passkeyIdentity.credentialID,
userName: passkeyIdentity.userName,
userHandle: passkeyIdentity.userHandle,
recordIdentifier: passkeyIdentity.recordIdentifier,
clientDataHash: request.clientDataHash,
userVerification: userVerification
userVerification: userVerification,
windowXy: self.getWindowPosition()
)
CredentialProviderViewController.client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext))
self.client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
return
}
}
if let request = credentialRequest as? ASPasswordCredentialRequest {
logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2(password) called \(request)")
return;
}
timeoutTimer.cancel()
logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2 called wrong")
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid authentication request"))
}
/*
Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with
ASExtensionError.userInteractionRequired. In this case, the system may present your extension's
UI and call this method. Show appropriate UI for authenticating the user then provide the password
by completing the extension request with the associated ASPasswordCredential.
override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) {
}
*/
override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) {
}
*/
private func createTimer() -> DispatchWorkItem {
// Create a timer for 600 second timeout
let timeoutTimer = DispatchWorkItem { [weak self] in
guard let self = self else { return }
logger.log("[autofill-extension] The operation timed out after 600 seconds")
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("The operation timed out"))
}
// Schedule the timeout
DispatchQueue.main.asyncAfter(deadline: .now() + 600, execute: timeoutTimer)
override func prepareInterfaceForExtensionConfiguration() {
logger.log("[autofill-extension] prepareInterfaceForExtensionConfiguration called")
return timeoutTimer
}
override func prepareInterface(forPasskeyRegistration registrationRequest: ASCredentialRequest) {
logger.log("[autofill-extension] prepareInterface")
let timeoutTimer = createTimer()
if let request = registrationRequest as? ASPasskeyCredentialRequest {
if let passkeyIdentity = registrationRequest.credentialIdentity as? ASPasskeyCredentialIdentity {
logger.log("[autofill-extension] prepareInterface(passkey) called \(request)")
class CallbackImpl: PreparePasskeyRegistrationCallback {
let ctx: ASCredentialProviderExtensionContext
required init(_ ctx: ASCredentialProviderExtensionContext) {
let timeoutTimer: DispatchWorkItem
let logger: Logger
required init(_ ctx: ASCredentialProviderExtensionContext, _ logger: Logger,_ timeoutTimer: DispatchWorkItem) {
self.ctx = ctx
self.logger = logger
self.timeoutTimer = timeoutTimer
}
func onComplete(credential: PasskeyRegistrationResponse) {
self.timeoutTimer.cancel()
ctx.completeRegistrationRequest(using: ASPasskeyRegistrationCredential(
relyingParty: credential.rpId,
clientDataHash: credential.clientDataHash,
@@ -169,58 +229,99 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
attestationObject: credential.attestationObject
))
}
func onError(error: BitwardenError) {
logger.error("[autofill-extension] OnError called, cancelling the request \(error)")
self.timeoutTimer.cancel()
ctx.cancelRequest(withError: error)
}
}
let userVerification = switch request.userVerificationPreference {
case .preferred:
UserVerification.preferred
case .required:
UserVerification.required
default:
UserVerification.discouraged
case .preferred:
UserVerification.preferred
case .required:
UserVerification.required
default:
UserVerification.discouraged
}
let req = PasskeyRegistrationRequest(
rpId: passkeyIdentity.relyingPartyIdentifier,
userName: passkeyIdentity.userName,
userHandle: passkeyIdentity.userHandle,
clientDataHash: request.clientDataHash,
userVerification: userVerification,
supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) }
supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) },
windowXy: self.getWindowPosition()
)
CredentialProviderViewController.client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext))
logger.log("[autofill-extension] prepareInterface(passkey) calling preparePasskeyRegistration")
self.client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
return
}
}
logger.log("[autofill-extension] We didn't get a passkey")
timeoutTimer.cancel()
// If we didn't get a passkey, return an error
self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid registration request"))
}
/*
Prepare your UI to list available credentials for the user to choose from. The items in
'serviceIdentifiers' describe the service the user is logging in to, so your extension can
prioritize the most relevant credentials in the list.
*/
override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) {
logger.log("[autofill-extension] prepareCredentialList for serviceIdentifiers: \(serviceIdentifiers.count)")
for serviceIdentifier in serviceIdentifiers {
logger.log(" service: \(serviceIdentifier.identifier)")
}
}
override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier], requestParameters: ASPasskeyCredentialRequestParameters) {
logger.log("[autofill-extension] prepareCredentialList(passkey) for serviceIdentifiers: \(serviceIdentifiers.count)")
logger.log("request parameters: \(requestParameters.relyingPartyIdentifier)")
for serviceIdentifier in serviceIdentifiers {
logger.log(" service: \(serviceIdentifier.identifier)")
class CallbackImpl: PreparePasskeyAssertionCallback {
let ctx: ASCredentialProviderExtensionContext
let timeoutTimer: DispatchWorkItem
let logger: Logger
required init(_ ctx: ASCredentialProviderExtensionContext,_ logger: Logger, _ timeoutTimer: DispatchWorkItem) {
self.ctx = ctx
self.logger = logger
self.timeoutTimer = timeoutTimer
}
func onComplete(credential: PasskeyAssertionResponse) {
self.timeoutTimer.cancel()
ctx.completeAssertionRequest(using: ASPasskeyAssertionCredential(
userHandle: credential.userHandle,
relyingParty: credential.rpId,
signature: credential.signature,
clientDataHash: credential.clientDataHash,
authenticatorData: credential.authenticatorData,
credentialID: credential.credentialId
))
}
func onError(error: BitwardenError) {
logger.error("[autofill-extension] OnError called, cancelling the request \(error)")
self.timeoutTimer.cancel()
ctx.cancelRequest(withError: error)
}
}
}
let userVerification = switch requestParameters.userVerificationPreference {
case .preferred:
UserVerification.preferred
case .required:
UserVerification.required
default:
UserVerification.discouraged
}
let req = PasskeyAssertionRequest(
rpId: requestParameters.relyingPartyIdentifier,
clientDataHash: requestParameters.clientDataHash,
userVerification: userVerification,
allowedCredentials: requestParameters.allowedCredentials,
windowXy: self.getWindowPosition()
//extensionInput: requestParameters.extensionInput,
)
let timeoutTimer = createTimer()
self.client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer))
return
}
}

View File

@@ -182,6 +182,10 @@ const routes: Routes = [
path: "passkeys",
component: Fido2PlaceholderComponent,
},
{
path: "passkeys",
component: Fido2PlaceholderComponent,
},
{
path: "",
component: AnonLayoutWrapperComponent,

View File

@@ -1,16 +1,45 @@
import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { BehaviorSubject, Observable } from "rxjs";
import {
DesktopFido2UserInterfaceService,
DesktopFido2UserInterfaceSession,
} from "../../autofill/services/desktop-fido2-user-interface.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
@Component({
standalone: true,
imports: [CommonModule],
template: `
<div
style="background:white; display:flex; justify-content: center; align-items: center; flex-direction: column"
>
<h1 style="color: black">Select your passkey</h1>
<div *ngFor="let item of cipherIds$ | async">
<button
style="color:black; padding: 10px 20px; border: 1px solid blue; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="chooseCipher(item)"
>
{{ item }}
</button>
</div>
<br />
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
type="button"
buttonType="secondary"
(click)="confirmPasskey()"
>
Confirm passkey
</button>
<button
style="color:black; padding: 10px 20px; border: 1px solid black; margin: 10px"
bitButton
@@ -23,14 +52,69 @@ import { DesktopSettingsService } from "../../platform/services/desktop-settings
</div>
`,
})
export class Fido2PlaceholderComponent {
export class Fido2PlaceholderComponent implements OnInit, OnDestroy {
session?: DesktopFido2UserInterfaceSession = null;
private cipherIdsSubject = new BehaviorSubject<string[]>([]);
cipherIds$: Observable<string[]>;
constructor(
private readonly desktopSettingsService: DesktopSettingsService,
private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService,
private readonly router: Router,
) {}
ngOnInit() {
this.session = this.fido2UserInterfaceService.getCurrentSession();
this.cipherIds$ = this.session?.availableCipherIds$;
}
async chooseCipher(cipherId: string) {
// For now: Set UV to true
this.session?.confirmChosenCipher(cipherId, true);
await this.router.navigate(["/"]);
await this.desktopSettingsService.setModalMode(false);
}
ngOnDestroy() {
this.cipherIdsSubject.complete(); // Clean up the BehaviorSubject
}
async confirmPasskey() {
try {
// Retrieve the current UI session to control the flow
if (!this.session) {
// todo: handle error
throw new Error("No session found");
}
// If we want to we could submit information to the session in order to create the credential
// const cipher = await session.createCredential({
// userHandle: "userHandle2",
// userName: "username2",
// credentialName: "zxsd2",
// rpId: "webauthn.io",
// userVerification: true,
// });
this.session.notifyConfirmNewCredential(true);
// Not sure this clean up should happen here or in session.
// The session currently toggles modal on and send us here
// But if this route is somehow opened outside of session we want to make sure we clean up?
await this.router.navigate(["/"]);
await this.desktopSettingsService.setModalMode(false);
} catch {
// TODO: Handle error appropriately
}
}
async closeModal() {
await this.router.navigate(["/"]);
await this.desktopSettingsService.setInModalMode(false);
await this.desktopSettingsService.setModalMode(false);
this.session.notifyConfirmNewCredential(false);
// little bit hacky:
this.session.confirmChosenCipher(null);
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { APP_INITIALIZER, NgModule } from "@angular/core";
import { Router } from "@angular/router";
import { Subject, merge } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
@@ -334,9 +335,21 @@ const safeProviders: SafeProvider[] = [
],
}),
safeProvider({
provide: Fido2UserInterfaceServiceAbstraction,
provide: DesktopFido2UserInterfaceService,
useClass: DesktopFido2UserInterfaceService,
deps: [AuthServiceAbstraction, CipherServiceAbstraction, AccountService, LogService],
deps: [
AuthServiceAbstraction,
CipherServiceAbstraction,
AccountService,
LogService,
MessagingServiceAbstraction,
Router,
DesktopSettingsService,
],
}),
safeProvider({
provide: Fido2UserInterfaceServiceAbstraction, // We utilize desktop specific methods when wiring OS API's
useExisting: DesktopFido2UserInterfaceService,
}),
safeProvider({
provide: Fido2AuthenticatorServiceAbstraction,

View File

@@ -80,6 +80,44 @@ export default {
return;
}
ipcRenderer.send("autofill.completePasskeyAssertion", {
clientId,
sequenceNumber,
response,
});
});
},
);
},
listenPasskeyAssertionWithoutUserInterface: (
fn: (
clientId: number,
sequenceNumber: number,
request: autofill.PasskeyAssertionWithoutUserInterfaceRequest,
completeCallback: (error: Error | null, response: autofill.PasskeyAssertionResponse) => void,
) => void,
) => {
ipcRenderer.on(
"autofill.passkeyAssertionWithoutUserInterface",
(
event,
data: {
clientId: number;
sequenceNumber: number;
request: autofill.PasskeyAssertionWithoutUserInterfaceRequest;
},
) => {
const { clientId, sequenceNumber, request } = data;
fn(clientId, sequenceNumber, request, (error, response) => {
if (error) {
ipcRenderer.send("autofill.completeError", {
clientId,
sequenceNumber,
error: error.message,
});
return;
}
ipcRenderer.send("autofill.completePasskeyAssertion", {
clientId,
sequenceNumber,

View File

@@ -1,7 +1,6 @@
import { Injectable, OnDestroy } from "@angular/core";
import { autofill } from "desktop_native/napi";
import {
EMPTY,
Subject,
distinctUntilChanged,
filter,
@@ -10,6 +9,7 @@ import {
mergeMap,
switchMap,
takeUntil,
EMPTY,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -26,9 +26,9 @@ import {
} from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { parseCredentialId } from "@bitwarden/common/platform/services/fido2/credential-id-utils";
import { getCredentialsForAutofill } from "@bitwarden/common/platform/services/fido2/fido2-autofill-utils";
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
import { guidToRawFormat } from "@bitwarden/common/platform/services/fido2/guid-utils";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -41,6 +41,8 @@ import {
NativeAutofillSyncCommand,
} from "../../platform/main/autofill/sync.command";
import type { NativeWindowObject } from "./desktop-fido2-user-interface.service";
@Injectable()
export class DesktopAutofillService implements OnDestroy {
private destroy$ = new Subject<void>();
@@ -49,7 +51,7 @@ export class DesktopAutofillService implements OnDestroy {
private logService: LogService,
private cipherService: CipherService,
private configService: ConfigService,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<void>,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
private accountService: AccountService,
) {}
@@ -155,7 +157,11 @@ export class DesktopAutofillService implements OnDestroy {
const controller = new AbortController();
void this.fido2AuthenticatorService
.makeCredential(this.convertRegistrationRequest(request), null, controller)
.makeCredential(
this.convertRegistrationRequest(request),
{ windowXy: request.windowXy },
controller,
)
.then((response) => {
callback(null, this.convertRegistrationResponse(request, response));
})
@@ -165,47 +171,77 @@ export class DesktopAutofillService implements OnDestroy {
});
});
ipc.autofill.listenPasskeyAssertionWithoutUserInterface(
async (clientId, sequenceNumber, request, callback) => {
this.logService.warning(
"listenPasskeyAssertion without user interface",
clientId,
sequenceNumber,
request,
);
// For some reason the credentialId is passed as an empty array in the request, so we need to
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
if (request.recordIdentifier && request.credentialId.length === 0) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (!activeUserId) {
this.logService.error("listenPasskeyAssertion error", "Active user not found");
callback(new Error("Active user not found"), null);
return;
}
const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId);
if (!cipher) {
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
callback(new Error("Cipher not found"), null);
return;
}
const decrypted = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
const fido2Credential = decrypted.login.fido2Credentials?.[0];
if (!fido2Credential) {
this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found");
callback(new Error("Fido2Credential not found"), null);
return;
}
request.credentialId = Array.from(
parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId),
);
}
const controller = new AbortController();
void this.fido2AuthenticatorService
.getAssertion(
this.convertAssertionRequest(request),
{ windowXy: request.windowXy },
controller,
)
.then((response) => {
callback(null, this.convertAssertionResponse(request, response));
})
.catch((error) => {
this.logService.error("listenPasskeyAssertion error", error);
callback(error, null);
});
},
);
ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => {
this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request);
// TODO: For some reason the credentialId is passed as an empty array in the request, so we need to
// get it from the cipher. For that we use the recordIdentifier, which is the cipherId.
if (request.recordIdentifier && request.credentialId.length === 0) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (!activeUserId) {
this.logService.error("listenPasskeyAssertion error", "Active user not found");
callback(new Error("Active user not found"), null);
return;
}
const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId);
if (!cipher) {
this.logService.error("listenPasskeyAssertion error", "Cipher not found");
callback(new Error("Cipher not found"), null);
return;
}
const decrypted = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
);
const fido2Credential = decrypted.login.fido2Credentials?.[0];
if (!fido2Credential) {
this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found");
callback(new Error("Fido2Credential not found"), null);
return;
}
request.credentialId = Array.from(
guidToRawFormat(decrypted.login.fido2Credentials?.[0].credentialId),
);
}
const controller = new AbortController();
void this.fido2AuthenticatorService
.getAssertion(this.convertAssertionRequest(request), null, controller)
.getAssertion(
this.convertAssertionRequest(request),
{ windowXy: request.windowXy },
controller,
)
.then((response) => {
callback(null, this.convertAssertionResponse(request, response));
})
@@ -257,27 +293,48 @@ export class DesktopAutofillService implements OnDestroy {
};
}
/**
*
* @param request
* @param assumeUserPresence For WithoutUserInterface requests, we assume the user is present
* @returns
*/
private convertAssertionRequest(
request: autofill.PasskeyAssertionRequest,
request:
| autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
): Fido2AuthenticatorGetAssertionParams {
let allowedCredentials;
if ("credentialId" in request) {
allowedCredentials = [
{
id: new Uint8Array(request.credentialId),
type: "public-key" as const,
},
];
} else {
allowedCredentials = request.allowedCredentials.map((credentialId) => ({
id: new Uint8Array(credentialId),
type: "public-key" as const,
}));
}
return {
rpId: request.rpId,
hash: new Uint8Array(request.clientDataHash),
allowCredentialDescriptorList: [
{
id: new Uint8Array(request.credentialId),
type: "public-key",
},
],
allowCredentialDescriptorList: allowedCredentials,
extensions: {},
requireUserVerification:
request.userVerification === "required" || request.userVerification === "preferred",
fallbackSupported: false,
assumeUserPresence: true, // For desktop assertions, it's safe to assume UP has been checked by OS dialogues
};
}
private convertAssertionResponse(
request: autofill.PasskeyAssertionRequest,
request:
| autofill.PasskeyAssertionRequest
| autofill.PasskeyAssertionWithoutUserInterfaceRequest,
response: Fido2AuthenticatorGetAssertionResult,
): autofill.PasskeyAssertionResponse {
return {

View File

@@ -1,4 +1,14 @@
import { firstValueFrom, map } from "rxjs";
import { Router } from "@angular/router";
import {
lastValueFrom,
firstValueFrom,
map,
Subject,
filter,
take,
BehaviorSubject,
timeout,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -10,8 +20,10 @@ import {
PickCredentialParams,
} from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType, CipherType, SecureNoteType } from "@bitwarden/common/vault/enums";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
@@ -19,28 +31,54 @@ import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
/**
* This type is used to pass the window position from the native UI
*/
export type NativeWindowObject = {
/**
* The position of the window, first entry is the x position, second is the y position
*/
windowXy?: { x: number; y: number };
};
export class DesktopFido2UserInterfaceService
implements Fido2UserInterfaceServiceAbstraction<void>
implements Fido2UserInterfaceServiceAbstraction<NativeWindowObject>
{
constructor(
private authService: AuthService,
private cipherService: CipherService,
private accountService: AccountService,
private logService: LogService,
private messagingService: MessagingService,
private router: Router,
private desktopSettingsService: DesktopSettingsService,
) {}
private currentSession: any;
getCurrentSession(): DesktopFido2UserInterfaceSession | undefined {
return this.currentSession;
}
async newSession(
fallbackSupported: boolean,
_tab: void,
nativeWindowObject: NativeWindowObject,
abortController?: AbortController,
): Promise<DesktopFido2UserInterfaceSession> {
this.logService.warning("newSession", fallbackSupported, abortController);
return new DesktopFido2UserInterfaceSession(
this.logService.warning("newSession", fallbackSupported, abortController, nativeWindowObject);
const session = new DesktopFido2UserInterfaceSession(
this.authService,
this.cipherService,
this.accountService,
this.logService,
this.router,
this.desktopSettingsService,
nativeWindowObject,
);
this.currentSession = session;
return session;
}
}
@@ -50,17 +88,110 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
private cipherService: CipherService,
private accountService: AccountService,
private logService: LogService,
private router: Router,
private desktopSettingsService: DesktopSettingsService,
private windowObject: NativeWindowObject,
) {}
private confirmCredentialSubject = new Subject<boolean>();
private createdCipher: Cipher;
private availableCipherIdsSubject = new BehaviorSubject<string[]>(null);
/**
* Observable that emits available cipher IDs once they're confirmed by the UI
*/
availableCipherIds$ = this.availableCipherIdsSubject.pipe(
filter((ids) => ids != null),
take(1),
);
private chosenCipherSubject = new Subject<{ cipherId: string; userVerified: boolean }>();
// Method implementation
async pickCredential({
cipherIds,
userVerification,
assumeUserPresence,
masterPasswordRepromptRequired,
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
this.logService.warning("pickCredential", cipherIds, userVerification);
this.logService.warning("pickCredential desktop function", {
cipherIds,
userVerification,
assumeUserPresence,
masterPasswordRepromptRequired,
});
return { cipherId: cipherIds[0], userVerified: userVerification };
try {
// Check if we can return the credential without user interaction
if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) {
this.logService.debug(
"shortcut - Assuming user presence and returning cipherId",
cipherIds[0],
);
return { cipherId: cipherIds[0], userVerified: userVerification };
}
this.logService.debug("Could not shortcut, showing UI");
// make the cipherIds available to the UI.
this.availableCipherIdsSubject.next(cipherIds);
await this.showUi("/passkeys", this.windowObject.windowXy);
const chosenCipherResponse = await this.waitForUiChosenCipher();
this.logService.debug("Received chosen cipher", chosenCipherResponse);
return {
cipherId: chosenCipherResponse.cipherId,
userVerified: chosenCipherResponse.userVerified,
};
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setModalMode(false);
}
}
confirmChosenCipher(cipherId: string, userVerified: boolean = false): void {
this.chosenCipherSubject.next({ cipherId, userVerified });
this.chosenCipherSubject.complete();
}
private async waitForUiChosenCipher(
timeoutMs: number = 60000,
): Promise<{ cipherId: string; userVerified: boolean } | undefined> {
try {
return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs)));
} catch {
// If we hit a timeout, return undefined instead of throwing
this.logService.warning("Timeout: User did not select a cipher within the allowed time", {
timeoutMs,
});
return { cipherId: undefined, userVerified: false };
}
}
/**
* Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS.
*/
notifyConfirmNewCredential(confirmed: boolean): void {
this.confirmCredentialSubject.next(confirmed);
this.confirmCredentialSubject.complete();
}
/**
* Returns once the UI has confirmed and completed the operation
* @returns
*/
private async waitForUiNewCredentialConfirmation(): Promise<boolean> {
return lastValueFrom(this.confirmCredentialSubject);
}
/**
* This is called by the OS. It loads the UI and waits for the user to confirm the new credential. Once the UI has confirmed, it returns to the the OS.
* @param param0
* @returns
*/
async confirmNewCredential({
credentialName,
userName,
@@ -75,6 +206,48 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
rpId,
);
try {
await this.showUi("/passkeys", this.windowObject.windowXy);
// Wait for the UI to wrap up
const confirmation = await this.waitForUiNewCredentialConfirmation();
if (!confirmation) {
return { cipherId: undefined, userVerified: false };
}
// Create the credential
await this.createCredential({
credentialName,
userName,
rpId,
userHandle: "",
userVerification,
});
// wait for 10ms to help RXJS catch up(?)
// We sometimes get a race condition from this.createCredential not updating cipherService in time
//console.log("waiting 10ms..");
//await new Promise((resolve) => setTimeout(resolve, 10));
//console.log("Just waited 10ms");
// Return the new cipher (this.createdCipher)
return { cipherId: this.createdCipher.id, userVerified: userVerification };
} finally {
// Make sure to clean up so the app is never stuck in modal mode?
await this.desktopSettingsService.setModalMode(false);
}
}
private async showUi(route: string, position?: { x: number; y: number }): Promise<void> {
// Load the UI:
await this.desktopSettingsService.setModalMode(true, position);
await this.router.navigate(["/passkeys"]);
}
/**
* Can be called by the UI to create a new credential with user input etc.
* @param param0
*/
async createCredential({ credentialName, userName, rpId }: NewCredentialParams): Promise<Cipher> {
// Store the passkey on a new cipher to avoid replacing something important
const cipher = new CipherView();
cipher.name = credentialName;
@@ -97,7 +270,9 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi
const encCipher = await this.cipherService.encrypt(cipher, activeUserId);
const createdCipher = await this.cipherService.createWithServer(encCipher);
return { cipherId: createdCipher.id, userVerified: userVerification };
this.createdCipher = createdCipher;
return createdCipher;
}
async informExcludedCredential(existingCipherIds: string[]): Promise<void> {

View File

@@ -649,13 +649,13 @@
"message": "Prijavi se u Bitwarden"
},
"enterTheCodeSentToYourEmail": {
"message": "Enter the code sent to your email"
"message": "Unesi kôd poslan e-poštom"
},
"enterTheCodeFromYourAuthenticatorApp": {
"message": "Enter the code from your authenticator app"
"message": "Unesi kôd iz svoje aplikacije za autentifikaciju"
},
"pressYourYubiKeyToAuthenticate": {
"message": "Press your YubiKey to authenticate"
"message": "Za autentifikaciju dodirni svoj YubiKey"
},
"logInWithPasskey": {
"message": "Prijava pristupnim ključem"
@@ -710,7 +710,7 @@
"message": "Podsjetnik glavne lozinke"
},
"passwordStrengthScore": {
"message": "Password strength score $SCORE$",
"message": "Ocjena jačine lozinke: $SCORE$",
"placeholders": {
"score": {
"content": "$1",
@@ -825,7 +825,7 @@
"message": "Autentifikacija je otkazana ili je trajala predugo. Molimo pokušaj ponovno."
},
"openInNewTab": {
"message": "Open in new tab"
"message": "Otvori u novoj kartici"
},
"invalidVerificationCode": {
"message": "Nevažeći kôd za provjeru"
@@ -858,7 +858,7 @@
"message": "Zapamti me"
},
"dontAskAgainOnThisDeviceFor30Days": {
"message": "Don't ask again on this device for 30 days"
"message": "Ne pitaj na ovom uređaju idućih 30 dana"
},
"sendVerificationCodeEmailAgain": {
"message": "Ponovno slanje kontrolnog koda e-poštom"
@@ -867,11 +867,11 @@
"message": "Koristiti drugi način prijave dvostrukom autentifikacijom"
},
"selectAnotherMethod": {
"message": "Select another method",
"message": "Odaberi drugi način",
"description": "Select another two-step login method"
},
"useYourRecoveryCode": {
"message": "Use your recovery code"
"message": "Koristi kôd za oporavak"
},
"insertYubiKey": {
"message": "Umetni svoj YubiKey u USB priključak računala, a zatim dodirni njegovu tipku."
@@ -907,7 +907,7 @@
"description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated."
},
"verifyYourIdentity": {
"message": "Verify your Identity"
"message": "Potvrdi svoj identitet"
},
"weDontRecognizeThisDevice": {
"message": "Ne prepoznajemo ovaj uređaj. Za potvrdu identiteta unesi kôd poslan e-poštom."
@@ -940,7 +940,7 @@
"message": "Mogućnosti prijave dvostrukom autentifikacijom"
},
"selectTwoStepLoginMethod": {
"message": "Select two-step login method"
"message": "Odaberi način prijave dvostrukom autentifikacijom"
},
"selfHostedEnvironment": {
"message": "Vlastito hosting okruženje"
@@ -998,7 +998,7 @@
"message": "Ne"
},
"location": {
"message": "Location"
"message": "Lokacija"
},
"overwritePassword": {
"message": "Prebriši lozinku"
@@ -1797,7 +1797,7 @@
"message": "Zahtijevaj lozinku ili PIN pri pokretanju"
},
"requirePasswordWithoutPinOnStart": {
"message": "Require password on app start"
"message": "Zahtijevaj lozinku pri pokretanju"
},
"recommendedForSecurity": {
"message": "Preporučeno za sigurnost."
@@ -2269,10 +2269,10 @@
"message": "Ovjeri WebAuthn"
},
"readSecurityKey": {
"message": "Read security key"
"message": "Pročitaj sigurnosni ključ"
},
"awaitingSecurityKeyInteraction": {
"message": "Awaiting security key interaction..."
"message": "Čekanje na interakciju sa sigurnosnim ključem..."
},
"hideEmail": {
"message": "Sakrij moju adresu e-pošte od primatelja."
@@ -2671,7 +2671,7 @@
}
},
"forwaderInvalidOperation": {
"message": "$SERVICENAME$ refused your request. Please contact your service provider for assistance.",
"message": "$SERVICENAME$ je odbio tvoj zahtjev. Obrati se svom pružatelju usluga za pomoć.",
"description": "Displayed when the user is forbidden from using the API by the forwarding service.",
"placeholders": {
"servicename": {
@@ -2681,7 +2681,7 @@
}
},
"forwaderInvalidOperationWithMessage": {
"message": "$SERVICENAME$ refused your request: $ERRORMESSAGE$",
"message": "$SERVICENAME$ je odbio tvoj zahtjev: $ERRORMESSAGE$",
"description": "Displayed when the user is forbidden from using the API by the forwarding service with an error message.",
"placeholders": {
"servicename": {
@@ -3222,10 +3222,10 @@
"message": "Za tvoj račun je potrebna Duo dvostruka autentifikacija."
},
"duoTwoFactorRequiredPageSubtitle": {
"message": "Duo two-step login is required for your account. Follow the steps below to finish logging in."
"message": "Za tvoj je račun potrebna Duo prijava u dva koraka. Za dovršetak prijave, slijedi daljnje korake."
},
"followTheStepsBelowToFinishLoggingIn": {
"message": "Follow the steps below to finish logging in."
"message": "Prati korake za dovršetak prijave."
},
"launchDuo": {
"message": "Pokreni Duo u pregledniku"
@@ -3485,25 +3485,25 @@
"message": "Potvrdi korištenje SSH ključa"
},
"agentForwardingWarningTitle": {
"message": "Warning: Agent Forwarding"
"message": "Upozorenje: Agent proslijeđivanje"
},
"agentForwardingWarningText": {
"message": "This request comes from a remote device that you are logged into"
"message": "Ovaj je zahtjev došao s prijavljenog udaljenog uređaja"
},
"sshkeyApprovalMessageInfix": {
"message": "traži pristup za"
},
"sshkeyApprovalMessageSuffix": {
"message": "in order to"
"message": "za"
},
"sshActionLogin": {
"message": "authenticate to a server"
"message": "autentifikaciju poslužitelju"
},
"sshActionSign": {
"message": "sign a message"
"message": "potpis poruke"
},
"sshActionGitSign": {
"message": "sign a git commit"
"message": "potpis git commita"
},
"unknownApplication": {
"message": "Aplikacija"
@@ -3518,7 +3518,7 @@
"message": "Uvezi ključ iz međuspremnika"
},
"sshKeyImported": {
"message": "SSH key imported successfully"
"message": "SSH ključ uspješno uvezen"
},
"fileSavedToDevice": {
"message": "Datoteka spremljena na uređaj. Upravljaj u preuzimanjima svog uređaja."
@@ -3578,6 +3578,6 @@
"message": "Proširenje preglednika koje koristitš zastarjelo. Ažuriraj ga ili onemogući provjeru otiska prsta u pregledniku u postavkama aplikacije za stolno računalo."
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
"message": "Promijeni rizičnu lozinku"
}
}

View File

@@ -907,7 +907,7 @@
"description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated."
},
"verifyYourIdentity": {
"message": "Verify your Identity"
"message": "Potwierdź swoją tożsamość"
},
"weDontRecognizeThisDevice": {
"message": "Nie rozpoznajemy tego urządzenia. Wpisz kod wysłany na Twój e-mail, aby zweryfikować tożsamość."
@@ -998,7 +998,7 @@
"message": "Nie"
},
"location": {
"message": "Location"
"message": "Lokalizacja"
},
"overwritePassword": {
"message": "Zastąp hasło"
@@ -3488,22 +3488,22 @@
"message": "Warning: Agent Forwarding"
},
"agentForwardingWarningText": {
"message": "This request comes from a remote device that you are logged into"
"message": "To żądanie pochodzi ze zdalnego urządzenia, do którego jesteś zalogowany"
},
"sshkeyApprovalMessageInfix": {
"message": "wnioskuje o dostęp do"
},
"sshkeyApprovalMessageSuffix": {
"message": "in order to"
"message": "w celu"
},
"sshActionLogin": {
"message": "authenticate to a server"
"message": "autoryzacji na serwerze"
},
"sshActionSign": {
"message": "sign a message"
"message": "podpisania wiadomości"
},
"sshActionGitSign": {
"message": "sign a git commit"
"message": "podpisania commita w giciem"
},
"unknownApplication": {
"message": "Aplikacja"
@@ -3518,7 +3518,7 @@
"message": "Importuj klucz ze schowka"
},
"sshKeyImported": {
"message": "SSH key imported successfully"
"message": "Klucz SSH zaimportowano pomyślnie"
},
"fileSavedToDevice": {
"message": "Plik zapisany na urządzeniu. Zarządzaj plikiem na swoim urządzeniu."
@@ -3578,6 +3578,6 @@
"message": "Rozszerzenie przeglądarki, którego używasz, jest nieaktualne. Zaktualizuj je lub wyłącz weryfikację odcisku palca integracji przeglądarki w ustawieniach aplikacji desktopowej."
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
"message": "Zmień zagrożone hasło"
}
}

View File

@@ -2470,7 +2470,7 @@
"message": "切换账户"
},
"alreadyHaveAccount": {
"message": "已经有账户了吗?"
"message": "已经有账户了吗?"
},
"options": {
"message": "选项"

View File

@@ -209,6 +209,14 @@ export class Main {
new ElectronMainMessagingService(this.windowMain),
);
this.trayMain = new TrayMain(
this.windowMain,
this.i18nService,
this.desktopSettingsService,
this.messagingService,
this.biometricsService,
);
messageSubject.asObservable().subscribe((message) => {
void this.messagingMain.onMessage(message).catch((err) => {
this.logService.error(
@@ -236,7 +244,7 @@ export class Main {
this.windowMain,
this.i18nService,
this.desktopSettingsService,
biometricStateService,
this.messagingService,
this.biometricsService,
);
@@ -285,7 +293,7 @@ export class Main {
async () => {
await this.toggleHardwareAcceleration();
// Reset modal mode to make sure main window is displayed correctly
await this.desktopSettingsService.resetInModalMode();
await this.desktopSettingsService.resetModalMode();
await this.windowMain.init();
await this.i18nService.init();
await this.messagingMain.init();

View File

@@ -37,6 +37,10 @@ export class MessagingMain {
async onMessage(message: any) {
switch (message.command) {
case "loadurl":
// TODO: Remove this once fakepopup is removed from tray (just used for dev)
await this.main.windowMain.loadUrl(message.url, message.modal);
break;
case "scheduleNextSync":
this.scheduleNextSync();
break;

View File

@@ -1,16 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
import { firstValueFrom } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BiometricStateService, BiometricsService } from "@bitwarden/key-management";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { BiometricsService } from "@bitwarden/key-management";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { cleanUserAgent, isDev } from "../utils";
import { isDev } from "../utils";
import { WindowMain } from "./window.main";
@@ -26,7 +26,7 @@ export class TrayMain {
private windowMain: WindowMain,
private i18nService: I18nService,
private desktopSettingsService: DesktopSettingsService,
private biometricsStateService: BiometricStateService,
private messagingService: MessagingService,
private biometricService: BiometricsService,
) {
if (process.platform === "win32") {
@@ -216,32 +216,6 @@ export class TrayMain {
* @returns
*/
private async fakePopup() {
if (this.windowMain.win == null || this.windowMain.win.isDestroyed()) {
await this.windowMain.createWindow("modal-app");
return;
}
// Restyle existing
const existingWin = this.windowMain.win;
await this.desktopSettingsService.setInModalMode(true);
await existingWin.loadURL(
url.format({
protocol: "file:",
//pathname: `${__dirname}/index.html`,
pathname: path.join(__dirname, "/index.html"),
slashes: true,
hash: "/passkeys",
query: {
redirectUrl: "/passkeys",
},
}),
{
userAgent: cleanUserAgent(existingWin.webContents.userAgent),
},
);
existingWin.once("ready-to-show", () => {
existingWin.show();
});
await this.messagingService.send("loadurl", { url: "/passkeys", modal: true });
}
}

View File

@@ -78,18 +78,19 @@ export class WindowMain {
}
});
this.desktopSettingsService.inModalMode$
this.desktopSettingsService.modalMode$
.pipe(
pairwise(),
concatMap(async ([lastValue, newValue]) => {
if (lastValue && !newValue) {
if (lastValue.isModalModeActive && !newValue.isModalModeActive) {
// Reset the window state to the main window state
applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]);
// Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode.
this.win.hide();
} else if (!lastValue && newValue) {
} else if (!lastValue.isModalModeActive && newValue.isModalModeActive) {
// Apply the popup modal styles
applyPopupModalStyles(this.win);
this.logService.info("Applying popup modal styles", newValue.modalPosition);
applyPopupModalStyles(this.win, newValue.modalPosition);
this.win.show();
}
}),
@@ -209,6 +210,35 @@ export class WindowMain {
}
}
// TODO: REMOVE ONCE WE CAN STOP USING FAKE POP UP BTN FROM TRAY
// Only used for development
async loadUrl(targetPath: string, modal: boolean = false) {
if (this.win == null || this.win.isDestroyed()) {
await this.createWindow("modal-app");
return;
}
await this.desktopSettingsService.setModalMode(modal);
await this.win.loadURL(
url.format({
protocol: "file:",
//pathname: `${__dirname}/index.html`,
pathname: path.join(__dirname, "/index.html"),
slashes: true,
hash: targetPath,
query: {
redirectUrl: targetPath,
},
}),
{
userAgent: cleanUserAgent(this.win.webContents.userAgent),
},
);
this.win.once("ready-to-show", () => {
this.win.show();
});
}
/**
* Creates the main window. The template argument is used to determine the styling of the window and what url will be loaded.
* When the template is "modal-app", the window will be styled as a modal and the passkeys page will be loaded.
@@ -394,9 +424,9 @@ export class WindowMain {
return;
}
const inModalMode = await firstValueFrom(this.desktopSettingsService.inModalMode$);
const modalMode = await firstValueFrom(this.desktopSettingsService.modalMode$);
if (inModalMode) {
if (modalMode.isModalModeActive) {
return;
}

View File

@@ -40,6 +40,7 @@ export class NativeAutofillMain {
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.registration", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyRegistration", {
@@ -52,6 +53,7 @@ export class NativeAutofillMain {
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.assertion", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyAssertion", {
@@ -60,6 +62,19 @@ export class NativeAutofillMain {
request,
});
},
// AssertionWithoutUserInterfaceCallback
(error, clientId, sequenceNumber, request) => {
if (error) {
this.logService.error("autofill.IpcServer.assertion", error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
return;
}
this.windowMain.win.webContents.send("autofill.passkeyAssertionWithoutUserInterface", {
clientId,
sequenceNumber,
request,
});
},
);
ipcMain.on("autofill.completePasskeyRegistration", (event, data) => {
@@ -77,7 +92,7 @@ export class NativeAutofillMain {
ipcMain.on("autofill.completeError", (event, data) => {
this.logService.warning("autofill.completeError", data);
const { clientId, sequenceNumber, error } = data;
this.ipcServer.completeAssertion(clientId, sequenceNumber, error);
this.ipcServer.completeError(clientId, sequenceNumber, String(error));
});
}

View File

@@ -11,3 +11,8 @@ export class WindowState {
y?: number;
zoomFactor?: number;
}
export class ModalModeState {
isModalModeActive: boolean;
modalPosition?: { x: number; y: number }; // Modal position is often passed from the native UI
}

View File

@@ -6,10 +6,11 @@ import { WindowState } from "./models/domain/window-state";
const popupWidth = 680;
const popupHeight = 500;
export function applyPopupModalStyles(window: BrowserWindow) {
type Position = { x: number; y: number };
export function applyPopupModalStyles(window: BrowserWindow, position?: Position) {
window.unmaximize();
window.setSize(popupWidth, popupHeight);
window.center();
window.setWindowButtonVisibility?.(false);
window.setMenuBarVisibility?.(false);
window.setResizable(false);
@@ -20,8 +21,21 @@ export function applyPopupModalStyles(window: BrowserWindow) {
window.setFullScreen(false);
window.once("leave-full-screen", () => {
window.setSize(popupWidth, popupHeight);
window.center();
positionWindow(window, position);
});
} else {
// If not in full screen
positionWindow(window, position);
}
}
function positionWindow(window: BrowserWindow, position?: Position) {
if (position) {
const centeredX = position.x - popupWidth / 2;
const centeredY = position.y - popupHeight / 2;
window.setPosition(centeredX, centeredY);
} else {
window.center();
}
}

View File

@@ -8,7 +8,7 @@ import {
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { WindowState } from "../models/domain/window-state";
import { ModalModeState, WindowState } from "../models/domain/window-state";
export const HARDWARE_ACCELERATION = new KeyDefinition<boolean>(
DESKTOP_SETTINGS_DISK,
@@ -75,7 +75,7 @@ const MINIMIZE_ON_COPY = new UserKeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "
clearOn: [], // User setting, no need to clear
});
const IN_MODAL_MODE = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "inModalMode", {
const MODAL_MODE = new KeyDefinition<ModalModeState>(DESKTOP_SETTINGS_DISK, "modalMode", {
deserializer: (b) => b,
});
@@ -174,9 +174,9 @@ export class DesktopSettingsService {
*/
minimizeOnCopy$ = this.minimizeOnCopyState.state$.pipe(map(Boolean));
private readonly inModalModeState = this.stateProvider.getGlobal(IN_MODAL_MODE);
private readonly modalModeState = this.stateProvider.getGlobal(MODAL_MODE);
inModalMode$ = this.inModalModeState.state$.pipe(map(Boolean));
modalMode$ = this.modalModeState.state$;
constructor(private stateProvider: StateProvider) {
this.window$ = this.windowState.state$.pipe(
@@ -190,8 +190,8 @@ export class DesktopSettingsService {
* This is used to clear the setting on application start to make sure we don't end up
* stuck in modal mode if the application is force-closed in modal mode.
*/
async resetInModalMode() {
await this.inModalModeState.update(() => false);
async resetModalMode() {
await this.modalModeState.update(() => ({ isModalModeActive: false }));
}
async setHardwareAcceleration(enabled: boolean) {
@@ -306,8 +306,11 @@ export class DesktopSettingsService {
* Sets the modal mode of the application. Setting this changes the windows-size and other properties.
* @param value `true` if the application is in modal mode, `false` if it is not.
*/
async setInModalMode(value: boolean) {
await this.inModalModeState.update(() => value);
async setModalMode(value: boolean, modalPosition?: { x: number; y: number }) {
await this.modalModeState.update(() => ({
isModalModeActive: value,
modalPosition,
}));
}
/**

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2025.3.0",
"version": "2025.3.1",
"scripts": {
"build:oss": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
"build:bit": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@@ -13,6 +13,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -65,6 +66,7 @@ describe("EmergencyViewDialogComponent", () => {
useValue: ChangeLoginPasswordService,
},
{ provide: ConfigService, useValue: ConfigService },
{ provide: CipherService, useValue: mock<CipherService>() },
],
},
add: {
@@ -79,6 +81,7 @@ describe("EmergencyViewDialogComponent", () => {
useValue: mock<ChangeLoginPasswordService>(),
},
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: CipherService, useValue: mock<CipherService>() },
],
},
})

View File

@@ -180,7 +180,7 @@ export class DeviceManagementComponent {
private updateDeviceTable(devices: Array<DeviceView>): void {
this.dataSource.data = devices
.map((device: DeviceView): DeviceTableData | null => {
if (device.id == undefined) {
if (!device.id) {
this.validationService.showError(new Error(this.i18nService.t("deviceIdMissing")));
return null;
}
@@ -190,7 +190,7 @@ export class DeviceManagementComponent {
return null;
}
if (device.creationDate == undefined) {
if (!device.creationDate) {
this.validationService.showError(
new Error(this.i18nService.t("deviceCreationDateMissing")),
);

View File

@@ -429,7 +429,7 @@
"message": "Zum Sortieren ziehen"
},
"dragToReorder": {
"message": "Ziehen zum umsortieren"
"message": "Ziehen zum Umsortieren"
},
"cfTypeText": {
"message": "Text"

View File

@@ -6,7 +6,7 @@
"message": "Kritične aplikacije"
},
"noCriticalAppsAtRisk": {
"message": "No critical applications at risk"
"message": "Nema kritičnih aplikacija u opasnosti"
},
"accessIntelligence": {
"message": "Pristup inteligenciji"
@@ -202,7 +202,7 @@
"message": "Bilješke"
},
"privateNote": {
"message": "Private note"
"message": "Privatna bilješka"
},
"note": {
"message": "Bilješka"
@@ -429,7 +429,7 @@
"message": "Povuci za sortiranje"
},
"dragToReorder": {
"message": "Drag to reorder"
"message": "Povuci za premještanje"
},
"cfTypeText": {
"message": "Tekst"
@@ -474,7 +474,7 @@
"message": "Uredi mapu"
},
"editWithName": {
"message": "Edit $ITEM$: $NAME$",
"message": "Uredi $ITEM$: $NAME$",
"placeholders": {
"item": {
"content": "$1",
@@ -1037,7 +1037,7 @@
"message": "Ne"
},
"location": {
"message": "Location"
"message": "Lokacija"
},
"loginOrCreateNewAccount": {
"message": "Prijavi se ili stvori novi račun za pristup svojem sigurnom trezoru."
@@ -1175,13 +1175,13 @@
"message": "Prijavi se u Bitwarden"
},
"enterTheCodeSentToYourEmail": {
"message": "Enter the code sent to your email"
"message": "Unesi kôd poslan e-poštom"
},
"enterTheCodeFromYourAuthenticatorApp": {
"message": "Enter the code from your authenticator app"
"message": "Unesi kôd iz svoje aplikacije za autentifikaciju"
},
"pressYourYubiKeyToAuthenticate": {
"message": "Press your YubiKey to authenticate"
"message": "Za autentifikaciju dodirni svoj YubiKey"
},
"authenticationTimeout": {
"message": "Istek vremena za autentifikaciju"
@@ -1190,7 +1190,7 @@
"message": "Sesija za autentifikaciju je istekla. Ponovi proces prijave."
},
"verifyYourIdentity": {
"message": "Verify your Identity"
"message": "Potvrdi svoj identitet"
},
"weDontRecognizeThisDevice": {
"message": "Ne prepoznajemo ovaj uređaj. Za potvrdu identiteta unesi kôd poslan e-poštom."
@@ -1460,7 +1460,7 @@
"message": "Zapamti me"
},
"dontAskAgainOnThisDeviceFor30Days": {
"message": "Don't ask again on this device for 30 days"
"message": "Ne pitaj na ovom uređaju idućih 30 dana"
},
"sendVerificationCodeEmailAgain": {
"message": "Ponovno slanje kontrolnog koda e-poštom"
@@ -1469,11 +1469,11 @@
"message": "Koristiti drugi način prijave dvostrukom autentifikacijom"
},
"selectAnotherMethod": {
"message": "Select another method",
"message": "Odaberi drugi način",
"description": "Select another two-step login method"
},
"useYourRecoveryCode": {
"message": "Use your recovery code"
"message": "Koristi kôd za oporavak"
},
"insertYubiKey": {
"message": "Umetni svoj YubiKey u USB priključak računala, a zatim dodirni njegovu tipku."
@@ -1494,7 +1494,7 @@
"message": "Mogućnosti prijave dvostrukom autentifikacijom"
},
"selectTwoStepLoginMethod": {
"message": "Select two-step login method"
"message": "Odaberi način prijave dvostrukom autentifikacijom"
},
"recoveryCodeDesc": {
"message": "Izgubljen je pristup uređaju za prijavu dvostrukom autentifikacijom? Koristi svoj kôd za oporavak za onemogućavanje svih pružatelja usluga prijave dvostrukom autentifikacijom na svom računu."
@@ -1539,7 +1539,7 @@
"message": "(migrirano s FIDO)"
},
"openInNewTab": {
"message": "Open in new tab"
"message": "Otvori u novoj kartici"
},
"emailTitle": {
"message": "E-pošta"
@@ -2237,7 +2237,7 @@
"message": "Opozovi pristup"
},
"revoke": {
"message": "Revoke"
"message": "Opozovi"
},
"twoStepLoginProviderEnabled": {
"message": "Ovaj pružatelj prijave dvostrukom autentifikacijom je omogućen na tvojem računu."
@@ -4097,10 +4097,10 @@
"message": "Koristiš nepodržani preglednik. Web trezor možda neće ispravno raditi."
},
"youHaveAPendingLoginRequest": {
"message": "You have a pending login request from another device."
"message": "Na čekanju je zahtjev za prijavu s drugog uređaja."
},
"reviewLoginRequest": {
"message": "Review login request"
"message": "Pregledaj zahtjev za prijavu"
},
"freeTrialEndPromptCount": {
"message": "Besplatno probno razdoblje završava za $COUNT$ dan/a.",
@@ -4491,7 +4491,7 @@
"message": "Ažuriranje ključa za šifriranje ne može se nastaviti"
},
"editFieldLabel": {
"message": "Edit $LABEL$",
"message": "Uredi $LABEL$",
"placeholders": {
"label": {
"content": "$1",
@@ -4500,7 +4500,7 @@
}
},
"reorderToggleButton": {
"message": "Reorder $LABEL$. Use arrow key to move item up or down.",
"message": "Ponovno poredaj $LABEL$. Koristi tipke sa strelicom za pomicanje stavke gore ili dolje.",
"placeholders": {
"label": {
"content": "$1",
@@ -4509,7 +4509,7 @@
}
},
"reorderFieldUp": {
"message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$",
"message": "$LABEL$ pomaknuto gore, pozicija $INDEX$ od $LENGTH$",
"placeholders": {
"label": {
"content": "$1",
@@ -4526,7 +4526,7 @@
}
},
"reorderFieldDown": {
"message": "$LABEL$ moved down, position $INDEX$ of $LENGTH$",
"message": "$LABEL$ pomaknuto dolje, pozicija $INDEX$ od$LENGTH$",
"placeholders": {
"label": {
"content": "$1",
@@ -4814,7 +4814,7 @@
"message": "Postavi pravila sigurnosti koja mora zadovoljiti glavna lozinka."
},
"passwordStrengthScore": {
"message": "Password strength score $SCORE$",
"message": "Ocjena jačine lozinke: $SCORE$",
"placeholders": {
"score": {
"content": "$1",
@@ -5103,14 +5103,14 @@
"message": "Pravilo neće biti primjenjeno na Vlasnike i Administratore."
},
"limitSendViews": {
"message": "Limit views"
"message": "Ograniči broj pogleda"
},
"limitSendViewsHint": {
"message": "No one can view this Send after the limit is reached.",
"message": "Nakon dosegnutog broja, nitko neće moći pogledati Send.",
"description": "Displayed under the limit views field on Send"
},
"limitSendViewsCount": {
"message": "$ACCESSCOUNT$ views left",
"message": "Preostalo pogleda: $ACCESSCOUNT$",
"description": "Displayed under the limit views field on Send",
"placeholders": {
"accessCount": {
@@ -5120,11 +5120,11 @@
}
},
"sendDetails": {
"message": "Send details",
"message": "Detalji Senda",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendTypeTextToShare": {
"message": "Text to share"
"message": "Tekst za dijeljenje"
},
"sendTypeFile": {
"message": "Datoteka"
@@ -5133,7 +5133,7 @@
"message": "Tekst"
},
"sendPasswordDescV3": {
"message": "Add an optional password for recipients to access this Send.",
"message": "Dodaj neobaveznu lozinku za pristup ovom Sendu.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"createSend": {
@@ -5161,14 +5161,14 @@
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"deleteSendPermanentConfirmation": {
"message": "Are you sure you want to permanently delete this Send?",
"message": "Sigurno želiš trajno izbrisati ovaj Send?",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"deletionDate": {
"message": "Datum brisanja"
},
"deletionDateDescV2": {
"message": "The Send will be permanently deleted on this date.",
"message": "Send će na ovaj datum biti trajno izbrisan.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"expirationDate": {
@@ -5218,7 +5218,7 @@
"message": "Čeka brisanje"
},
"hideTextByDefault": {
"message": "Hide text by default"
"message": "Zadano sakrij tekst"
},
"expired": {
"message": "Isteklo"
@@ -5689,7 +5689,7 @@
"message": "Došlo je do greške kod spremanja vaših datuma isteka i brisanja."
},
"hideYourEmail": {
"message": "Hide your email address from viewers."
"message": "Sakriti adresu e-pošte od primatelja."
},
"webAuthnFallbackMsg": {
"message": "Za ovjeru tvoje 2FA, odaberi donju tipku."
@@ -5698,10 +5698,10 @@
"message": "Ovjeri WebAuthn"
},
"readSecurityKey": {
"message": "Read security key"
"message": "Pročitaj sigurnosni ključ"
},
"awaitingSecurityKeyInteraction": {
"message": "Awaiting security key interaction..."
"message": "Čekanje na interakciju sa sigurnosnim ključem..."
},
"webAuthnNotSupported": {
"message": "WebAuthn nije podržan u ovom pregledniku."
@@ -6997,7 +6997,7 @@
}
},
"forwaderInvalidOperation": {
"message": "$SERVICENAME$ refused your request. Please contact your service provider for assistance.",
"message": "$SERVICENAME$ je odbio tvoj zahtjev. Obrati se svom pružatelju usluga za pomoć.",
"description": "Displayed when the user is forbidden from using the API by the forwarding service.",
"placeholders": {
"servicename": {
@@ -7007,7 +7007,7 @@
}
},
"forwaderInvalidOperationWithMessage": {
"message": "$SERVICENAME$ refused your request: $ERRORMESSAGE$",
"message": "$SERVICENAME$ je odbio tvoj zahtjev: $ERRORMESSAGE$",
"description": "Displayed when the user is forbidden from using the API by the forwarding service with an error message.",
"placeholders": {
"servicename": {
@@ -7250,10 +7250,10 @@
"message": "Za tvoj račun je potrebna Duo dvostruka autentifikacija."
},
"duoTwoFactorRequiredPageSubtitle": {
"message": "Duo two-step login is required for your account. Follow the steps below to finish logging in."
"message": "Za tvoj je račun potrebna Duo prijava u dva koraka. Za dovršetak prijave, slijedi daljnje korake."
},
"followTheStepsBelowToFinishLoggingIn": {
"message": "Follow the steps below to finish logging in."
"message": "Prati korake za dovršetak prijave."
},
"launchDuo": {
"message": "Pokreni Duo"
@@ -9339,13 +9339,13 @@
"message": "Konfiguriraj upravljanje uređajima za Bitwarden pomoću vodiča za implementaciju za svoju platformu."
},
"deviceIdMissing": {
"message": "Device ID is missing"
"message": "Nedostaje ID uređaja"
},
"deviceTypeMissing": {
"message": "Device type is missing"
"message": "Nedostaje vrsta uređaja"
},
"deviceCreationDateMissing": {
"message": "Device creation date is missing"
"message": "Nedostaje datum stvaranja uređaja"
},
"desktopRequired": {
"message": "Potrebno stolno računalo"
@@ -9847,13 +9847,13 @@
"message": "Saznaj više o Bitwarden API"
},
"fileSend": {
"message": "File Send"
"message": "Send datoteke"
},
"fileSends": {
"message": "Send datoteke"
},
"textSend": {
"message": "Text Send"
"message": "Send teksta"
},
"textSends": {
"message": "Send tekstovi"
@@ -10349,34 +10349,34 @@
"message": "Naziv organizacije ne može biti duži od 50 znakova."
},
"sshKeyWrongPassword": {
"message": "The password you entered is incorrect."
"message": "Unesena lozinka nije ispravna."
},
"importSshKey": {
"message": "Import"
"message": "Uvoz"
},
"confirmSshKeyPassword": {
"message": "Confirm password"
"message": "Potvrdi lozinku"
},
"enterSshKeyPasswordDesc": {
"message": "Enter the password for the SSH key."
"message": "Unesi lozinku za SSH ključ."
},
"enterSshKeyPassword": {
"message": "Enter password"
"message": "Unesi lozinku"
},
"invalidSshKey": {
"message": "The SSH key is invalid"
"message": "SSH ključ nije valjan"
},
"sshKeyTypeUnsupported": {
"message": "The SSH key type is not supported"
"message": "Tip SSH ključa nije podržan"
},
"importSshKeyFromClipboard": {
"message": "Import key from clipboard"
"message": "Uvezi ključ iz međuspremnika"
},
"sshKeyImported": {
"message": "SSH key imported successfully"
"message": "SSH ključ uspješno uvezen"
},
"copySSHPrivateKey": {
"message": "Copy private key"
"message": "Kopiraj privatni ključ"
},
"openingExtension": {
"message": "Otvaranje Bitwarden proširenja preglednika"
@@ -10518,16 +10518,16 @@
"message": "Dodijeljene licence premašuju dostupne licence."
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
"message": "Promijeni rizičnu lozinku"
},
"removeUnlockWithPinPolicyTitle": {
"message": "Remove Unlock with PIN"
"message": "Ukloni otključavanje PIN-om"
},
"removeUnlockWithPinPolicyDesc": {
"message": "Do not allow members to unlock their account with a PIN."
"message": "Ne dozvoli članovima otključavanje računa PIN-om."
},
"limitedEventLogs": {
"message": "$PRODUCT_TYPE$ plans do not have access to real event logs",
"message": "$PRODUCT_TYPE$ planovi nemaju pristup stvarnim zapisima događaja",
"placeholders": {
"product_type": {
"content": "$1",
@@ -10536,15 +10536,15 @@
}
},
"upgradeForFullEvents": {
"message": "Get full access to organization event logs by upgrading to a Teams or Enterprise plan."
"message": "Omogući puni pristup zapisnicima događaja organizacije nadogradnjom na plan Teams ili Enterprise."
},
"upgradeEventLogTitle": {
"message": "Upgrade for real event log data"
"message": "Nadogradi za stvarne podatke dnevnika događaja"
},
"upgradeEventLogMessage": {
"message": "These events are examples only and do not reflect real events within your Bitwarden organization."
"message": "Ovi događaji su samo primjeri i ne odražavaju stvarne događaje unutar tvoje Bitwarden organizacije."
},
"cannotCreateCollection": {
"message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections."
"message": "Besplatne organizacije mogu imati do 2 zbirke. Nadogradi na plaćeni plan za dodavanje više zbirki."
}
}

View File

@@ -9847,13 +9847,13 @@
"message": "Lees meer over Bitwarden's API"
},
"fileSend": {
"message": "Bestand verzenden"
"message": "Bestand Send"
},
"fileSends": {
"message": "Bestand-Sends"
},
"textSend": {
"message": "Tekst-Sends"
"message": "Tekst Send"
},
"textSends": {
"message": "Tekst-Sends"
@@ -10382,7 +10382,7 @@
"message": "Bitwarden-browserextensie openen"
},
"somethingWentWrong": {
"message": "Er is iets fout gegaan..."
"message": "Er is iets fout gegaan"
},
"openingExtensionError": {
"message": "We konden de Bitwarden-browserextensie niet openen. Klik op de knop om deze nu te openen."
@@ -10407,7 +10407,7 @@
"description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'We had trouble opening the Bitwarden browser extension. Open the Bitwarden icon [Bitwarden Icon] from the toolbar.'"
},
"openExtensionManuallyPart2": {
"message": "vanaf de werkbank.",
"message": "vanaf de werkbalk.",
"description": "This will be used as part of a larger sentence, broken up to include the Bitwarden icon. The full sentence will read 'We had trouble opening the Bitwarden browser extension. Open the Bitwarden icon [Bitwarden Icon] from the toolbar.'"
},
"resellerRenewalWarningMsg": {
@@ -10542,7 +10542,7 @@
"message": "Upgrade voor echte event log gegevens"
},
"upgradeEventLogMessage": {
"message": "Deze events zijn voorbeelden en weerspiegelen geen echte evenementen binnen je Bitwarden-organisatie."
"message": "Deze evenementen zijn alleen voorbeelden en weerspiegelen geen echte evenementen binnen je Bitwarden organisatie."
},
"cannotCreateCollection": {
"message": "Gratis organisaties kunnen maximaal twee collecties hebben. Upgrade naar een betaald abonnement voor het toevoegen van meer collecties."

View File

@@ -2500,7 +2500,7 @@
}
},
"noInactive2fa": {
"message": "没有在您的密码库发现未配置两步登录的网站。"
"message": "在您的密码库中没有发现未配置两步登录的网站。"
},
"instructions": {
"message": "说明"
@@ -2596,7 +2596,7 @@
}
},
"noReusedPasswords": {
"message": "您密码库中没有密码重复使用的项目。"
"message": "您密码库中没有密码重复使用的登录项目。"
},
"timesReused": {
"message": "重复使用次数"
@@ -7921,7 +7921,7 @@
}
},
"domainNotVerifiedEvent": {
"message": "$DOMAIN$ 验证",
"message": "$DOMAIN$ 无法验证",
"placeholders": {
"DOMAIN": {
"content": "$1",
@@ -8779,7 +8779,7 @@
"description": "Label for field requesting a self-hosted integration service URL"
},
"alreadyHaveAccount": {
"message": "已经有账户了吗?"
"message": "已经有账户了吗?"
},
"toggleSideNavigation": {
"message": "切换侧边导航"
@@ -10264,7 +10264,7 @@
"message": "您的账户在以下设备上登录过。"
},
"claimedDomains": {
"message": "声明域名"
"message": "声明域名"
},
"claimDomain": {
"message": "声明域名"
@@ -10294,7 +10294,7 @@
"message": "已声明"
},
"domainStatusUnderVerification": {
"message": "正在验证"
"message": "验证"
},
"claimedDomainsDesc": {
"message": "声明一个域名,以拥有电子邮箱地址与该域名匹配的所有成员账户。成员登录时将可以跳过 SSO 标识符。管理员也可以删除成员账户。"

View File

@@ -187,9 +187,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private async initStandardAuthRequestFlow(): Promise<void> {
this.flow = Flow.StandardAuthRequest;
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
this.email = (await firstValueFrom(this.loginEmailService.loginEmail$)) || undefined;
if (!this.email) {
await this.handleMissingEmail();

View File

@@ -82,7 +82,7 @@ export abstract class Fido2UserInterfaceSession {
*
* @param params The parameters to use when asking the user to pick a credential.
* @param abortController An abort controller that can be used to cancel/close the session.
* @returns The ID of the cipher that contains the credentials the user picked.
* @returns The ID of the cipher that contains the credentials the user picked. If not cipher was picked, return cipherId = undefined to to let the authenticator throw the error.
*/
pickCredential: (
params: PickCredentialParams,

View File

@@ -225,9 +225,10 @@ describe("NotificationsService", () => {
});
it.each([
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
// Temporarily rolling back notifications being connected while locked
// { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
// { initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
// { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Unlocked },
])(
"does not re-connect when the user transitions from $initialStatus to $updatedStatus",
@@ -252,7 +253,11 @@ describe("NotificationsService", () => {
},
);
it.each([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked])(
it.each([
// Temporarily disabling notifications connecting while in a locked state
// AuthenticationStatus.Locked,
AuthenticationStatus.Unlocked,
])(
"connects when a user transitions from logged out to %s",
async (newStatus: AuthenticationStatus) => {
emitActiveUser(mockUser1);

View File

@@ -123,13 +123,13 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
);
}
// This method name is a lie currently as we also have an access token
// when locked, this is eventually where we want to be but it increases load
// on signalR so we are rolling back until we can move the load of browser to
// web push.
private hasAccessToken$(userId: UserId) {
return this.authService.authStatusFor$(userId).pipe(
map(
(authStatus) =>
authStatus === AuthenticationStatus.Locked ||
authStatus === AuthenticationStatus.Unlocked,
),
map((authStatus) => authStatus === AuthenticationStatus.Unlocked),
distinctUntilChanged(),
);
}

View File

@@ -8,6 +8,24 @@ describe("MSecureCsvImporter.parse", () => {
importer = new MSecureCsvImporter();
});
it("should correctly parse legacy formatted cards", async () => {
const mockCsvData =
`aWeirdOldStyleCard|1032,Credit Card,,Security code 1234,Card Number|12|5555 4444 3333 2222,Expiration Date|11|04/0029,Name on Card|9|Obi Wan Kenobi,Security Code|9|444,`.trim();
const result = await importer.parse(mockCsvData);
expect(result.success).toBe(true);
expect(result.ciphers.length).toBe(1);
const cipher = result.ciphers[0];
expect(cipher.name).toBe("aWeirdOldStyleCard");
expect(cipher.type).toBe(CipherType.Card);
expect(cipher.card.number).toBe("5555 4444 3333 2222");
expect(cipher.card.expiration).toBe("04 / 2029");
expect(cipher.card.code).toBe("444");
expect(cipher.card.cardholderName).toBe("Obi Wan Kenobi");
expect(cipher.notes).toBe("Security code 1234");
expect(cipher.card.brand).toBe("");
});
it("should correctly parse credit card entries as Secret Notes", async () => {
const mockCsvData =
`myCreditCard|155089404,Credit Card,,,Card Number|12|41111111111111111,Expiration Date|11|05/2026,Security Code|9|123,Name on Card|0|John Doe,PIN|9|1234,Issuing Bank|0|Visa,Phone Number|4|,Billing Address|0|,`.trim();

View File

@@ -43,23 +43,34 @@ export class MSecureCsvImporter extends BaseImporter implements Importer {
).split("/");
cipher.card.expMonth = month.trim();
cipher.card.expYear = year.trim();
cipher.card.code = this.getValueOrDefault(this.splitValueRetainingLastPart(value[6]));
cipher.card.cardholderName = this.getValueOrDefault(
this.splitValueRetainingLastPart(value[7]),
const securityCodeRegex = RegExp("^Security Code\\|\\d*\\|");
const securityCodeEntry = value.find((entry: string) => securityCodeRegex.test(entry));
cipher.card.code = this.getValueOrDefault(
this.splitValueRetainingLastPart(securityCodeEntry),
);
cipher.card.brand = this.getValueOrDefault(this.splitValueRetainingLastPart(value[9]));
cipher.notes =
this.getValueOrDefault(value[8].split("|")[0]) +
": " +
this.getValueOrDefault(this.splitValueRetainingLastPart(value[8]), "") +
"\n" +
this.getValueOrDefault(value[10].split("|")[0]) +
": " +
this.getValueOrDefault(this.splitValueRetainingLastPart(value[10]), "") +
"\n" +
this.getValueOrDefault(value[11].split("|")[0]) +
": " +
this.getValueOrDefault(this.splitValueRetainingLastPart(value[11]), "");
const cardNameRegex = RegExp("^Name on Card\\|\\d*\\|");
const nameOnCardEntry = value.find((entry: string) => entry.match(cardNameRegex));
cipher.card.cardholderName = this.getValueOrDefault(
this.splitValueRetainingLastPart(nameOnCardEntry),
);
cipher.card.brand = this.getValueOrDefault(this.splitValueRetainingLastPart(value[9]), "");
const noteRegex = RegExp("\\|\\d*\\|");
const rawNotes = value
.slice(2)
.filter((entry: string) => !this.isNullOrWhitespace(entry) && !noteRegex.test(entry));
const noteIndexes = [8, 10, 11];
const indexedNotes = noteIndexes
.filter((idx) => value[idx] && noteRegex.test(value[idx]))
.map((idx) => value[idx])
.map((val) => {
const key = val.split("|")[0];
const value = this.getValueOrDefault(this.splitValueRetainingLastPart(val), "");
return `${key}: ${value}`;
});
cipher.notes = [...rawNotes, ...indexedNotes].join("\n");
} else if (value.length > 3) {
cipher.type = CipherType.SecureNote;
cipher.secureNote = new SecureNoteView();
@@ -95,6 +106,6 @@ export class MSecureCsvImporter extends BaseImporter implements Importer {
// like "Password|8|myPassword", we want to keep the "myPassword" but also ensure that if
// the value contains any "|" it works fine
private splitValueRetainingLastPart(value: string) {
return value.split("|").slice(0, 2).concat(value.split("|").slice(2).join("|")).pop();
return value && value.split("|").slice(0, 2).concat(value.split("|").slice(2).join("|")).pop();
}
}

View File

@@ -1,17 +1,23 @@
<bit-callout type="danger" title="{{ 'vaultExportDisabled' | i18n }}" *ngIf="disabledByPolicy">
<bit-callout
type="danger"
title="{{ 'vaultExportDisabled' | i18n }}"
*ngIf="disablePersonalVaultExportPolicy$ | async"
>
{{ "personalVaultExportPolicyInEffect" | i18n }}
</bit-callout>
<tools-export-scope-callout
[organizationId]="organizationId"
*ngIf="!disabledByPolicy"
></tools-export-scope-callout>
<tools-export-scope-callout [organizationId]="organizationId"></tools-export-scope-callout>
<form [formGroup]="exportForm" [bitSubmit]="submit" id="export_form_exportForm">
<ng-container *ngIf="organizations$ | async as organizations">
<bit-form-field *ngIf="organizations.length > 0">
<bit-label>{{ "exportFrom" | i18n }}</bit-label>
<bit-select formControlName="vaultSelector">
<bit-option [label]="'myVault' | i18n" value="myVault" icon="bwi-user" />
<bit-option
[label]="'myVault' | i18n"
value="myVault"
icon="bwi-user"
*ngIf="!(disablePersonalOwnershipPolicy$ | async)"
/>
<bit-option
*ngFor="let o of organizations$ | async"
[value]="o.id"

View File

@@ -22,6 +22,7 @@ import {
Subject,
switchMap,
takeUntil,
tap,
} from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
@@ -154,6 +155,9 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
return this._disabledByPolicy;
}
disablePersonalVaultExportPolicy$: Observable<boolean>;
disablePersonalOwnershipPolicy$: Observable<boolean>;
exportForm = this.formBuilder.group({
vaultSelector: [
"myVault",
@@ -201,15 +205,13 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
this.formDisabled.emit(c === "DISABLED");
});
this.policyService
.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport)
.pipe(takeUntil(this.destroy$))
.subscribe((policyAppliesToActiveUser) => {
this._disabledByPolicy = policyAppliesToActiveUser;
if (this.disabledByPolicy) {
this.exportForm.disable();
}
});
// policies
this.disablePersonalVaultExportPolicy$ = this.policyService.policyAppliesToActiveUser$(
PolicyType.DisablePersonalVaultExport,
);
this.disablePersonalOwnershipPolicy$ = this.policyService.policyAppliesToActiveUser$(
PolicyType.PersonalOwnership,
);
merge(
this.exportForm.get("format").valueChanges,
@@ -269,13 +271,45 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
}),
);
combineLatest([
this.disablePersonalVaultExportPolicy$,
this.disablePersonalOwnershipPolicy$,
this.organizations$,
])
.pipe(
tap(([disablePersonalVaultExport, disablePersonalOwnership, organizations]) => {
this._disabledByPolicy = disablePersonalVaultExport;
// When personalOwnership is disabled and we have orgs, set the first org as the selected vault
if (disablePersonalOwnership && organizations.length > 0) {
this.exportForm.enable();
this.exportForm.controls.vaultSelector.setValue(organizations[0].id);
}
// When personalOwnership is disabled and we have no orgs, disable the form
if (disablePersonalOwnership && organizations.length === 0) {
this.exportForm.disable();
}
// When personalVaultExport is disabled, disable the form
if (disablePersonalVaultExport) {
this.exportForm.disable();
}
// When neither policy is enabled, enable the form and set the default vault to "myVault"
if (!disablePersonalVaultExport && !disablePersonalOwnership) {
this.exportForm.controls.vaultSelector.setValue("myVault");
}
}),
takeUntil(this.destroy$),
)
.subscribe();
this.exportForm.controls.vaultSelector.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((value) => {
this.organizationId = value != "myVault" ? value : undefined;
});
this.exportForm.controls.vaultSelector.setValue("myVault");
}
ngAfterViewInit(): void {
@@ -286,6 +320,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
get encryptedFormat() {

View File

@@ -25,6 +25,11 @@ export type OptionalInitialValues = {
username?: string;
password?: string;
name?: string;
cardholderName?: string;
number?: string;
expMonth?: string;
expYear?: string;
code?: string;
};
/**

View File

@@ -65,6 +65,8 @@ describe("CardDetailsSectionComponent", () => {
cardView.cardholderName = "Ron Burgundy";
cardView.number = "4242 4242 4242 4242";
cardView.brand = "Visa";
cardView.expMonth = "";
cardView.code = "";
expect(patchCipherSpy).toHaveBeenCalled();
const patchFn = patchCipherSpy.mock.lastCall[0];
@@ -79,6 +81,10 @@ describe("CardDetailsSectionComponent", () => {
});
const cardView = new CardView();
cardView.cardholderName = "";
cardView.number = "";
cardView.expMonth = "";
cardView.code = "";
cardView.expYear = "2022";
expect(patchCipherSpy).toHaveBeenCalled();

View File

@@ -97,6 +97,10 @@ export class CardDetailsSectionComponent implements OnInit {
EventType = EventType;
get initialValues() {
return this.cipherFormContainer.config.initialValues;
}
constructor(
private cipherFormContainer: CipherFormContainer,
private formBuilder: FormBuilder,
@@ -139,7 +143,9 @@ export class CardDetailsSectionComponent implements OnInit {
const prefillCipher = this.cipherFormContainer.getInitialCipherView();
if (prefillCipher) {
this.setInitialValues(prefillCipher);
this.initFromExistingCipher(prefillCipher.card);
} else {
this.initNewCipher();
}
if (this.disabled) {
@@ -147,6 +153,26 @@ export class CardDetailsSectionComponent implements OnInit {
}
}
private initFromExistingCipher(existingCard: CardView) {
this.cardDetailsForm.patchValue({
cardholderName: this.initialValues?.cardholderName ?? existingCard.cardholderName,
number: this.initialValues?.number ?? existingCard.number,
expMonth: this.initialValues?.expMonth ?? existingCard.expMonth,
expYear: this.initialValues?.expYear ?? existingCard.expYear,
code: this.initialValues?.code ?? existingCard.code,
});
}
private initNewCipher() {
this.cardDetailsForm.patchValue({
cardholderName: this.initialValues?.cardholderName || "",
number: this.initialValues?.number || "",
expMonth: this.initialValues?.expMonth || "",
expYear: this.initialValues?.expYear || "",
code: this.initialValues?.code || "",
});
}
/** Get the section heading based on the card brand */
getSectionHeading(): string {
const { brand } = this.cardDetailsForm.value;

View File

@@ -15,7 +15,8 @@ import { isCardExpired } from "@bitwarden/common/autofill/utils";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherId, CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -87,6 +88,7 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
private platformUtilsService: PlatformUtilsService,
private changeLoginPasswordService: ChangeLoginPasswordService,
private configService: ConfigService,
private cipherService: CipherService,
) {}
async ngOnChanges() {
@@ -152,7 +154,12 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
const userId = await firstValueFrom(this.activeUserId$);
if (this.cipher.edit && this.cipher.viewPassword) {
// Show Tasks for Manage and Edit permissions
// Using cipherService to see if user has access to cipher in a non-AC context to address with Edit Except Password permissions
const allCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
const cipherServiceCipher = allCiphers[this.cipher?.id as CipherId];
if (cipherServiceCipher?.edit && cipherServiceCipher?.viewPassword) {
await this.checkPendingChangePasswordTasks(userId);
}

4
package-lock.json generated
View File

@@ -189,7 +189,7 @@
},
"apps/browser": {
"name": "@bitwarden/browser",
"version": "2025.3.0"
"version": "2025.3.1"
},
"apps/cli": {
"name": "@bitwarden/cli",
@@ -243,7 +243,7 @@
},
"apps/web": {
"name": "@bitwarden/web-vault",
"version": "2025.3.0"
"version": "2025.3.1"
},
"libs/admin-console": {
"name": "@bitwarden/admin-console",