diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 73fc7ff1d77..d63b6e1b8d7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -141,7 +141,7 @@ apps/desktop/src/locales/en/messages.json apps/web/src/locales/en/messages.json ## Ssh agent temporary co-codeowner -apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-platform-dev @bitwarden/wg-ssh-keys +apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-platform-dev @bitwarden/wg-ssh-keys @bitwarden/team-autofill-dev ## BRE team owns these workflows ## .github/workflows/brew-bump-desktop.yml @bitwarden/dept-bre diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index f98705ce1b6..a71021e7ded 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "أدخل رمز التحقق من 6 أرقام من تطبيق المصادقة الخاص بك." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "أدخل رمز التحقق المكون من 6 أرقام الذي تم إرساله إلى $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "عرض جميع خيارات تسجيل الدخول" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "تم إرسال إشعار إلى جهازك." diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index 63e0c367e84..a1783a91ef8 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -26,7 +26,7 @@ "message": "Keçid açarı ilə giriş et" }, "useSingleSignOn": { - "message": "Tək daxil olma üsulunu istifadə et" + "message": "Vahid daxil olma üsulunu istifadə et" }, "welcomeBack": { "message": "Yenidən xoş gəlmisiniz" @@ -38,7 +38,7 @@ "message": "Bir parol təyin edərək hesabınızı yaratmağı başa çatdırın" }, "enterpriseSingleSignOn": { - "message": "Müəssisə üçün tək daxil olma" + "message": "Müəssisə üçün vahid daxil olma" }, "cancel": { "message": "İmtina" @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Kimlik doğrulayıcı tətbiqindən 6 rəqəmli doğrulama kodunu daxil edin." }, + "authenticationTimeout": { + "message": "Kimlik doğrulama vaxtı bitdi" + }, + "authenticationSessionTimedOut": { + "message": "Kimlik doğrulama seansının vaxtı bitdi. Lütfən giriş prosesini yenidən başladın." + }, "enterVerificationCodeEmail": { "message": "$EMAIL$ ünvanına göndərilən e-poçtdakı 6 rəqəmli doğrulama kodunu daxil edin.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Bütün giriş seçimlərinə bax" }, - "viewAllLoginOptions": { - "message": "Bütün giriş seçimlərinə bax" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Cihazınıza bir bildiriş göndərildi." diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index b09e7374c6e..17bcdec4560 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Увядзіце 6 лічбаў праверачнага кода з вашай праграмы аўтэнтыфікацыі." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Увядзіце 6 лічбаў праверачнага кода, які быў адпраўлены на $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Паглядзець усе варыянты ўваходу" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Апавяшчэнне было адпраўлена на вашу прыладу." diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 652129f6920..b76b72001fe 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Въведете шестцифрения код за потвърждение от приложението за удостоверяване." }, + "authenticationTimeout": { + "message": "Време на давност за удостоверяването" + }, + "authenticationSessionTimedOut": { + "message": "Сесията за удостоверяване е изтекла. Моля, започнете отначало процеса по вписване." + }, "enterVerificationCodeEmail": { "message": "Въведете шестцифрения код за потвърждение, който е бил изпратен на $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Вижте всички възможности за вписване" }, - "viewAllLoginOptions": { - "message": "Вижте всички възможности за вписване" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Към устройството Ви е изпратено известие." diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 3b3b4cad43a..d37499d6748 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "আপনার প্রমাণীকরণকারী অ্যাপ থেকে ৬ সংখ্যার যাচাইকরণ কোডটি প্রবেশ করুন।" }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "$EMAIL$ এ ইমেইল করা ৬ সংখ্যার যাচাই কোডটি প্রবেশ করুন।", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index a6e893887c8..4d024d25e77 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index da8d7a3f8cf..bc351b0faa2 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Introduïu el codi de verificació de 6 dígits de l'aplicació autenticadora." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Introduïu el codi de verificació de 6 dígits que s'ha enviat per correu electrònic a $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Veure totes les opcions d'inici de sessió" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "S'ha enviat una notificació al vostre dispositiu." diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 1984ba2700f..f6ad593bd53 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Zadejte 6místný kód z ověřovací aplikace." }, + "authenticationTimeout": { + "message": "Časový limit ověření" + }, + "authenticationSessionTimedOut": { + "message": "Vypršel časový limit relace ověřování. Restartujte proces přihlášení." + }, "enterVerificationCodeEmail": { "message": "Zadejte 6místný kód z e-mailu, který byl zaslán na $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Zobrazit všechny volby přihlášení" }, - "viewAllLoginOptions": { - "message": "Zobrazit všechny volby přihlášení" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Na Vaše zařízení bylo odesláno oznámení." diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index c57db3b62ff..5d2a0a2e019 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Gweld pob dewis mewngofnodi" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "A notification has been sent to your device." diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 8c10608a298..5407b8298a0 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Indtast den 6-cifrede verifikationskode fra din autentificerings-app." }, + "authenticationTimeout": { + "message": "Godkendelsestimeout" + }, + "authenticationSessionTimedOut": { + "message": "Godkendelsessessionen fik timeout. Genstart loginprocessen." + }, "enterVerificationCodeEmail": { "message": "Indtast den 6-cifrede verifikationskode, der blev sendt til $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Vis alle indlogningsmuligheder" }, - "viewAllLoginOptions": { - "message": "Vis alle indlogningsmuligheder" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "En notifikation er sendt til din enhed." diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index b03f5a97579..e1873f0c3d8 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Gib den 6-stelligen Verifizierungscode aus deiner Authenticator App ein." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Gib den 6-stelligen Bestätigungscode ein, der an $EMAIL$ gesendet wurde.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Alle Anmeldeoptionen anzeigen" }, - "viewAllLoginOptions": { - "message": "Alle Anmeldeoptionen anzeigen" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Eine Benachrichtigung wurde an dein Gerät gesendet." diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index f56db1424a6..ff14bfe30f2 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Εισάγετε τον 6ψήφιο κωδικό από την εφαρμογή επαλήθευσης." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Εισάγετε τον 6ψήφιο κωδικό επαλήθευσης τον οποίο λάβατε στο $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Δείτε όλες τις επιλογές σύνδεσης" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Μια ειδοποίηση έχει σταλεί στη συσκευή σας." diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 39bc6ed9b86..f4a498f3e05 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -192,6 +192,13 @@ "autoFillIdentity": { "message": "Autofill identity" }, + "fillVerificationCode": { + "message": "Fill verification code" + }, + "fillVerificationCodeAria": { + "message": "Fill Verification Code", + "description": "Aria label for the heading displayed the inline menu for totp code autofill" + }, "generatePasswordCopied": { "message": "Generate password (copied)" }, @@ -3173,7 +3180,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { @@ -3580,6 +3587,14 @@ "message": "Unlock your account, opens in a new window", "description": "Screen reader text (aria-label) for unlock account button in overlay" }, + "totpCodeAria": { + "message": "Time-based One-Time Password Verification Code", + "description": "Aria label for the totp code displayed in the inline menu for autofill" + }, + "totpSecondsSpanAria": { + "message": "Time remaining before current TOTP expires", + "description": "Aria label for the totp seconds displayed in the inline menu for autofill" + }, "fillCredentialsFor": { "message": "Fill credentials for", "description": "Screen reader text for when overlay item is in focused" diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 392c8ef7f89..5e24a807d0e 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 52996939873..18b96eb4178 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index a7e7b8cebf1..14576cd12c5 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Introduce el código de verificación de 6 dígitos de tu aplicación autenticadora." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Introduce el código de verificación de 6 dígitos que te ha sido enviado por correo electrónico", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Ver todas las opciones de acceso" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Se ha enviado una notificación a tu dispositivo." diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 020f282fb48..28da8976ddb 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Sisesta autentimise rakendusest 6 kohaline number." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Sisesta 6 kohaline number, mis saadeti e-posti aadressile $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Vaata kõiki valikuid" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Sinu seadmesse saadeti teavitus." diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index cf9054064d8..6dab0b5754a 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Sartu zure autentifikazio aplikazioaren 6 digituko egiaztatze kodea." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Sartu $EMAIL$-era bidalitako 6 digituko egiaztatze-kodea.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Ikusi erregistro guztiak ezarpenetan" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "A notification has been sent to your device." diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 9f7091b3ec9..177ca744651 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "کد ۶ رقمی تأیید را از برنامه احراز هویت وارد کنید." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "کد ۶ رقمی تأیید را که به $EMAIL$ ایمیل شده را وارد کنید.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "مشاهده همه گزینه‌های ورود به سیستم" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "یک اعلان به دستگاه شما ارسال شده است." diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index aa8adbb799d..308f2563ab8 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Syötä todennussovelluksesi näyttämä kuusinumeroinen todennuskoodi." }, + "authenticationTimeout": { + "message": "Todennuksen aikakatkaisu" + }, + "authenticationSessionTimedOut": { + "message": "Todennusistunto aikakatkaistiin. Ole hyvä ja aloita kirjautumisprosessi uudelleen." + }, "enterVerificationCodeEmail": { "message": "Syötä osoitteeseen $EMAIL$ lähetetty kuusinumeroinen todennuskoodi.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Näytä kaikki kirjautumisvaihtoehdot" }, - "viewAllLoginOptions": { - "message": "Näytä kaikki kirjautumisvaihtoehdot" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Laitteellesi on lähetetty ilmoitus." @@ -4890,12 +4896,12 @@ "message": "Beta" }, "extensionWidth": { - "message": "Extension width" + "message": "Laajennuksen leveys" }, "wide": { - "message": "Wide" + "message": "Leveä" }, "extraWide": { - "message": "Extra wide" + "message": "Erittäin leveä" } } diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index fe3714b6d5f..a4e98843112 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Ipasok ang 6 na digit na code ng pagpapatunay mula sa iyong authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Ipasok ang 6 na digit na code na na-email sa $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Tingnan ang lahat ng mga pagpipilian sa pag log in" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Naipadala na ang notification sa iyong device." diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index a4f238705aa..01997f9ca52 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Saisissez le code de vérification à 6 chiffres depuis votre application d'authentification." }, + "authenticationTimeout": { + "message": "Délai d'authentification dépassé" + }, + "authenticationSessionTimedOut": { + "message": "La session d'authentification a expiré. Veuillez redémarrer le processus de connexion." + }, "enterVerificationCodeEmail": { "message": "Saisissez le code de vérification à 6 chiffres qui a été envoyé par courriel à $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Afficher toutes les options de connexion" }, - "viewAllLoginOptions": { - "message": "Afficher toutes les options de connexion" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Une notification a été envoyée à votre appareil." diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 845ddb08a97..aa0d9a17762 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 88264c26e5d..3c418682003 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "הכנס את קוד האימות בן 6 הספרות מאפליקציית האימות שלך." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "הכנס את קוד האימות בן 6 הספרות שנשלח ל-$EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 0d4cf1548d4..b4c03a8f829 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "अपने ऑथेंटिकेटर ऐप से 6 डिजिट वेरिफिकेशन कोड डालें।" }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 659a8d1b593..80eb68be8ac 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Unesi 6-znamenkasti kontrolni kôd iz autentifikatorske aplikacije." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Unesi 6-znamenkasti kontrolni kôd poslan e-poštom na $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Pogledaj sve mogućnosti prijave" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Obavijest je poslana na tvoj uređaj." diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index d11b62b6cb4..25ea3d440b7 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Add meg a 6 számjegyű ellenőrző kódot a hitelesítő alkalmazásodból." }, + "authenticationTimeout": { + "message": "Hitelesítési időkifutás" + }, + "authenticationSessionTimedOut": { + "message": "A hitelesítési munkamenet időkifutással lejárt. Indítsuk újra a bejelentkezési folyamatot." + }, "enterVerificationCodeEmail": { "message": "$EMAIL$ email címre elküldött 6 számjegyű ellenőrző kód megadása.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Összes bejelentkezési opció megtekintése" }, - "viewAllLoginOptions": { - "message": "Összes bejelentkezési opció megtekintése" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Egy értesítés lett elküldve az eszközre." diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 467492a2f78..8a341c59b8d 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Masukkan 6 digit kode verifikasi dari aplikasi autentikasi Anda." }, + "authenticationTimeout": { + "message": "Batas waktu otentikasi" + }, + "authenticationSessionTimedOut": { + "message": "Sesi otentikasi telah berakhir. Harap mulai ulang proses masuk." + }, "enterVerificationCodeEmail": { "message": "Masukkan 6 digit kode verifikasi yang dikirim melalui email ke $EMAIL$.", "placeholders": { @@ -1687,7 +1693,7 @@ "message": "Dr" }, "mx": { - "message": "Mx" + "message": "Yth" }, "firstName": { "message": "Nama Depan" @@ -2144,7 +2150,7 @@ "message": "Kata sandi utama baru" }, "confirmNewMasterPass": { - "message": "Confirm new master password" + "message": "Konfirmasi kata sandi utama baru" }, "masterPasswordPolicyInEffect": { "message": "Satu atau lebih kebijakan organisasi membutuhkan kata sandi utama Anda untuk memenuhi persyaratan berikut:" @@ -2189,19 +2195,19 @@ "message": "Kata sandi utama Anda yang baru tidak memenuhi persyaratan kebijakan." }, "receiveMarketingEmailsV2": { - "message": "Get advice, announcements, and research opportunities from Bitwarden in your inbox." + "message": "Dapatkan saran, pengumuman, dan kesempatan penelitian dari Bitwarden di kotak masuk Anda." }, "unsubscribe": { - "message": "Unsubscribe" + "message": "Berhenti berlangganan" }, "atAnyTime": { - "message": "at any time." + "message": "kapanpun." }, "byContinuingYouAgreeToThe": { - "message": "By continuing, you agree to the" + "message": "Dengan melanjutkan, Anda menyetujui" }, "and": { - "message": "and" + "message": "dan" }, "acceptPolicies": { "message": "Dengan mencentang kotak ini, Anda menyetujui yang berikut:" @@ -2222,10 +2228,10 @@ "message": "Oke" }, "errorRefreshingAccessToken": { - "message": "Access Token Refresh Error" + "message": "Galat Penyegaran Token Akses" }, "errorRefreshingAccessTokenDesc": { - "message": "No refresh token or API keys found. Please try logging out and logging back in." + "message": "Tidak ada token penyegaran atau kunci API yang ditemukan. Harap coba keluar dan masuk kembali." }, "desktopSyncVerificationTitle": { "message": "Verifikasi sinkronisasi desktop" @@ -2264,10 +2270,10 @@ "message": "Akun tidak cocok" }, "nativeMessagingWrongUserKeyTitle": { - "message": "Biometric key missmatch" + "message": "Kunci biometrik tidak cocok" }, "nativeMessagingWrongUserKeyDesc": { - "message": "Biometric unlock failed. The biometric secret key failed to unlock the vault. Please try to set up biometrics again." + "message": "Gagal membuka dengan biometrik. Kunci rahasia biometrik gagal membuka brankas. Harap coba atur biometrik lagi." }, "biometricsNotEnabledTitle": { "message": "Biometrik tidak diaktifkan" @@ -2282,10 +2288,10 @@ "message": "Biometrik peramban tidak didukung di perangkat ini." }, "biometricsNotUnlockedTitle": { - "message": "User locked or logged out" + "message": "Pengguna terkunci atau telah keluar" }, "biometricsNotUnlockedDesc": { - "message": "Please unlock this user in the desktop application and try again." + "message": "Harap buka kunci pengguna ini di aplikasi desktop dan coba kembali." }, "biometricsNotAvailableTitle": { "message": "Buka dengan biometrik tidak tersedia" @@ -2518,7 +2524,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", + "message": "Tambahkan kata sandi tidak wajib untuk penerima untuk mengakses Send ini.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendNotesDesc": { @@ -2818,7 +2824,7 @@ "message": "Tidak ada pengidentifikasi unik yang ditemukan." }, "convertOrganizationEncryptionDesc": { - "message": "$ORGANIZATION$ is using SSO with a self-hosted key server. A master password is no longer required to log in for members of this organization.", + "message": "$ORGANIZATION$ menggunakan SSO dengan server kunci yang dihosting sendiri. Kata sandi utama tidak lagi diperlukan untuk masuk untuk anggota organisasi ini.", "placeholders": { "organization": { "content": "$1", @@ -2842,16 +2848,16 @@ "message": "Anda telah keluar dari organisasi." }, "toggleCharacterCount": { - "message": "Toggle character count" + "message": "Saklar hitung karakter" }, "sessionTimeout": { - "message": "Your session has timed out. Please go back and try logging in again." + "message": "Sesi Anda telah berakhir. Harap kembali dan coba masuk lagi." }, "exportingPersonalVaultTitle": { - "message": "Exporting individual vault" + "message": "Mengekspor brankas individu" }, "exportingIndividualVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", + "message": "Hanya benda-benda brankas perorangan yang terkait dengan $EMAIL$ yang akan diekspor. Benda-benda brankas organisasi tidak akan disertakan. Hanya informasi benda brankas yang akan diekspor dan tidak menyertakan lampiran yang terkait.", "placeholders": { "email": { "content": "$1", @@ -2860,10 +2866,10 @@ } }, "exportingOrganizationVaultTitle": { - "message": "Exporting organization vault" + "message": "Mengekspor brankas organisasi" }, "exportingOrganizationVaultDesc": { - "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.", + "message": "Hanya brankas organisasi yang terkait dengan $ORGANIZATION$ yang akan diekspor. Benda-benda di brankas perorangan atau organisasi lainnya tidak akan disertakan.", "placeholders": { "organization": { "content": "$1", @@ -2881,10 +2887,10 @@ "message": "Buat nama pengguna baru" }, "generateEmail": { - "message": "Generate email" + "message": "Buat email" }, "spinboxBoundariesHint": { - "message": "Value must be between $MIN$ and $MAX$.", + "message": "Nilai harus ada di antara $MIN$ dan $MAX$.", "description": "Explains spin box minimum and maximum values to the user", "placeholders": { "min": { @@ -2898,7 +2904,7 @@ } }, "passwordLengthRecommendationHint": { - "message": " Use $RECOMMENDED$ characters or more to generate a strong password.", + "message": " Gunakan $RECOMMENDED$ karakter atau lebih untuk menghasilkan kata sandi yang kuat.", "description": "Appended to `spinboxBoundariesHint` to recommend a length to the user. This must include any language-specific 'sentence' separator characters (e.g. a space in english).", "placeholders": { "recommended": { @@ -2908,7 +2914,7 @@ } }, "passphraseNumWordsRecommendationHint": { - "message": " Use $RECOMMENDED$ words or more to generate a strong passphrase.", + "message": " Gunakan $RECOMMENDED$ kata atau lebih untuk menghasilkan frasa sandi yang kuat.", "description": "Appended to `spinboxBoundariesHint` to recommend a number of words to the user. This must include any language-specific 'sentence' separator characters (e.g. a space in english).", "placeholders": { "recommended": { @@ -2921,17 +2927,17 @@ "message": "Jenis nama pengguna" }, "plusAddressedEmail": { - "message": "Plus addressed email", + "message": "Surel dengan alamat plus", "description": "Username generator option that appends a random sub-address to the username. For example: address+subaddress@email.com" }, "plusAddressedEmailDesc": { - "message": "Use your email provider's sub-addressing capabilities." + "message": "Gunakan kemampuan sub-addressing penyedia surel Anda." }, "catchallEmail": { - "message": "Catch-all email" + "message": "Surel tangkap-semua" }, "catchallEmailDesc": { - "message": "Use your domain's configured catch-all inbox." + "message": "Gunakan pengaturan kotak masuk tangkap-semua milik domain Anda." }, "random": { "message": "Acak" @@ -2949,24 +2955,24 @@ "message": "Jenis kata sandi" }, "service": { - "message": "Service" + "message": "Layanan" }, "forwardedEmail": { - "message": "Forwarded email alias" + "message": "Alias surel yang diteruskan" }, "forwardedEmailDesc": { - "message": "Generate an email alias with an external forwarding service." + "message": "Buat alias surel dengan layanan penerusan eksternal." }, "forwarderDomainName": { - "message": "Email domain", + "message": "Domain surel", "description": "Labels the domain name email forwarder service option" }, "forwarderDomainNameHint": { - "message": "Choose a domain that is supported by the selected service", + "message": "Pilih domain yang didukung oleh layanan terpilih", "description": "Guidance provided for email forwarding services that support multiple email domains." }, "forwarderError": { - "message": "$SERVICENAME$ error: $ERRORMESSAGE$", + "message": "Galat $SERVICENAME$: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", "placeholders": { "servicename": { @@ -2980,11 +2986,11 @@ } }, "forwarderGeneratedBy": { - "message": "Generated by Bitwarden.", + "message": "Dibuat oleh Bitwarden.", "description": "Displayed with the address on the forwarding service's configuration screen." }, "forwarderGeneratedByWithWebsite": { - "message": "Website: $WEBSITE$. Generated by Bitwarden.", + "message": "Situs web: $WEBSITE$. Dibuat oleh Bitwarden.", "description": "Displayed with the address on the forwarding service's configuration screen.", "placeholders": { "WEBSITE": { @@ -2994,7 +3000,7 @@ } }, "forwaderInvalidToken": { - "message": "Invalid $SERVICENAME$ API token", + "message": "Token API $SERVICENAME$ tidak valid", "description": "Displayed when the user's API token is empty or rejected by the forwarding service.", "placeholders": { "servicename": { @@ -3004,7 +3010,7 @@ } }, "forwaderInvalidTokenWithMessage": { - "message": "Invalid $SERVICENAME$ API token: $ERRORMESSAGE$", + "message": "Token API $SERVICENAME$ tidak valid: $ERRORMESSAGE$", "description": "Displayed when the user's API token is rejected by the forwarding service with an error message.", "placeholders": { "servicename": { @@ -3018,7 +3024,7 @@ } }, "forwarderNoAccountId": { - "message": "Unable to obtain $SERVICENAME$ masked email account ID.", + "message": "Gagal mendapatkan akun ID surel bertopeng dari $SERVICENAME$.", "description": "Displayed when the forwarding service fails to return an account ID.", "placeholders": { "servicename": { @@ -3028,7 +3034,7 @@ } }, "forwarderNoDomain": { - "message": "Invalid $SERVICENAME$ domain.", + "message": "Domain $SERVICENAME$ tidak valid.", "description": "Displayed when the domain is empty or domain authorization failed at the forwarding service.", "placeholders": { "servicename": { @@ -3038,7 +3044,7 @@ } }, "forwarderNoUrl": { - "message": "Invalid $SERVICENAME$ url.", + "message": "URL $SERVICENAME$ tidak valid.", "description": "Displayed when the url of the forwarding service wasn't supplied.", "placeholders": { "servicename": { @@ -3048,7 +3054,7 @@ } }, "forwarderUnknownError": { - "message": "Unknown $SERVICENAME$ error occurred.", + "message": "Terjadi galat yang tidak diketahui dari $SERVICENAME$.", "description": "Displayed when the forwarding service failed due to an unknown error.", "placeholders": { "servicename": { @@ -3058,7 +3064,7 @@ } }, "forwarderUnknownForwarder": { - "message": "Unknown forwarder: '$SERVICENAME$'.", + "message": "Penerus tidak diketahui: '$SERVICENAME$'.", "description": "Displayed when the forwarding service is not supported.", "placeholders": { "servicename": { @@ -3068,20 +3074,20 @@ } }, "hostname": { - "message": "Hostname", + "message": "Nama host", "description": "Part of a URL." }, "apiAccessToken": { - "message": "API Access Token" + "message": "Token Akses API" }, "apiKey": { - "message": "API Key" + "message": "Kunci API" }, "ssoKeyConnectorError": { - "message": "Key connector error: make sure key connector is available and working correctly." + "message": "Galat kunci penyambung: pastikan kunci penyambung tersedia dan bekerja dengan benar." }, "premiumSubcriptionRequired": { - "message": "Premium subscription required" + "message": "Langganan premium diperlukan" }, "organizationIsDisabled": { "message": "Organisasi ditangguhkan." @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Lihat semua pilihan masuk" }, - "viewAllLoginOptions": { - "message": "Lihat semua pilihan masuk" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Sebuah pemberitahuan dikirim ke perangkat Anda." @@ -3279,115 +3285,115 @@ "message": "Buka di jendela baru" }, "rememberThisDeviceToMakeFutureLoginsSeamless": { - "message": "Remember this device to make future logins seamless" + "message": "Ingat perangkat ini untuk membuat login berikutnya lebih lancar" }, "deviceApprovalRequired": { - "message": "Device approval required. Select an approval option below:" + "message": "Persetujuan perangkat diperlukan. Pilih sebuah pilihan persetujuan berikut:" }, "deviceApprovalRequiredV2": { - "message": "Device approval required" + "message": "Persetujuan perangkat diperlukan" }, "selectAnApprovalOptionBelow": { - "message": "Select an approval option below" + "message": "Pilih sebuah pilihan persetujuan berikut" }, "rememberThisDevice": { - "message": "Remember this device" + "message": "Ingat perangkat ini" }, "uncheckIfPublicDevice": { - "message": "Uncheck if using a public device" + "message": "Batalkan centang jika menggunakan perangkat umum" }, "approveFromYourOtherDevice": { - "message": "Approve from your other device" + "message": "Setujui dari perangkat lain milik Anda" }, "requestAdminApproval": { - "message": "Request admin approval" + "message": "Minta persetujuan admin" }, "approveWithMasterPassword": { - "message": "Approve with master password" + "message": "Setujui dengan kata sandi utama" }, "ssoIdentifierRequired": { - "message": "Organization SSO identifier is required." + "message": "Pengenal SSO organisasi diperlukan." }, "creatingAccountOn": { - "message": "Creating account on" + "message": "Membuat akun pada" }, "checkYourEmail": { - "message": "Check your email" + "message": "Periksa surel Anda" }, "followTheLinkInTheEmailSentTo": { - "message": "Follow the link in the email sent to" + "message": "Ikuti tautan pada surel yang telah dikirim" }, "andContinueCreatingYourAccount": { - "message": "and continue creating your account." + "message": "dan lanjutkan membuat akun Anda." }, "noEmail": { - "message": "No email?" + "message": "Tidak punya surel?" }, "goBack": { - "message": "Go back" + "message": "Kembali" }, "toEditYourEmailAddress": { - "message": "to edit your email address." + "message": "untuk menyunting alamat surel Anda." }, "eu": { "message": "EU", "description": "European Union" }, "accessDenied": { - "message": "Access denied. You do not have permission to view this page." + "message": "Akses ditolak. Anda tidak mempunyai izin untuk melihat halaman ini." }, "general": { - "message": "General" + "message": "Umum" }, "display": { - "message": "Display" + "message": "Tampilan" }, "accountSuccessfullyCreated": { - "message": "Account successfully created!" + "message": "Akun berhasil dibuat!" }, "adminApprovalRequested": { - "message": "Admin approval requested" + "message": "Persetujuan admin telah diminta" }, "adminApprovalRequestSentToAdmins": { - "message": "Your request has been sent to your admin." + "message": "Permintaan Anda telah dikirim ke admin Anda." }, "youWillBeNotifiedOnceApproved": { - "message": "You will be notified once approved." + "message": "Anda akan diberitahu setelah disetujui." }, "troubleLoggingIn": { - "message": "Trouble logging in?" + "message": "Kesulitan masuk?" }, "loginApproved": { - "message": "Login approved" + "message": "Login disetujui" }, "userEmailMissing": { - "message": "User email missing" + "message": "Surel pengguna hilang" }, "activeUserEmailNotFoundLoggingYouOut": { - "message": "Active user email not found. Logging you out." + "message": "Surel pengguna yang aktif tidak ditemukan. Mengeluarkan Anda." }, "deviceTrusted": { - "message": "Device trusted" + "message": "Perangkat dipercaya" }, "sendsNoItemsTitle": { - "message": "No active Sends", + "message": "Tidak ada Send yang aktif", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendsNoItemsMessage": { - "message": "Use Send to securely share encrypted information with anyone.", + "message": "Gunakan Send untuk membagikan informasi terenkripsi secara aman dengan siapapun.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "inputRequired": { - "message": "Input is required." + "message": "Masukan ini harus diisi." }, "required": { - "message": "required" + "message": "wajib diisi" }, "search": { - "message": "Search" + "message": "Cari" }, "inputMinLength": { - "message": "Input must be at least $COUNT$ characters long.", + "message": "Masukan sekurang-kurangnya $COUNT$ karakter.", "placeholders": { "count": { "content": "$1", @@ -3396,7 +3402,7 @@ } }, "inputMaxLength": { - "message": "Input must not exceed $COUNT$ characters in length.", + "message": "Masukan tidak boleh melebihi $COUNT$ karakter.", "placeholders": { "count": { "content": "$1", @@ -3405,7 +3411,7 @@ } }, "inputForbiddenCharacters": { - "message": "The following characters are not allowed: $CHARACTERS$", + "message": "Karakter berikut tidak diperbolehkan: $CHARACTERS$", "placeholders": { "characters": { "content": "$1", @@ -3414,7 +3420,7 @@ } }, "inputMinValue": { - "message": "Input value must be at least $MIN$.", + "message": "Nilai masukan sekurang-kurangnya $MIN$.", "placeholders": { "min": { "content": "$1", @@ -3423,7 +3429,7 @@ } }, "inputMaxValue": { - "message": "Input value must not exceed $MAX$.", + "message": "Nilai masukan tidak boleh melebihi $MAX$.", "placeholders": { "max": { "content": "$1", @@ -3432,17 +3438,17 @@ } }, "multipleInputEmails": { - "message": "1 or more emails are invalid" + "message": "1 atau lebih surel tidak valid" }, "inputTrimValidator": { - "message": "Input must not contain only whitespace.", + "message": "Masukan tidak boleh berisi hanya spasi kosong.", "description": "Notification to inform the user that a form's input can't contain only whitespace." }, "inputEmail": { - "message": "Input is not an email address." + "message": "Masukan bukan sebuah alamat surel." }, "fieldsNeedAttention": { - "message": "$COUNT$ field(s) above need your attention.", + "message": "$COUNT$ bidang di atas memerlukan perhatian Anda.", "placeholders": { "count": { "content": "$1", @@ -3451,10 +3457,10 @@ } }, "singleFieldNeedsAttention": { - "message": "1 field needs your attention." + "message": "1 bidang memerlukan perhatian Anda." }, "multipleFieldsNeedAttention": { - "message": "$COUNT$ fields need your attention.", + "message": "$COUNT$ bidang memerlukan perhatian Anda.", "placeholders": { "count": { "content": "$1", @@ -3463,10 +3469,10 @@ } }, "selectPlaceholder": { - "message": "-- Select --" + "message": "-- Pilih --" }, "multiSelectPlaceholder": { - "message": "-- Type to filter --" + "message": "-- Ketik untuk menyaring --" }, "multiSelectLoading": { "message": "Mengambil pilihan..." @@ -3663,40 +3669,40 @@ "message": "Diperlukan verifikasi untuk tindakan ini. Atur PIN untuk melanjutkan." }, "setPin": { - "message": "Set PIN" + "message": "Atur PIN" }, "verifyWithBiometrics": { - "message": "Verify with biometrics" + "message": "Verifikasi dengan biometrik" }, "awaitingConfirmation": { - "message": "Awaiting confirmation" + "message": "Menunggu konfirmasi" }, "couldNotCompleteBiometrics": { - "message": "Could not complete biometrics." + "message": "Tidak dapat melengkapi biometrik." }, "needADifferentMethod": { - "message": "Need a different method?" + "message": "Perlu cara lain?" }, "useMasterPassword": { - "message": "Use master password" + "message": "Gunakan kata sandi utama" }, "usePin": { - "message": "Use PIN" + "message": "Gunakan PIN" }, "useBiometrics": { - "message": "Use biometrics" + "message": "Gunakan biometrik" }, "enterVerificationCodeSentToEmail": { - "message": "Enter the verification code that was sent to your email." + "message": "Masukkan kode verifikasi yang dikirim ke surel Anda." }, "resendCode": { - "message": "Resend code" + "message": "Kirim ulang kode" }, "total": { - "message": "Total" + "message": "Jumlah" }, "importWarning": { - "message": "You are importing data to $ORGANIZATION$. Your data may be shared with members of this organization. Do you want to proceed?", + "message": "Anda mengimpor data ke $ORGANIZATION$. Data Anda dapat dibagikan dengan anggota organisasi ini. Apakah Anda ingin melanjutkan?", "placeholders": { "organization": { "content": "$1", @@ -3705,49 +3711,49 @@ } }, "duoHealthCheckResultsInNullAuthUrlError": { - "message": "Error connecting with the Duo service. Use a different two-step login method or contact Duo for assistance." + "message": "Gagal menyambungkan dengan layanan Duo. Gunakan cara masuk dua-langkah lainnya atau hubungi Duo untuk mendapatkan panduan." }, "launchDuoAndFollowStepsToFinishLoggingIn": { - "message": "Launch Duo and follow the steps to finish logging in." + "message": "Luncurkan Duo dan ikuti langkah-langkah untuk menyelesaikan masuk." }, "duoRequiredForAccount": { - "message": "Duo two-step login is required for your account." + "message": "Login dua-langkah Duo diperlukan untuk akun Anda." }, "popoutTheExtensionToCompleteLogin": { - "message": "Popout the extension to complete login." + "message": "Sembulkan ekstensi untuk melengkapi login." }, "popoutExtension": { - "message": "Popout extension" + "message": "Sembulkan ekstensi" }, "launchDuo": { - "message": "Launch Duo" + "message": "Luncurkan Duo" }, "importFormatError": { - "message": "Data is not formatted correctly. Please check your import file and try again." + "message": "Data tidak diformat dengan benar. Harap periksa berkas impor Anda dan coba lagi." }, "importNothingError": { - "message": "Nothing was imported." + "message": "Tidak ada yang diimpor." }, "importEncKeyError": { - "message": "Error decrypting the exported file. Your encryption key does not match the encryption key used export the data." + "message": "Tidak dapat mendekripsi berkas yang diekspor. Kunci enkripsi Anda tidak cocok dengan kunci enkripsi yang digunakan untuk mengekspor data tersebut." }, "invalidFilePassword": { - "message": "Invalid file password, please use the password you entered when you created the export file." + "message": "Kata sandi berkas tidak valid, harap menggunakan kata sandi yang Anda masukkan saat Anda membuat berkas ekspor." }, "destination": { - "message": "Destination" + "message": "Tujuan" }, "learnAboutImportOptions": { - "message": "Learn about your import options" + "message": "Pelajari tentang pilihan impor Anda" }, "selectImportFolder": { - "message": "Select a folder" + "message": "Pilih folder" }, "selectImportCollection": { - "message": "Select a collection" + "message": "Pilih koleksi" }, "importTargetHint": { - "message": "Select this option if you want the imported file contents moved to a $DESTINATION$", + "message": "Pilih pilihan ini jika Anda ingin isi dari berkas yang diimpor dipindah ke $DESTINATION$", "description": "Located as a hint under the import target. Will be appended by either folder or collection, depending if the user is importing into an individual or an organizational vault.", "placeholders": { "destination": { @@ -3757,25 +3763,25 @@ } }, "importUnassignedItemsError": { - "message": "File contains unassigned items." + "message": "Berkas berisi benda-benda yang belum ditetapkan." }, "selectFormat": { - "message": "Select the format of the import file" + "message": "Pilih format untuk berkas yang diimpor" }, "selectImportFile": { - "message": "Select the import file" + "message": "Pilih berkas yang akan diimpor" }, "chooseFile": { - "message": "Choose File" + "message": "Pilih Berkas" }, "noFileChosen": { - "message": "No file chosen" + "message": "Tidak ada berkas yang dipilih" }, "orCopyPasteFileContents": { - "message": "or copy/paste the import file contents" + "message": "atau salin/tempel isi berkas yang diimpor" }, "instructionsFor": { - "message": "$NAME$ Instructions", + "message": "Petunjuk $NAME$", "description": "The title for the import tool instructions.", "placeholders": { "name": { @@ -3785,52 +3791,52 @@ } }, "confirmVaultImport": { - "message": "Confirm vault import" + "message": "Konfirmasi impor brankas" }, "confirmVaultImportDesc": { - "message": "This file is password-protected. Please enter the file password to import data." + "message": "Berkas ini dilindungi oleh kata sandi. Masukkan kata sandi berkas untuk mengimpor data." }, "confirmFilePassword": { - "message": "Confirm file password" + "message": "Konfirmasi kata sandi berkas" }, "exportSuccess": { - "message": "Vault data exported" + "message": "Data brankas berhasil diekspor" }, "typePasskey": { - "message": "Passkey" + "message": "Kunci sandi" }, "accessing": { - "message": "Accessing" + "message": "Sedang mengakses" }, "loggedInExclamation": { - "message": "Logged in!" + "message": "Sudah masuk!" }, "passkeyNotCopied": { - "message": "Passkey will not be copied" + "message": "Kunci sandi tidak akan disalin" }, "passkeyNotCopiedAlert": { - "message": "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?" + "message": "Kunci sandi tidak akan disalin ke benda yang digandakan. Apakah Anda ingin melanjutkan menggandakan benda ini?" }, "passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword": { - "message": "Verification required by the initiating site. This feature is not yet implemented for accounts without master password." + "message": "Verifikasi diperlukan oleh situs yang menyelenggarakan. Fitur ini belum diterapkan untuk akun tanpa kata sandi utama." }, "logInWithPasskeyQuestion": { - "message": "Log in with passkey?" + "message": "Masuk dengan kunci sandi?" }, "passkeyAlreadyExists": { - "message": "A passkey already exists for this application." + "message": "Kunci sandi sudah ada untuk aplikasi ini." }, "noPasskeysFoundForThisApplication": { - "message": "No passkeys found for this application." + "message": "Tidak ada kunci sandi yang ditemukan untuk aplikasi ini." }, "noMatchingPasskeyLogin": { - "message": "You do not have a matching login for this site." + "message": "Anda tidak memiliki login yang cocok untuk situs ini." }, "noMatchingLoginsForSite": { - "message": "No matching logins for this site" + "message": "Tidak ada login yang cocok untuk situs ini" }, "searchSavePasskeyNewLogin": { - "message": "Search or save passkey as new login" + "message": "Cari atau simpan kunci sandi sebagai login baru" }, "confirm": { "message": "Konfirmasi" @@ -3991,99 +3997,99 @@ "description": "Label indicating the most common import formats" }, "confirmContinueToBrowserSettingsTitle": { - "message": "Continue to browser settings?", + "message": "Lanjutkan ke pengaturan peramban?", "description": "Title for dialog which asks if the user wants to proceed to a relevant browser settings page" }, "confirmContinueToHelpCenter": { - "message": "Continue to Help Center?", + "message": "Lanjutkan ke Pusat Bantuan?", "description": "Title for dialog which asks if the user wants to proceed to a relevant Help Center page" }, "confirmContinueToHelpCenterPasswordManagementContent": { - "message": "Change your browser's autofill and password management settings.", + "message": "Ganti pengaturan isi otomatis dan pengelolaan kata sandi peramban Anda.", "description": "Body content for dialog which asks if the user wants to proceed to the Help Center's page about browser password management settings" }, "confirmContinueToHelpCenterKeyboardShortcutsContent": { - "message": "You can view and set extension shortcuts in your browser's settings.", + "message": "Anda dapat melihat dan mengatur pintasan ekstensi di pengaturan peramban Anda.", "description": "Body content for dialog which asks if the user wants to proceed to the Help Center's page about browser keyboard shortcut settings" }, "confirmContinueToBrowserPasswordManagementSettingsContent": { - "message": "Change your browser's autofill and password management settings.", + "message": "Ganti pengaturan isi otomatis dan pengelolaan kata sandi peramban Anda.", "description": "Body content for dialog which asks if the user wants to proceed to the browser's password management settings page" }, "confirmContinueToBrowserKeyboardShortcutSettingsContent": { - "message": "You can view and set extension shortcuts in your browser's settings.", + "message": "Anda dapat melihat dan mengatur pintasan ekstensi di pengaturan peramban Anda.", "description": "Body content for dialog which asks if the user wants to proceed to the browser's keyboard shortcut settings page" }, "overrideDefaultBrowserAutofillTitle": { - "message": "Make Bitwarden your default password manager?", + "message": "Jadikan Bitwarden sebagai pengelola kata sandi bawaan Anda?", "description": "Dialog title facilitating the ability to override a chrome browser's default autofill behavior" }, "overrideDefaultBrowserAutofillDescription": { - "message": "Ignoring this option may cause conflicts between Bitwarden autofill suggestions and your browser's.", + "message": "Mengabaikan pilihan ini dapat mengakibatkan perseteruan antara saran isi otomatis Bitwarden dengan peramban Anda.", "description": "Dialog message facilitating the ability to override a chrome browser's default autofill behavior" }, "overrideDefaultBrowserAutoFillSettings": { - "message": "Make Bitwarden your default password manager", + "message": "Jadikan Bitwarden sebagai pengelola kata sandi bawaan Anda", "description": "Label for the setting that allows overriding the default browser autofill settings" }, "privacyPermissionAdditionNotGrantedTitle": { - "message": "Unable to set Bitwarden as the default password manager", + "message": "Tidak dapat mengatur Bitwarden sebagai pengelola kata sandi bawaan", "description": "Title for the dialog that appears when the user has not granted the extension permission to set privacy settings" }, "privacyPermissionAdditionNotGrantedDescription": { - "message": "You must grant browser privacy permissions to Bitwarden to set it as the default password manager.", + "message": "Anda harus mengizinkan perizinan privasi peramban kepada Bitwarden untuk mengaturnya sebagai pengelola kata sandi bawaan.", "description": "Description for the dialog that appears when the user has not granted the extension permission to set privacy settings" }, "makeDefault": { - "message": "Make default", + "message": "Jadikan bawaan", "description": "Button text for the setting that allows overriding the default browser autofill settings" }, "saveCipherAttemptSuccess": { - "message": "Credentials saved successfully!", + "message": "Kredensial berhasil disimpan!", "description": "Notification message for when saving credentials has succeeded." }, "passwordSaved": { - "message": "Password saved!", + "message": "Kata sandi telah disimpan!", "description": "Notification message for when saving credentials has succeeded." }, "updateCipherAttemptSuccess": { - "message": "Credentials updated successfully!", + "message": "Kredensial berhasil diperbarui!", "description": "Notification message for when updating credentials has succeeded." }, "passwordUpdated": { - "message": "Password updated!", + "message": "Kata sandi telah diperbarui!", "description": "Notification message for when updating credentials has succeeded." }, "saveCipherAttemptFailed": { - "message": "Error saving credentials. Check console for details.", + "message": "Gagal menyimpan kredensial. Periksa konsol untuk rinciannya.", "description": "Notification message for when saving credentials has failed." }, "success": { - "message": "Success" + "message": "Berhasil" }, "removePasskey": { - "message": "Remove passkey" + "message": "Hapus kunci sandi" }, "passkeyRemoved": { - "message": "Passkey removed" + "message": "Kunci sandi dihapus" }, "autofillSuggestions": { - "message": "Autofill suggestions" + "message": "Saran isi otomatis" }, "autofillSuggestionsTip": { - "message": "Save a login item for this site to autofill" + "message": "Simpan benda login untuk situs ini ke isi otomatis" }, "yourVaultIsEmpty": { - "message": "Your vault is empty" + "message": "Brankas Anda kosong" }, "noItemsMatchSearch": { - "message": "No items match your search" + "message": "Tidak ada benda yang cocok dengan pencarian Anda" }, "clearFiltersOrTryAnother": { - "message": "Clear filters or try another search term" + "message": "Bersihkan penyaringan atau coba cari kata lainnya" }, "copyInfoTitle": { - "message": "Copy info - $ITEMNAME$", + "message": "Menyalin info - $ITEMNAME$", "description": "Title for a button that opens a menu with options to copy information from an item.", "placeholders": { "itemname": { @@ -4093,7 +4099,7 @@ } }, "copyNoteTitle": { - "message": "Copy Note - $ITEMNAME$", + "message": "Menyalin Catatan - $ITEMNAME$", "description": "Title for a button copies a note to the clipboard.", "placeholders": { "itemname": { @@ -4103,7 +4109,7 @@ } }, "moreOptionsLabel": { - "message": "More options, $ITEMNAME$", + "message": "Pilihan lainnya, $ITEMNAME$", "description": "Aria label for a button that opens a menu with more options for an item.", "placeholders": { "itemname": { @@ -4113,7 +4119,7 @@ } }, "moreOptionsTitle": { - "message": "More options - $ITEMNAME$", + "message": "Pilihan lainnya - $ITEMNAME$", "description": "Title for a button that opens a menu with more options for an item.", "placeholders": { "itemname": { @@ -4123,7 +4129,7 @@ } }, "viewItemTitle": { - "message": "View item - $ITEMNAME$", + "message": "LIhat benda - $ITEMNAME$", "description": "Title for a link that opens a view for an item.", "placeholders": { "itemname": { @@ -4133,7 +4139,7 @@ } }, "autofillTitle": { - "message": "Autofill - $ITEMNAME$", + "message": "Isi otomatis - $ITEMNAME$", "description": "Title for a button that autofills a login item.", "placeholders": { "itemname": { @@ -4143,40 +4149,40 @@ } }, "noValuesToCopy": { - "message": "No values to copy" + "message": "Tidak ada nilai untuk disalin" }, "assignToCollections": { - "message": "Assign to collections" + "message": "Menempatkan ke koleksi" }, "copyEmail": { - "message": "Copy email" + "message": "Salin surel" }, "copyPhone": { - "message": "Copy phone" + "message": "Salin nomor telepon" }, "copyAddress": { - "message": "Copy address" + "message": "Salin alamat" }, "adminConsole": { - "message": "Admin Console" + "message": "Konsol Admin" }, "accountSecurity": { - "message": "Account security" + "message": "Keamanan akun" }, "notifications": { - "message": "Notifications" + "message": "Pemberitahuan" }, "appearance": { - "message": "Appearance" + "message": "Tampilan" }, "errorAssigningTargetCollection": { - "message": "Error assigning target collection." + "message": "Gagal menetapkan ke koleksi yang dituju." }, "errorAssigningTargetFolder": { - "message": "Error assigning target folder." + "message": "Gagal menetapkan ke folder yang dituju." }, "viewItemsIn": { - "message": "View items in $NAME$", + "message": "Lihat benda-benda di $NAME$", "description": "Button to view the contents of a folder or collection", "placeholders": { "name": { @@ -4186,7 +4192,7 @@ } }, "backTo": { - "message": "Back to $NAME$", + "message": "Kembali ke $NAME$", "description": "Navigate back to a previous folder or collection", "placeholders": { "name": { @@ -4196,10 +4202,10 @@ } }, "new": { - "message": "New" + "message": "Baru" }, "removeItem": { - "message": "Remove $NAME$", + "message": "Buang $NAME$", "description": "Remove a selected option, such as a folder or collection", "placeholders": { "name": { @@ -4209,16 +4215,16 @@ } }, "itemsWithNoFolder": { - "message": "Items with no folder" + "message": "Benda-benda tanpa folder" }, "itemDetails": { - "message": "Item details" + "message": "Rincian benda" }, "itemName": { - "message": "Item name" + "message": "Nama benda" }, "cannotRemoveViewOnlyCollections": { - "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", + "message": "Anda tidak dapat menghapus koleksi dengan izin hanya lihat: $COLLECTIONS$", "placeholders": { "collections": { "content": "$1", @@ -4227,47 +4233,47 @@ } }, "organizationIsDeactivated": { - "message": "Organization is deactivated" + "message": "Organisasi dinonaktifkan" }, "owner": { - "message": "Owner" + "message": "Pemilik" }, "selfOwnershipLabel": { - "message": "You", + "message": "Anda", "description": "Used as a label to indicate that the user is the owner of an item." }, "contactYourOrgAdmin": { - "message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance." + "message": "Benda-benda di organisasi yang dinonaktifkan tidak dapat diakses. Hubungi pemilik organisasi Anda untuk mendapatkan panduan." }, "additionalInformation": { - "message": "Additional information" + "message": "Informasi tambahan" }, "itemHistory": { - "message": "Item history" + "message": "Riwayat benda" }, "lastEdited": { - "message": "Last edited" + "message": "Terakhir disunting" }, "ownerYou": { - "message": "Owner: You" + "message": "Pemilik: Anda" }, "linked": { - "message": "Linked" + "message": "Terkait" }, "copySuccessful": { - "message": "Copy Successful" + "message": "Berhasil Disalin" }, "upload": { - "message": "Upload" + "message": "Unggah" }, "addAttachment": { - "message": "Add attachment" + "message": "Tambahkan lampiran" }, "maxFileSizeSansPunctuation": { - "message": "Maximum file size is 500 MB" + "message": "Ukuran berkas maksimal adalah 500 MB" }, "deleteAttachmentName": { - "message": "Delete attachment $NAME$", + "message": "Hapus lampiran $NAME$", "placeholders": { "name": { "content": "$1", @@ -4276,7 +4282,7 @@ } }, "downloadAttachmentName": { - "message": "Download $NAME$", + "message": "Unduh $NAME$", "placeholders": { "name": { "content": "$1", @@ -4285,25 +4291,25 @@ } }, "permanentlyDeleteAttachmentConfirmation": { - "message": "Are you sure you want to permanently delete this attachment?" + "message": "Apakah Anda yakin ingin menghapus lampiran ini selamanya?" }, "premium": { "message": "Premium" }, "freeOrgsCannotUseAttachments": { - "message": "Free organizations cannot use attachments" + "message": "Organisasi gratis tidak dapat menggunakan lampiran" }, "filters": { - "message": "Filters" + "message": "Penyaring" }, "filterVault": { - "message": "Filter vault" + "message": "Penyaring brankas" }, "filterApplied": { - "message": "One filter applied" + "message": "Satu saringan diterapkan" }, "filterAppliedPlural": { - "message": "$COUNT$ filters applied", + "message": "$COUNT$ saringan diterapkan", "placeholders": { "count": { "content": "$1", @@ -4312,10 +4318,10 @@ } }, "personalDetails": { - "message": "Personal details" + "message": "Rincian pribadi" }, "identification": { - "message": "Identification" + "message": "Pengenalan" }, "contactInfo": { "message": "Contact info" diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index e11b793399b..ff2326809a8 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Inserisci il codice di verifica a 6 cifre dalla tua app di autenticazione." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Inserisci il codice di verifica a 6 cifre inviato a $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Visualizza tutte le opzioni di accesso" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Una notifica è stata inviata al tuo dispositivo." diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index d9908835c8f..fa12570fc50 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "認証アプリに表示された6桁の認証コードを入力してください。" }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "$EMAIL$に送信された6桁の認証コードを入力してください。", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "すべてのログインオプションを表示" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "デバイスに通知を送信しました。" diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 8b92a9ff340..ed909731d78 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 6f26673abcd..6aa17c1d7e3 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 6d15ddc9068..40fc910b088 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "ನಿಮ್ಮ ದೃಢೀಕರಣ ಅಪ್ಲಿಕೇಶನ್‌ನಿಂದ 6 ಅಂಕಿಯ ಪರಿಶೀಲನಾ ಕೋಡ್ ಅನ್ನು ನಮೂದಿಸಿ." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "$EMAIL$ಗೆ ಇಮೇಲ್ ಮಾಡಲಾದ 6 ಅಂಕಿಯ ಪರಿಶೀಲನಾ ಕೋಡ್ ಅನ್ನು ನಮೂದಿಸಿ.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 3318bd8df11..8c2c12ec65b 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Įvesk 6 skaitmenų patvirtinimo kodą iš tavo autentifikavimo aplikacijos." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Įvesk 6 skaitmenų prisijungimo kodą, kuris buvo išsiųstas $EMAIL$ el. paštu.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index cb0f299dcb7..e91d207ff20 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Jāievada 6 ciparu apstiprinājuma kods no autentificētāja lietotnes." }, + "authenticationTimeout": { + "message": "Autentificēšanās noildze" + }, + "authenticationSessionTimedOut": { + "message": "Iestājās autentificēšanās sesijas noildze. Lūgums sākt pieteikšanos no jauna." + }, "enterVerificationCodeEmail": { "message": "Jāievada 6 ciparu apstiprinājuma kods, kas tika nosūtīts uz $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Skatīt visas pieteikšanās iespējas" }, - "viewAllLoginOptions": { - "message": "Skatīt visas pieteikšanās iespējas" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Uz ierīci ir nosūtīts paziņojums." diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index f5a2e244fc5..e031dfcbcbd 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "നിങ്ങളുടെ ഓതന്റിക്കേറ്റർ അപ്ലിക്കേഷനിൽ നിന്ന് 6 അക്ക സ്ഥിരീകരണ കോഡ് നൽകുക." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "$EMAIL$ൽ ഇമെയിൽ ചെയ്ത 6 അക്ക സ്ഥിരീകരണ കോഡ് നൽകുക", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index bd7a1c755ec..dfb25015dc3 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 6f26673abcd..6aa17c1d7e3 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 027923d0509..0a14e176891 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Skriv inn den 6-sifrede verifiseringskoden som står på din autentiseringsapp." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Skriv inn den 6-sifrede verifiseringskoden som ble sendt til", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Vis alle innloggingsalternativer" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Et varsel er sendt til enheten din." diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 6f26673abcd..6aa17c1d7e3 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 6ed66273305..28cbea13382 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Voer de 6-cijferige verificatiecode uit je authenticatie-app in." }, + "authenticationTimeout": { + "message": "Authenticatie-timeout" + }, + "authenticationSessionTimedOut": { + "message": "De verificatiesessie is verlopen. Start het inlogproces opnieuw op." + }, "enterVerificationCodeEmail": { "message": "Voer de 6-cijferige verificatiecode in die via e-mail is verstuurd naar $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Alle inlogopties bekijken" }, - "viewAllLoginOptions": { - "message": "Alle loginopties bekijken" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Er is een melding naar je apparaat verzonden." diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 6f26673abcd..6aa17c1d7e3 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 6f26673abcd..6aa17c1d7e3 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index dc5b84eb6e5..a49b5c52e3b 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Wpisz 6-cyfrowy kod weryfikacyjny z aplikacji uwierzytelniającej." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Wpisz 6-cyfrowy kod weryfikacyjny, który został przesłany na adres $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Zobacz wszystkie sposoby logowania" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Powiadomienie zostało wysłane na urządzenie." diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index 79c87bbda09..0a34f5b9dd7 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Insira o código de verificação de 6 dígitos do seu aplicativo de autenticação." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Insira o código de verificação de 6 dígitos que foi enviado por e-mail para $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Ver todas as opções de login" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Uma notificação foi enviada para seu dispositivo." diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 46d0fee21c4..6c85d72e6bf 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -1269,7 +1269,7 @@ "message": "Pode adquirir uma subscrição Premium no cofre web em bitwarden.com. Pretende visitar o site agora?" }, "premiumPurchaseAlertV2": { - "message": "Pode adquirir o Premium a partir das definições da sua conta na aplicação Web do Bitwarden." + "message": "Pode adquirir o Premium a partir das definições da sua conta na aplicação Web Bitwarden." }, "premiumCurrentMember": { "message": "É um membro Premium!" @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Introduza o código de verificação de 6 dígitos da sua aplicação de autenticação." }, + "authenticationTimeout": { + "message": "Tempo limite de autenticação" + }, + "authenticationSessionTimedOut": { + "message": "A sessão de autenticação expirou. Por favor, reinicie o processo de início de sessão." + }, "enterVerificationCodeEmail": { "message": "Introduza o código de verificação de 6 dígitos que foi enviado por e-mail para $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Ver todas as opções de início de sessão" }, - "viewAllLoginOptions": { - "message": "Ver todas as opções de início de sessão" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Foi enviada uma notificação para o seu dispositivo." diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index c2f145087c5..680d22feeb6 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Introducere cod de verificare din 6 cifre din aplicația de autentificare." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Introducere cod de verificare din 6 cifre care a fost trimis prin e-mail la $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Afișați toate opțiunile de conectare" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "O notificare a fost trimisă pe dispozitivul dvs." diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 7eb6d55cf5b..526d77e009d 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Введите 6-значный код подтверждения из вашего приложения-аутентификатора." }, + "authenticationTimeout": { + "message": "Таймаут аутентификации" + }, + "authenticationSessionTimedOut": { + "message": "Сеанс аутентификации завершился по времени. Пожалуйста, попробуйте войти еще раз." + }, "enterVerificationCodeEmail": { "message": "Введите 6-значный код подтверждения, который был отправлен на $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Посмотреть все варианты авторизации" }, - "viewAllLoginOptions": { - "message": "Посмотреть все варианты авторизации" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "На ваше устройство отправлено уведомление." diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 42be772294c..9ada4ff9281 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "ඔබගේ සත්යාපන යෙදුමෙන් 6 ඉලක්කම් සත්යාපන කේතය ඇතුළත් කරන්න." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "$EMAIL$වෙත ඊමේල් කරන ලද 6 ඉලක්කම් සත්යාපන කේතය ඇතුළත් කරන්න.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 9a720ea2891..d1374c51cfe 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Zadajte 6-miestny verifikačný kód z vašej overovacej aplikácie." }, + "authenticationTimeout": { + "message": "Časový limit overenia" + }, + "authenticationSessionTimedOut": { + "message": "Relácia overovania skončila. Znovu spustite proces prihlásenia." + }, "enterVerificationCodeEmail": { "message": "Zadajte 6-miestny verifikačný kód, ktorý vám bol zaslaný emailom", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Zobraziť všetky možnosti prihlásenia" }, - "viewAllLoginOptions": { - "message": "Zobraziť všetky možnosti prihlásenia" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Do vášho zariadenia bolo odoslané upozornenie." diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index b5dadd4340f..d155e1b1a76 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Vnesite 6-mestno verifikacijsko kodo iz svoje aplikacije za avtentikacijo." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Vnesite 6-mestno verifikacijsko kodo, ki vam je bila poslana na $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index e7759897a81..f17bc4c97eb 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -1126,7 +1126,7 @@ "description": "WARNING (should stay in capitalized letters if the language permits)" }, "warningCapitalized": { - "message": "Warning", + "message": "Упозорење", "description": "Warning (should maintain locale-relevant capitalization)" }, "confirmVaultExport": { @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Унесите шестоцифрени верификациони код из апликације за утврђивање аутентичности." }, + "authenticationTimeout": { + "message": "Истекло је време аутентификације" + }, + "authenticationSessionTimedOut": { + "message": "Истекло је време сесије за аутентификацију. Молим вас покрените процес пријаве поново." + }, "enterVerificationCodeEmail": { "message": "Унесите шестоцифрени верификациони код који је послан на $EMAIL$.", "placeholders": { @@ -1768,7 +1774,7 @@ "message": "SSH кључ" }, "newItemHeader": { - "message": "New $TYPE$", + "message": "Нови $TYPE$", "placeholders": { "type": { "content": "$1", @@ -1813,7 +1819,7 @@ "message": "Колекције" }, "nCollections": { - "message": "$COUNT$ collections", + "message": "$COUNT$ колекција", "placeholders": { "count": { "content": "$1", @@ -1843,7 +1849,7 @@ "message": "Сигурносне белешке" }, "sshKeys": { - "message": "SSH Keys" + "message": "SSH Кључеви" }, "clear": { "message": "Очисти", @@ -2571,7 +2577,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInHours": { - "message": "The Send will be available to anyone with the link for the next $HOURS$ hours.", + "message": "Send ће бити доступан свакоме са везом у наредних $HOURS$ часова.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "hours": { @@ -2585,7 +2591,7 @@ "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "sendExpiresInDays": { - "message": "The Send will be available to anyone with the link for the next $DAYS$ days.", + "message": "Send ће бити доступан свакоме са везом у наредних $DAYS$ дана.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.", "placeholders": { "days": { @@ -2884,7 +2890,7 @@ "message": "Генеришите имејл" }, "spinboxBoundariesHint": { - "message": "Value must be between $MIN$ and $MAX$.", + "message": "Вредност мора бити између $MIN$ и $MAX$.", "description": "Explains spin box minimum and maximum values to the user", "placeholders": { "min": { @@ -2898,7 +2904,7 @@ } }, "passwordLengthRecommendationHint": { - "message": " Use $RECOMMENDED$ characters or more to generate a strong password.", + "message": " Употребити $RECOMMENDED$ знакова или више да бисте генерисали јаку лозинку.", "description": "Appended to `spinboxBoundariesHint` to recommend a length to the user. This must include any language-specific 'sentence' separator characters (e.g. a space in english).", "placeholders": { "recommended": { @@ -2908,7 +2914,7 @@ } }, "passphraseNumWordsRecommendationHint": { - "message": " Use $RECOMMENDED$ words or more to generate a strong passphrase.", + "message": " Употребити $RECOMMENDED$ речи или више да бисте генерисали јаку приступну фразу.", "description": "Appended to `spinboxBoundariesHint` to recommend a number of words to the user. This must include any language-specific 'sentence' separator characters (e.g. a space in english).", "placeholders": { "recommended": { @@ -3165,25 +3171,25 @@ "message": "Поново послати обавештење" }, "viewAllLogInOptions": { - "message": "View all log in options" - }, - "viewAllLoginOptions": { "message": "Погледајте сав извештај у опције" }, + "viewAllLoginOptionsV1": { + "message": "View all log in options" + }, "notificationSentDevice": { "message": "Обавештење је послато на ваш уређај." }, "aNotificationWasSentToYourDevice": { - "message": "A notification was sent to your device" + "message": "Обавештење је послато на ваш уређај" }, "makeSureYourAccountIsUnlockedAndTheFingerprintEtc": { - "message": "Make sure your account is unlocked and the fingerprint phrase matches on the other device" + "message": "Уверите се да је ваш налог откључан и да се фраза отиска подудара на другом уређају" }, "youWillBeNotifiedOnceTheRequestIsApproved": { - "message": "You will be notified once the request is approved" + "message": "Бићете обавештени када захтев буде одобрен" }, "needAnotherOptionV1": { - "message": "Need another option?" + "message": "Треба Вам друга опције?" }, "loginInitiated": { "message": "Пријава је покренута" @@ -3279,16 +3285,16 @@ "message": "Отвара се у новом прозору" }, "rememberThisDeviceToMakeFutureLoginsSeamless": { - "message": "Remember this device to make future logins seamless" + "message": "Запамтити овај уређај да би будуће пријаве биле беспрекорне" }, "deviceApprovalRequired": { "message": "Потребно је одобрење уређаја. Изаберите опцију одобрења испод:" }, "deviceApprovalRequiredV2": { - "message": "Device approval required" + "message": "Потребно је одобрење уређаја" }, "selectAnApprovalOptionBelow": { - "message": "Select an approval option below" + "message": "Изаберите опцију одобрења у наставку" }, "rememberThisDevice": { "message": "Запамти овај уређај" @@ -3364,7 +3370,7 @@ "message": "Недостаје имејл корисника" }, "activeUserEmailNotFoundLoggingYouOut": { - "message": "Active user email not found. Logging you out." + "message": "Имејл активног корисника није пронађен. Одјављивање." }, "deviceTrusted": { "message": "Уређај поуздан" @@ -3803,7 +3809,7 @@ "message": "Приступ" }, "loggedInExclamation": { - "message": "Logged in!" + "message": "Пријављено!" }, "passkeyNotCopied": { "message": "Приступни кључ неће бити копиран" @@ -4297,13 +4303,13 @@ "message": "Филтери" }, "filterVault": { - "message": "Filter vault" + "message": "Филтер сефа" }, "filterApplied": { - "message": "One filter applied" + "message": "Примењен је један филтер" }, "filterAppliedPlural": { - "message": "$COUNT$ filters applied", + "message": "Примењени су $COUNT$ филтера", "placeholders": { "count": { "content": "$1", @@ -4775,55 +4781,55 @@ "description": "Represents the % key in screen reader content as a readable word" }, "caretCharacterDescriptor": { - "message": "Caret", + "message": "Знак за уметање", "description": "Represents the ^ key in screen reader content as a readable word" }, "ampersandCharacterDescriptor": { - "message": "Ampersand", + "message": "Знак Ampersand", "description": "Represents the & key in screen reader content as a readable word" }, "asteriskCharacterDescriptor": { - "message": "Asterisk", + "message": "Знак звездица", "description": "Represents the * key in screen reader content as a readable word" }, "parenLeftCharacterDescriptor": { - "message": "Left parenthesis", + "message": "Отворена заграда", "description": "Represents the ( key in screen reader content as a readable word" }, "parenRightCharacterDescriptor": { - "message": "Right parenthesis", + "message": "Затворена заграда", "description": "Represents the ) key in screen reader content as a readable word" }, "hyphenCharacterDescriptor": { - "message": "Underscore", + "message": "Доња црта", "description": "Represents the _ key in screen reader content as a readable word" }, "underscoreCharacterDescriptor": { - "message": "Hyphen", + "message": "Цртица", "description": "Represents the - key in screen reader content as a readable word" }, "plusCharacterDescriptor": { - "message": "Plus", + "message": "Плус", "description": "Represents the + key in screen reader content as a readable word" }, "equalsCharacterDescriptor": { - "message": "Equals", + "message": "Једнако", "description": "Represents the = key in screen reader content as a readable word" }, "braceLeftCharacterDescriptor": { - "message": "Left brace", + "message": "Лева велика заграда", "description": "Represents the { key in screen reader content as a readable word" }, "braceRightCharacterDescriptor": { - "message": "Right brace", + "message": "Десна велика заграда", "description": "Represents the } key in screen reader content as a readable word" }, "bracketLeftCharacterDescriptor": { - "message": "Left bracket", + "message": "Лева заграда", "description": "Represents the [ key in screen reader content as a readable word" }, "bracketRightCharacterDescriptor": { - "message": "Right bracket", + "message": "Десна заграда", "description": "Represents the ] key in screen reader content as a readable word" }, "pipeCharacterDescriptor": { @@ -4831,71 +4837,71 @@ "description": "Represents the | key in screen reader content as a readable word" }, "backSlashCharacterDescriptor": { - "message": "Back slash", + "message": "Задња коса црта", "description": "Represents the back slash key in screen reader content as a readable word" }, "colonCharacterDescriptor": { - "message": "Colon", + "message": "Две тачке", "description": "Represents the : key in screen reader content as a readable word" }, "semicolonCharacterDescriptor": { - "message": "Semicolon", + "message": "Тачка-запета", "description": "Represents the ; key in screen reader content as a readable word" }, "doubleQuoteCharacterDescriptor": { - "message": "Double quote", + "message": "Двоструки наводници", "description": "Represents the double quote key in screen reader content as a readable word" }, "singleQuoteCharacterDescriptor": { - "message": "Single quote", + "message": "Један наводник", "description": "Represents the ' key in screen reader content as a readable word" }, "lessThanCharacterDescriptor": { - "message": "Less than", + "message": "Мање од", "description": "Represents the < key in screen reader content as a readable word" }, "greaterThanCharacterDescriptor": { - "message": "Greater than", + "message": "Веће од", "description": "Represents the > key in screen reader content as a readable word" }, "commaCharacterDescriptor": { - "message": "Comma", + "message": "Зарез", "description": "Represents the , key in screen reader content as a readable word" }, "periodCharacterDescriptor": { - "message": "Period", + "message": "Тачка", "description": "Represents the . key in screen reader content as a readable word" }, "questionCharacterDescriptor": { - "message": "Question mark", + "message": "Упитник", "description": "Represents the ? key in screen reader content as a readable word" }, "forwardSlashCharacterDescriptor": { - "message": "Forward slash", + "message": "Коса црта", "description": "Represents the / key in screen reader content as a readable word" }, "lowercaseAriaLabel": { - "message": "Lowercase" + "message": "Мала слова" }, "uppercaseAriaLabel": { - "message": "Uppercase" + "message": "Велика слова" }, "generatedPassword": { - "message": "Generated password" + "message": "Генерисана лозинка" }, "compactMode": { - "message": "Compact mode" + "message": "Компактни режим" }, "beta": { - "message": "Beta" + "message": "Бета" }, "extensionWidth": { - "message": "Extension width" + "message": "Ширина додатка" }, "wide": { - "message": "Wide" + "message": "Широко" }, "extraWide": { - "message": "Extra wide" + "message": "Врло широко" } } diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 2bbf1e133e7..dc8c1bed901 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Ange den 6-siffriga verifieringskoden från din autentiseringsapp." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Ange den 6-siffriga verifieringskoden som skickades till $EMAIL$.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Visa alla inloggningsalternativ" }, - "viewAllLoginOptions": { - "message": "Visa alla inloggningsalternativ" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "En avisering har skickats till din enhet." diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 6f26673abcd..6aa17c1d7e3 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 76d3e0cf540..517ab17a489 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Enter the 6 digit verification code from your authenticator app." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Enter the 6 digit verification code that was emailed to $EMAIL$.", "placeholders": { @@ -3167,7 +3173,7 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { + "viewAllLoginOptionsV1": { "message": "View all log in options" }, "notificationSentDevice": { diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 1d54e9865b8..496ce4eac76 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Kimlik doğrulama uygulamanızdaki 6 haneli doğrulama kodunu girin." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "$EMAIL$ adresine e-postayla gönderdiğimiz 6 haneli doğrulama kodunu girin.", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Tüm giriş seçeneklerini gör" }, - "viewAllLoginOptions": { - "message": "Tüm giriş seçeneklerini gör" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Cihazınıza bir bildirim gönderildi." diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 3ffc1f375fd..d9a1c11bed5 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -1126,7 +1126,7 @@ "description": "WARNING (should stay in capitalized letters if the language permits)" }, "warningCapitalized": { - "message": "Warning", + "message": "Попередження", "description": "Warning (should maintain locale-relevant capitalization)" }, "confirmVaultExport": { @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Введіть 6-значний код підтвердження з програми автентифікації." }, + "authenticationTimeout": { + "message": "Час очікування автентифікації" + }, + "authenticationSessionTimedOut": { + "message": "Час очікування сеансу автентифікації завершився. Перезапустіть процес входу в систему." + }, "enterVerificationCodeEmail": { "message": "Введіть 6-значний код підтвердження, надісланий на $EMAIL$.", "placeholders": { @@ -1843,7 +1849,7 @@ "message": "Захищені нотатки" }, "sshKeys": { - "message": "SSH Keys" + "message": "Ключі SSH" }, "clear": { "message": "Стерти", @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "Переглянути всі варіанти входу" }, - "viewAllLoginOptions": { - "message": "Переглянути всі варіанти входу" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Сповіщення було надіслано на ваш пристрій." @@ -3279,16 +3285,16 @@ "message": "Відкривається у новому вікні" }, "rememberThisDeviceToMakeFutureLoginsSeamless": { - "message": "Remember this device to make future logins seamless" + "message": "Запам'ятайте цей пристрій, щоб спростити майбутні входи в систему" }, "deviceApprovalRequired": { "message": "Необхідне підтвердження пристрою. Виберіть варіант підтвердження нижче:" }, "deviceApprovalRequiredV2": { - "message": "Device approval required" + "message": "Потрібне підтвердження пристрою" }, "selectAnApprovalOptionBelow": { - "message": "Select an approval option below" + "message": "Виберіть варіант підтвердження нижче" }, "rememberThisDevice": { "message": "Запам'ятати цей пристрій" @@ -3364,7 +3370,7 @@ "message": "Немає адреси електронної пошти" }, "activeUserEmailNotFoundLoggingYouOut": { - "message": "Active user email not found. Logging you out." + "message": "Адресу е-пошти активного користувача не знайдено. Виконується вихід із системи." }, "deviceTrusted": { "message": "Довірений пристрій" @@ -3803,7 +3809,7 @@ "message": "Доступ" }, "loggedInExclamation": { - "message": "Logged in!" + "message": "Ви увійшли!" }, "passkeyNotCopied": { "message": "Ключ доступу не буде скопійовано" @@ -4890,12 +4896,12 @@ "message": "Бета" }, "extensionWidth": { - "message": "Extension width" + "message": "Ширина вікна розширення" }, "wide": { - "message": "Wide" + "message": "Широке" }, "extraWide": { - "message": "Extra wide" + "message": "Дуже широке" } } diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 88adbc3a53b..317a2599be1 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "Nhập mã xác nhận 6 chữ số từ ứng dụng xác thực của bạn." }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "Nhập mã xác nhận 6 chữ số đã được gửi tới email", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "Xem tất cả tùy chọn đăng nhập" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "Một thông báo đã được gửi đến thiết bị của bạn." diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 05b71990c1d..fb7f7dd7bdc 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "请输入您的验证器 App 中的 6 位数验证码。" }, + "authenticationTimeout": { + "message": "身份验证超时" + }, + "authenticationSessionTimedOut": { + "message": "身份验证会话超时。请重新启动登录过程。" + }, "enterVerificationCodeEmail": { "message": "请输入发送给电子邮件 $EMAIL$ 的 6 位数验证码。", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "查看所有登录选项" }, - "viewAllLoginOptions": { - "message": "查看所有登录选项" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "通知已发送到您的设备。" diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index fd71e2de563..9d51dfedb53 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -1319,6 +1319,12 @@ "enterVerificationCodeApp": { "message": "輸入驗證器應用程式提供的 6 位數驗證碼。" }, + "authenticationTimeout": { + "message": "Authentication timeout" + }, + "authenticationSessionTimedOut": { + "message": "The authentication session timed out. Please restart the login process." + }, "enterVerificationCodeEmail": { "message": "輸入已傳送至 $EMAIL$ 的 6 位數驗證碼。", "placeholders": { @@ -3167,8 +3173,8 @@ "viewAllLogInOptions": { "message": "View all log in options" }, - "viewAllLoginOptions": { - "message": "檢視所有登入選項" + "viewAllLoginOptionsV1": { + "message": "View all log in options" }, "notificationSentDevice": { "message": "已傳送通知至您的裝置。" diff --git a/apps/browser/src/auth/popup/login-via-auth-request-v1.component.html b/apps/browser/src/auth/popup/login-via-auth-request-v1.component.html index 2abff7bdb9c..e7fafbb252c 100644 --- a/apps/browser/src/auth/popup/login-via-auth-request-v1.component.html +++ b/apps/browser/src/auth/popup/login-via-auth-request-v1.component.html @@ -30,7 +30,7 @@ @@ -53,7 +53,7 @@ diff --git a/apps/browser/src/auth/popup/login/extension-sso-component.service.spec.ts b/apps/browser/src/auth/popup/login/extension-sso-component.service.spec.ts new file mode 100644 index 00000000000..7d64c4114c0 --- /dev/null +++ b/apps/browser/src/auth/popup/login/extension-sso-component.service.spec.ts @@ -0,0 +1,67 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { + EnvironmentService, + Environment, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; + +import { BrowserApi } from "../../../platform/browser/browser-api"; + +import { ExtensionSsoComponentService } from "./extension-sso-component.service"; + +describe("ExtensionSsoComponentService", () => { + let service: ExtensionSsoComponentService; + const baseUrl = "https://vault.bitwarden.com"; + + let syncService: MockProxy; + let authService: MockProxy; + let environmentService: MockProxy; + let i18nService: MockProxy; + let logService: MockProxy; + + beforeEach(() => { + syncService = mock(); + authService = mock(); + environmentService = mock(); + i18nService = mock(); + logService = mock(); + environmentService.environment$ = new BehaviorSubject({ + getWebVaultUrl: () => baseUrl, + } as Environment); + + TestBed.configureTestingModule({ + providers: [ + { provide: SyncService, useValue: syncService }, + { provide: AuthService, useValue: authService }, + { provide: EnvironmentService, useValue: environmentService }, + { provide: I18nService, useValue: i18nService }, + { provide: LogService, useValue: logService }, + ExtensionSsoComponentService, + ], + }); + + service = TestBed.inject(ExtensionSsoComponentService); + + jest.spyOn(BrowserApi, "reloadOpenWindows").mockImplementation(); + }); + + it("creates the service", () => { + expect(service).toBeTruthy(); + }); + + describe("closeWindow", () => { + it("closes window", async () => { + const windowSpy = jest.spyOn(window, "close").mockImplementation(); + + await service.closeWindow?.(); + + expect(windowSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/auth/popup/login/extension-sso-component.service.ts b/apps/browser/src/auth/popup/login/extension-sso-component.service.ts new file mode 100644 index 00000000000..3ddc7c67f7c --- /dev/null +++ b/apps/browser/src/auth/popup/login/extension-sso-component.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from "@angular/core"; + +import { DefaultSsoComponentService, SsoComponentService } from "@bitwarden/auth/angular"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; + +/** + * This service is used to handle the SSO login process for the browser extension. + */ +@Injectable() +export class ExtensionSsoComponentService + extends DefaultSsoComponentService + implements SsoComponentService +{ + constructor( + protected syncService: SyncService, + protected authService: AuthService, + protected environmentService: EnvironmentService, + protected i18nService: I18nService, + protected logService: LogService, + ) { + super(); + } + + /** + * Closes the popup window after a successful login. + */ + async closeWindow() { + window.close(); + } +} diff --git a/apps/browser/src/auth/popup/sso.component.html b/apps/browser/src/auth/popup/sso-v1.component.html similarity index 100% rename from apps/browser/src/auth/popup/sso.component.html rename to apps/browser/src/auth/popup/sso-v1.component.html diff --git a/apps/browser/src/auth/popup/sso.component.ts b/apps/browser/src/auth/popup/sso-v1.component.ts similarity index 97% rename from apps/browser/src/auth/popup/sso.component.ts rename to apps/browser/src/auth/popup/sso-v1.component.ts index 988563c2fe6..ecb743848c7 100644 --- a/apps/browser/src/auth/popup/sso.component.ts +++ b/apps/browser/src/auth/popup/sso-v1.component.ts @@ -29,9 +29,9 @@ import { BrowserApi } from "../../platform/browser/browser-api"; @Component({ selector: "app-sso", - templateUrl: "sso.component.html", + templateUrl: "sso-v1.component.html", }) -export class SsoComponent extends BaseSsoComponent { +export class SsoComponentV1 extends BaseSsoComponent { constructor( ssoLoginService: SsoLoginServiceAbstraction, loginStrategyService: LoginStrategyServiceAbstraction, diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index a0cdfb3cebf..03284f3fd89 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -160,6 +160,9 @@ export type InlineMenuCipherData = { icon: WebsiteIconData; accountCreationFieldType?: string; login?: { + totp?: string; + totpField?: boolean; + totpCodeTimeInterval?: number; username: string; passkey: { rpName: string; @@ -262,6 +265,7 @@ export type InlineMenuListPortMessageHandlers = { updateAutofillInlineMenuListHeight: ({ message, port }: PortOnMessageHandlerParams) => void; refreshGeneratedPassword: () => Promise; fillGeneratedPassword: ({ port }: PortConnectionParam) => Promise; + refreshOverlayCiphers: () => Promise; }; export interface OverlayBackground { diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 6ec3c0a9b5a..e7b72b72c9b 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -928,6 +928,7 @@ describe("OverlayBackground", () => { login: { username: "username-1", passkey: null, + totpField: false, }, name: "name-1", reprompt: loginCipher1.reprompt, @@ -1065,6 +1066,7 @@ describe("OverlayBackground", () => { login: { username: loginCipher1.login.username, passkey: null, + totpField: false, }, name: loginCipher1.name, reprompt: loginCipher1.reprompt, @@ -1189,6 +1191,7 @@ describe("OverlayBackground", () => { rpName: passkeyCipher.login.fido2Credentials[0].rpName, userName: passkeyCipher.login.fido2Credentials[0].userName, }, + totpField: false, }, }, { @@ -1207,6 +1210,7 @@ describe("OverlayBackground", () => { login: { username: passkeyCipher.login.username, passkey: null, + totpField: false, }, }, { @@ -1225,6 +1229,7 @@ describe("OverlayBackground", () => { login: { username: loginCipher1.login.username, passkey: null, + totpField: false, }, }, ], @@ -1272,6 +1277,7 @@ describe("OverlayBackground", () => { login: { username: passkeyCipher.login.username, passkey: null, + totpField: false, }, }, { @@ -1290,6 +1296,7 @@ describe("OverlayBackground", () => { login: { username: loginCipher1.login.username, passkey: null, + totpField: false, }, }, ], @@ -1337,6 +1344,7 @@ describe("OverlayBackground", () => { login: { username: passkeyCipher.login.username, passkey: null, + totpField: false, }, }, { @@ -1355,6 +1363,7 @@ describe("OverlayBackground", () => { login: { username: loginCipher1.login.username, passkey: null, + totpField: false, }, }, ], @@ -1400,6 +1409,7 @@ describe("OverlayBackground", () => { login: { username: loginCipher1.login.username, passkey: null, + totpField: false, }, }, { @@ -1418,6 +1428,7 @@ describe("OverlayBackground", () => { login: { username: loginCipher2.login.username, passkey: null, + totpField: false, }, }, ], diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index aaeeea857b3..fd16bfcf16a 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -204,6 +204,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { updateAutofillInlineMenuListHeight: ({ message }) => this.updateInlineMenuListHeight(message), refreshGeneratedPassword: () => this.updateGeneratedPassword(true), fillGeneratedPassword: ({ port }) => this.fillGeneratedPassword(port), + refreshOverlayCiphers: () => this.updateOverlayCiphers(false), }; constructor( @@ -464,7 +465,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { this.showPasskeysLabelsWithinInlineMenu = false; if (this.shouldShowInlineMenuAccountCreation()) { - inlineMenuCipherData = this.buildInlineMenuAccountCreationCiphers( + inlineMenuCipherData = await this.buildInlineMenuAccountCreationCiphers( inlineMenuCiphersArray, true, ); @@ -485,7 +486,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param inlineMenuCiphersArray - Array of inline menu ciphers * @param showFavicons - Identifies whether favicons should be shown */ - private buildInlineMenuAccountCreationCiphers( + private async buildInlineMenuAccountCreationCiphers( inlineMenuCiphersArray: [string, CipherView][], showFavicons: boolean, ) { @@ -497,7 +498,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (cipher.type === CipherType.Login) { accountCreationLoginCiphers.push( - this.buildCipherData({ + await this.buildCipherData({ inlineMenuCipherId, cipher, showFavicons, @@ -517,7 +518,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { } inlineMenuCipherData.push( - this.buildCipherData({ + await this.buildCipherData({ inlineMenuCipherId, cipher, showFavicons, @@ -561,13 +562,13 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (!passkeysEnabled || !(await this.showCipherAsPasskey(cipher, domainExclusionsSet))) { inlineMenuCipherData.push( - this.buildCipherData({ inlineMenuCipherId, cipher, showFavicons }), + await this.buildCipherData({ inlineMenuCipherId, cipher, showFavicons }), ); continue; } passkeyCipherData.push( - this.buildCipherData({ + await this.buildCipherData({ inlineMenuCipherId, cipher, showFavicons, @@ -577,7 +578,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (cipher.login?.password && cipher.login.username) { inlineMenuCipherData.push( - this.buildCipherData({ inlineMenuCipherId, cipher, showFavicons }), + await this.buildCipherData({ inlineMenuCipherId, cipher, showFavicons }), ); } } @@ -620,6 +621,23 @@ export class OverlayBackground implements OverlayBackgroundInterface { return this.inlineMenuFido2Credentials.has(credentialId); } + private isTotpFieldForCurrentField(): boolean { + if (!this.focusedFieldData) { + return false; + } + const { tabId, frameId } = this.focusedFieldData; + const pageDetailsMap = this.pageDetailsForTab[tabId]; + if (!pageDetailsMap || !pageDetailsMap.has(frameId)) { + return false; + } + const pageDetail = pageDetailsMap.get(frameId); + return ( + pageDetail?.details?.fields?.every((field) => + this.inlineMenuFieldQualificationService.isTotpField(field), + ) || false + ); + } + /** * Builds the cipher data for the inline menu list. * @@ -630,14 +648,14 @@ export class OverlayBackground implements OverlayBackgroundInterface { * @param hasPasskey - Identifies whether the cipher has a FIDO2 credential * @param identityData - Pre-created identity data */ - private buildCipherData({ + private async buildCipherData({ inlineMenuCipherId, cipher, showFavicons, showInlineMenuAccountCreation, hasPasskey, identityData, - }: BuildCipherDataParams): InlineMenuCipherData { + }: BuildCipherDataParams): Promise { const inlineMenuData: InlineMenuCipherData = { id: inlineMenuCipherId, name: cipher.name, @@ -649,8 +667,13 @@ export class OverlayBackground implements OverlayBackgroundInterface { }; if (cipher.type === CipherType.Login) { + const totpCode = await this.totpService.getCode(cipher.login?.totp); + const totpCodeTimeInterval = this.totpService.getTimeInterval(cipher.login?.totp); inlineMenuData.login = { username: cipher.login.username, + totp: totpCode, + totpField: this.isTotpFieldForCurrentField(), + totpCodeTimeInterval: totpCodeTimeInterval, passkey: hasPasskey ? { rpName: cipher.login.fido2Credentials[0].rpName, @@ -1980,35 +2003,39 @@ export class OverlayBackground implements OverlayBackgroundInterface { private getInlineMenuTranslations() { if (!this.inlineMenuPageTranslations) { const translationKeys = [ + "addNewCardItemAria", + "addNewIdentityItemAria", + "addNewLoginItemAria", + "addNewVaultItem", + "authenticating", + "cardNumberEndsWith", + "fillCredentialsFor", + "fillGeneratedPassword", + "fillVerificationCode", + "fillVerificationCodeAria", + "generatedPassword", + "lowercaseAriaLabel", + "logInWithPasskeyAriaLabel", + "newCard", + "newIdentity", + "newItem", + "newLogin", + "noItemsToShow", "opensInANewWindow", + "passkeys", + "passwordRegenerated", + "passwords", + "regeneratePassword", + "saveLoginToBitwarden", "toggleBitwardenVaultOverlay", - "unlockYourAccountToViewAutofillSuggestions", + "totpCodeAria", + "totpSecondsSpanAria", "unlockAccount", "unlockAccountAria", - "fillCredentialsFor", + "unlockYourAccountToViewAutofillSuggestions", + "uppercaseAriaLabel", "username", "view", - "noItemsToShow", - "newItem", - "addNewVaultItem", - "newLogin", - "addNewLoginItemAria", - "newCard", - "addNewCardItemAria", - "newIdentity", - "addNewIdentityItemAria", - "cardNumberEndsWith", - "passkeys", - "passwords", - "logInWithPasskeyAriaLabel", - "authenticating", - "fillGeneratedPassword", - "regeneratePassword", - "passwordRegenerated", - "saveLoginToBitwarden", - "lowercaseAriaLabel", - "uppercaseAriaLabel", - "generatedPassword", ...Object.values(specialCharacterToKeyMap), ]; this.inlineMenuPageTranslations = translationKeys.reduce( diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap index d920820b0e0..785cadb5510 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap @@ -681,10 +681,121 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f class="cipher-container" > + + + +
  • +
    + + +
    +
  • + + +`; + +exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user creates the view for a totp field 1`] = ` +
    +
      +
    • +
      + + +
      +
    • +
    • +
      + + +
      +
    • +
    • +
      + + +
      +
    • +
    • +
      + + +
      +
    • +
    • +
      + - + (); protected readonly SubscriptionProduct = SubscriptionProduct; protected readonly ProductType = ProductType; + protected trialPaymentOptional$ = this.configService.getFeatureFlag$( + FeatureFlag.TrialPaymentOptional, + ); constructor( protected router: Router, @@ -90,6 +105,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { private registrationFinishService: RegistrationFinishService, private validationService: ValidationService, private loginStrategyService: LoginStrategyServiceAbstraction, + private configService: ConfigService, ) {} async ngOnInit(): Promise { @@ -119,6 +135,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { this.product = this.validProducts.includes(product) ? product : ProductType.PasswordManager; const productTierParam = parseInt(qParams.productTier) as ProductTierType; + this.productTierValue = productTierParam; /** Only show the trial stepper for a subset of types */ const showPasswordManagerStepper = this.stepperProductTypes.includes(productTierParam); @@ -185,6 +202,16 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { } } + async orgNameEntrySubmit(): Promise { + const isTrialPaymentOptional = await firstValueFrom(this.trialPaymentOptional$); + + if (isTrialPaymentOptional) { + await this.createOrganizationOnTrial(); + } else { + await this.conditionallyCreateOrganization(); + } + } + /** Update local details from organization created event */ createdOrganization(event: OrganizationCreatedEvent) { this.orgId = event.organizationId; @@ -192,11 +219,62 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy { this.verticalStepper.next(); } + /** create an organization on trial without payment method */ + async createOrganizationOnTrial() { + this.loading = true; + let trialInitiationPath: InitiationPath = "Password Manager trial from marketing website"; + let plan: PlanInformation = { + type: this.getPlanType(), + passwordManagerSeats: 1, + }; + + if (this.product === ProductType.SecretsManager) { + trialInitiationPath = "Secrets Manager trial from marketing website"; + plan = { + ...plan, + subscribeToSecretsManager: true, + isFromSecretsManagerTrial: true, + secretsManagerSeats: 1, + }; + } + + const organization: OrganizationInformation = { + name: this.orgInfoFormGroup.value.name, + billingEmail: this.orgInfoFormGroup.value.billingEmail, + initiationPath: trialInitiationPath, + }; + + const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({ + organization, + plan, + }); + + this.orgId = response?.id; + this.billingSubLabel = response.name.toString(); + this.loading = false; + this.verticalStepper.next(); + } + /** Move the user to the previous step */ previousStep() { this.verticalStepper.previous(); } + getPlanType() { + switch (this.productTier) { + case ProductTierType.Teams: + return PlanType.TeamsAnnually; + case ProductTierType.Enterprise: + return PlanType.EnterpriseAnnually; + case ProductTierType.Families: + return PlanType.FamiliesAnnually; + case ProductTierType.Free: + return PlanType.Free; + default: + return PlanType.EnterpriseAnnually; + } + } + get isSecretsManagerFree() { return this.product === ProductType.SecretsManager && this.productTier === ProductTierType.Free; } diff --git a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html index 878672a1fb9..93751f0ef72 100644 --- a/apps/web/src/app/billing/organizations/change-plan-dialog.component.html +++ b/apps/web/src/app/billing/organizations/change-plan-dialog.component.html @@ -8,7 +8,7 @@
      {{ "selectAPlan" | i18n }} -
      +
      { this.policies = policies; @@ -451,10 +454,20 @@ export class EventService { msg = humanReadableMsg = this.i18nService.t("removedDomain", ev.domainName); break; case EventType.OrganizationDomain_Verified: - msg = humanReadableMsg = this.i18nService.t("domainVerifiedEvent", ev.domainName); + msg = humanReadableMsg = this.i18nService.t( + (await this.configService.getFeatureFlag(FeatureFlag.AccountDeprovisioning)) + ? "domainClaimedEvent" + : "domainVerifiedEvent", + ev.domainName, + ); break; case EventType.OrganizationDomain_NotVerified: - msg = humanReadableMsg = this.i18nService.t("domainNotVerifiedEvent", ev.domainName); + msg = humanReadableMsg = this.i18nService.t( + (await this.configService.getFeatureFlag(FeatureFlag.AccountDeprovisioning)) + ? "domainNotClaimedEvent" + : "domainNotVerifiedEvent", + ev.domainName, + ); break; // Secrets Manager case EventType.Secret_Retrieved: diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index c6a24608f06..b5708fcf92f 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -28,11 +28,13 @@ import { LockIcon, TwoFactorTimeoutIcon, UserLockIcon, + SsoKeyIcon, LoginViaAuthRequestComponent, DevicesIcon, RegistrationUserAddIcon, RegistrationLockAltIcon, RegistrationExpiredLinkIcon, + SsoComponent, VaultIcon, LoginDecryptionOptionsComponent, } from "@bitwarden/auth/angular"; @@ -61,7 +63,7 @@ import { AccountComponent } from "./auth/settings/account/account.component"; import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emergency-access.component"; import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component"; import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module"; -import { SsoComponent } from "./auth/sso.component"; +import { SsoComponentV1 } from "./auth/sso-v1.component"; import { CompleteTrialInitiationComponent } from "./auth/trial-initiation/complete-trial-initiation/complete-trial-initiation.component"; import { freeTrialTextResolver } from "./auth/trial-initiation/complete-trial-initiation/resolver/free-trial-text.resolver"; import { TrialInitiationComponent } from "./auth/trial-initiation/trial-initiation.component"; @@ -430,27 +432,57 @@ const routes: Routes = [ }, ], }, - { - path: "sso", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "enterpriseSingleSignOn", - }, - titleId: "enterpriseSingleSignOn", - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [ - { - path: "", - component: SsoComponent, - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, + ...unauthUiRefreshSwap( + SsoComponentV1, + SsoComponent, + { + path: "sso", + canActivate: [unauthGuardFn()], + data: { + pageTitle: { + key: "enterpriseSingleSignOn", + }, + titleId: "enterpriseSingleSignOn", + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [ + { + path: "", + component: SsoComponentV1, + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "sso", + canActivate: [unauthGuardFn()], + data: { + pageTitle: { + key: "singleSignOn", + }, + titleId: "enterpriseSingleSignOn", + pageSubtitle: { + key: "singleSignOnEnterOrgIdentifierText", + }, + titleAreaMaxWidth: "md", + pageIcon: SsoKeyIcon, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [ + { + path: "", + component: SsoComponent, + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + ), { path: "login", canActivate: [unauthGuardFn()], diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 15f15e2e317..3176ac81c1a 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -50,7 +50,7 @@ import { TwoFactorSetupYubiKeyComponent } from "../auth/settings/two-factor/two- import { TwoFactorSetupComponent } from "../auth/settings/two-factor/two-factor-setup.component"; import { TwoFactorVerifyComponent } from "../auth/settings/two-factor/two-factor-verify.component"; import { UserVerificationModule } from "../auth/shared/components/user-verification"; -import { SsoComponent } from "../auth/sso.component"; +import { SsoComponentV1 } from "../auth/sso-v1.component"; import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component"; import { TwoFactorComponent } from "../auth/two-factor.component"; import { UpdatePasswordComponent } from "../auth/update-password.component"; @@ -158,7 +158,7 @@ import { SharedModule } from "./shared.module"; SetPasswordComponent, SponsoredFamiliesComponent, SponsoringOrgRowComponent, - SsoComponent, + SsoComponentV1, TwoFactorSetupAuthenticatorComponent, TwoFactorComponent, TwoFactorSetupDuoComponent, @@ -225,7 +225,7 @@ import { SharedModule } from "./shared.module"; SetPasswordComponent, SponsoredFamiliesComponent, SponsoringOrgRowComponent, - SsoComponent, + SsoComponentV1, TwoFactorSetupAuthenticatorComponent, TwoFactorComponent, TwoFactorSetupDuoComponent, diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index 7b855b470a6..7038ffb898a 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -21,7 +21,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; @@ -69,7 +68,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On organizationService: OrganizationService, logService: LogService, passwordRepromptService: PasswordRepromptService, - sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, configService: ConfigService, @@ -90,7 +88,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On logService, passwordRepromptService, organizationService, - sendApiService, dialogService, window, datePipe, diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index c3b0d0cb0b1..135db7f46f4 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -16,7 +16,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; 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"; @@ -55,7 +54,6 @@ export class AddEditComponent extends BaseAddEditComponent { logService: LogService, passwordRepromptService: PasswordRepromptService, organizationService: OrganizationService, - sendApiService: SendApiService, dialogService: DialogService, datePipe: DatePipe, configService: ConfigService, @@ -78,7 +76,6 @@ export class AddEditComponent extends BaseAddEditComponent { organizationService, logService, passwordRepromptService, - sendApiService, dialogService, datePipe, configService, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 06728929912..5e86e13f6ce 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4739,6 +4739,12 @@ "ssoLogInWithOrgIdentifier": { "message": "Log in using your organization's single sign-on portal. Please enter your organization's SSO identifier to begin." }, + "singleSignOnEnterOrgIdentifier": { + "message": "Enter your organization's SSO identifier to begin" + }, + "singleSignOnEnterOrgIdentifierText": { + "message": "To log in with your SSO provider, enter your organization's SSO identifier to begin. You may need to enter this SSO identifier when you log in from a new device." + }, "enterpriseSingleSignOn": { "message": "Enterprise single sign-on" }, @@ -6156,9 +6162,6 @@ "emailSent": { "message": "Email sent" }, - "revokeSponsorshipConfirmation": { - "message": "After removing this account, the Families plan sponsorship will expire at the end of the billing period. You will not be able to redeem a new sponsorship offer until the existing one expires. Are you sure you want to continue?" - }, "removeSponsorshipSuccess": { "message": "Sponsorship removed" }, @@ -9801,8 +9804,8 @@ "selfHostingTitleProper": { "message": "Self-Hosting" }, - "verified-domain-single-org-warning" : { - "message": "Verifying a domain will turn on the single organization policy." + "claim-domain-single-org-warning" : { + "message": "Claiming a domain will turn on the single organization policy." }, "single-org-revoked-user-warning": { "message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations." @@ -9902,5 +9905,87 @@ }, "removeMembers": { "message": "Remove members" + }, + "claimedDomains": { + "message": "Claimed domains" + }, + "claimDomain": { + "message": "Claim domain" + }, + "reclaimDomain": { + "message": "Reclaim domain" + }, + "claimDomainNameInputHint": { + "message": "Example: mydomain.com. Subdomains require separate entries to be claimed." + }, + "automaticClaimedDomains": { + "message": "Automatic Claimed Domains" + }, + "automaticDomainClaimProcess": { + "message": "Bitwarden will attempt to claim the domain 3 times during the first 72 hours. If the domain can’t be claimed, check the DNS record in your host and manually claim. The domain will be removed from your organization in 7 days if it is not claimed." + }, + "domainNotClaimed": { + "message": "$DOMAIN$ not claimed. Check your DNS records.", + "placeholders": { + "DOMAIN": { + "content": "$1", + "example": "bitwarden.com" + } + } + }, + "domainStatusClaimed": { + "message": "Claimed" + }, + "domainStatusUnderVerification": { + "message": "Under verification" + }, + "claimedDomainsDesc": { + "message": "Claim a domain to own all member accounts whose email address matches the domain. Members will be able to skip the SSO identifier when logging in. Administrators will also be able to delete member accounts." + }, + "invalidDomainNameClaimMessage": { + "message": "Input is not a valid format. Format: mydomain.com. Subdomains require separate entries to be claimed." + }, + "domainClaimedEvent": { + "message": "$DOMAIN$ claimed", + "placeholders": { + "DOMAIN": { + "content": "$1", + "example": "bitwarden.com" + } + } + }, + "domainNotClaimedEvent": { + "message": "$DOMAIN$ not claimed", + "placeholders": { + "DOMAIN": { + "content": "$1", + "example": "bitwarden.com" + } + } + }, + "updatedRevokeSponsorshipConfirmationForSentSponsorship": { + "message": "If you remove $EMAIL$, the sponsorship for this Family plan cannot be redeemed. Are you sure you want to continue?", + "placeholders": { + "email": { + "content": "$1", + "example": "sponsored@organization.com" + } + } + }, + "updatedRevokeSponsorshipConfirmationForAcceptedSponsorship": { + "message": "If you remove $EMAIL$, the sponsorship for this Family plan will end and the saved payment method will be charged $40 + applicable tax on $DATE$. You will not be able to redeem a new sponsorship until $DATE$. Are you sure you want to continue?", + "placeholders": { + "email": { + "content": "$1", + "example": "sponsored@organization.com" + }, + "date": { + "content": "$2", + "example": "12/10/2024" + } + } + }, + "domainClaimed": { + "message": "Domain claimed" } } diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts new file mode 100644 index 00000000000..427cb06d9e0 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/models/password-health.ts @@ -0,0 +1,92 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore + +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { BadgeVariant } from "@bitwarden/components"; + +/** + * All applications report summary. The total members, + * total at risk members, application, and at risk application + * counts. Aggregated from all calculated applications + */ +export type ApplicationHealthReportSummary = { + totalMemberCount: number; + totalAtRiskMemberCount: number; + totalApplicationCount: number; + totalAtRiskApplicationCount: number; +}; + +/** + * All applications report detail. Application is the cipher + * uri. Has the at risk, password, and member information + */ +export type ApplicationHealthReportDetail = { + applicationName: string; + passwordCount: number; + atRiskPasswordCount: number; + memberCount: number; + + memberDetails: MemberDetailsFlat[]; + atRiskMemberDetails: MemberDetailsFlat[]; +}; + +/** + * Breaks the cipher health info out by uri and passes + * along the password health and member info + */ +export type CipherHealthReportUriDetail = { + cipherId: string; + reusedPasswordCount: number; + weakPasswordDetail: WeakPasswordDetail; + exposedPasswordDetail: ExposedPasswordDetail; + cipherMembers: MemberDetailsFlat[]; + trimmedUri: string; +}; + +/** + * Associates a cipher with it's essential information. + * Gets the password health details, cipher members, and + * the trimmed uris for the cipher + */ +export type CipherHealthReportDetail = CipherView & { + reusedPasswordCount: number; + weakPasswordDetail: WeakPasswordDetail; + exposedPasswordDetail: ExposedPasswordDetail; + cipherMembers: MemberDetailsFlat[]; + trimmedUris: string[]; +}; + +/** + * Weak password details containing the score + * and the score type for the label and badge + */ +export type WeakPasswordDetail = { + score: number; + detailValue: WeakPasswordScore; +} | null; + +/** + * Weak password details containing the badge and + * the label for the password score + */ +export type WeakPasswordScore = { + label: string; + badgeVariant: BadgeVariant; +} | null; + +/** + * How many times a password has been exposed + */ +export type ExposedPasswordDetail = { + exposedXTimes: number; +} | null; + +/** + * Flattened member details that associates an + * organization member to a cipher + */ +export type MemberDetailsFlat = { + userName: string; + email: string; + cipherId: string; +}; diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/ciphers.mock.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/ciphers.mock.ts index e7693e46a32..ca5cdc35b8a 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/ciphers.mock.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/ciphers.mock.ts @@ -1,10 +1,18 @@ +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; + +const createLoginUriView = (uri: string): LoginUriView => { + const view = new LoginUriView(); + view.uri = uri; + return view; +}; + export const mockCiphers: any[] = [ { initializerKey: 1, id: "cbea34a8-bde4-46ad-9d19-b05001228ab1", organizationId: null, folderId: null, - name: "Cannot Be Edited", + name: "Weak Password Cipher", notes: null, isDeleted: false, type: 1, @@ -14,10 +22,11 @@ export const mockCiphers: any[] = [ password: "123", hasUris: true, uris: [ - { uri: "www.google.com" }, - { uri: "accounts.google.com" }, - { uri: "https://www.google.com" }, - { uri: "https://www.google.com/login" }, + createLoginUriView("101domain.com"), + createLoginUriView("www.google.com"), + createLoginUriView("accounts.google.com"), + createLoginUriView("https://www.google.com"), + createLoginUriView("https://www.google.com/login"), ], }, edit: false, @@ -31,23 +40,18 @@ export const mockCiphers: any[] = [ }, { initializerKey: 1, - id: "cbea34a8-bde4-46ad-9d19-b05001228ab2", + id: "cbea34a8-bde4-46ad-9d19-b05001228cd3", organizationId: null, folderId: null, - name: "Can Be Edited id ending 2", + name: "Strong Password Cipher", notes: null, - isDeleted: false, type: 1, favorite: false, organizationUseTotp: false, login: { - password: "123", + password: "Password!123", hasUris: true, - uris: [ - { - uri: "http://nothing.com", - }, - ], + uris: [createLoginUriView("http://example.com")], }, edit: true, viewPassword: true, @@ -60,22 +64,18 @@ export const mockCiphers: any[] = [ }, { initializerKey: 1, - id: "cbea34a8-bde4-46ad-9d19-b05001228cd3", + id: "cbea34a8-bde4-46ad-9d19-b05001228ab2", organizationId: null, folderId: null, - name: "Can Be Edited id ending 3", + name: "Strong password Cipher", notes: null, type: 1, favorite: false, organizationUseTotp: false, login: { - password: "123", hasUris: true, - uris: [ - { - uri: "http://example.com", - }, - ], + password: "Password!1234", + uris: [createLoginUriView("101domain.com")], }, edit: true, viewPassword: true, @@ -91,14 +91,15 @@ export const mockCiphers: any[] = [ id: "cbea34a8-bde4-46ad-9d19-b05001228xy4", organizationId: null, folderId: null, - name: "Can Be Edited id ending 4", + name: "Strong password Cipher", notes: null, type: 1, favorite: false, organizationUseTotp: false, login: { hasUris: true, - uris: [{ uri: "101domain.com" }], + password: "Password!123", + uris: [createLoginUriView("example.com")], }, edit: true, viewPassword: true, @@ -114,14 +115,39 @@ export const mockCiphers: any[] = [ id: "cbea34a8-bde4-46ad-9d19-b05001227nm5", organizationId: null, folderId: null, - name: "Can Be Edited id ending 5", + name: "Exposed password Cipher", notes: null, type: 1, favorite: false, organizationUseTotp: false, login: { hasUris: true, - uris: [{ uri: "123formbuilder.com" }], + password: "123", + uris: [createLoginUriView("123formbuilder.com"), createLoginUriView("www.google.com")], + }, + edit: true, + viewPassword: true, + collectionIds: [], + revisionDate: "2023-08-03T17:40:59.793Z", + creationDate: "2023-08-03T17:40:59.793Z", + deletedDate: null, + reprompt: 0, + localData: null, + }, + { + initializerKey: 1, + id: "cbea34a8-bde4-46ad-9d19-b05001227tt1", + organizationId: null, + folderId: null, + name: "Secure Co Login", + notes: null, + type: 1, + favorite: false, + organizationUseTotp: false, + login: { + hasUris: true, + password: "4gRyhhOX2Og2p0", + uris: [createLoginUriView("SecureCo.com")], }, edit: true, viewPassword: true, diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts index c7bace84e5b..e930c7666e8 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/index.ts @@ -1,2 +1,3 @@ export * from "./member-cipher-details-api.service"; export * from "./password-health.service"; +export * from "./risk-insights-report.service"; diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/member-cipher-details-api.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/member-cipher-details-api.service.spec.ts index 872a4cdff55..d6474c2c9c4 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/member-cipher-details-api.service.spec.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/member-cipher-details-api.service.spec.ts @@ -69,6 +69,12 @@ export const mockMemberCipherDetails: any = [ "cbea34a8-bde4-46ad-9d19-b05001228xy4", ], }, + { + userName: "Mister Secure", + email: "mister.secure@secureco.com", + usesKeyConnector: true, + cipherIds: ["cbea34a8-bde4-46ad-9d19-b05001227tt1"], + }, ]; describe("Member Cipher Details API Service", () => { @@ -91,7 +97,7 @@ describe("Member Cipher Details API Service", () => { const orgId = "1234"; const result = await memberCipherDetailsApiService.getMemberCipherDetails(orgId); expect(result).not.toBeNull(); - expect(result).toHaveLength(6); + expect(result).toHaveLength(7); expect(apiService.send).toHaveBeenCalledWith( "GET", "/reports/member-cipher-details/" + orgId, diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/password-health.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/password-health.service.spec.ts index c0f77abeb79..b81acb09bed 100644 --- a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/password-health.service.spec.ts +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/password-health.service.spec.ts @@ -3,18 +3,15 @@ import { TestBed } from "@angular/core/testing"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { mockCiphers } from "./ciphers.mock"; import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec"; import { PasswordHealthService } from "./password-health.service"; +// FIXME: Remove password-health report service after PR-15498 completion describe("PasswordHealthService", () => { let service: PasswordHealthService; - let cipherService: CipherService; - let memberCipherDetailsApiService: MemberCipherDetailsApiService; - beforeEach(() => { TestBed.configureTestingModule({ providers: [ @@ -51,8 +48,6 @@ describe("PasswordHealthService", () => { }); service = TestBed.inject(PasswordHealthService); - cipherService = TestBed.inject(CipherService); - memberCipherDetailsApiService = TestBed.inject(MemberCipherDetailsApiService); }); it("should be created", () => { @@ -67,83 +62,4 @@ describe("PasswordHealthService", () => { expect(service.exposedPasswordMap.size).toBe(0); expect(service.totalMembersMap.size).toBe(0); }); - - describe("generateReport", () => { - beforeEach(async () => { - await service.generateReport(); - }); - - it("should fetch all ciphers for the organization", () => { - expect(cipherService.getAllFromApiForOrganization).toHaveBeenCalledWith("org1"); - }); - - it("should fetch member cipher details", () => { - expect(memberCipherDetailsApiService.getMemberCipherDetails).toHaveBeenCalledWith("org1"); - }); - - it("should populate reportCiphers with ciphers that have issues", () => { - expect(service.reportCiphers.length).toBeGreaterThan(0); - }); - - it("should detect weak passwords", () => { - expect(service.passwordStrengthMap.size).toBeGreaterThan(0); - expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toEqual([ - "veryWeak", - "danger", - ]); - expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toEqual([ - "veryWeak", - "danger", - ]); - expect(service.passwordStrengthMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toEqual([ - "veryWeak", - "danger", - ]); - }); - - it("should detect reused passwords", () => { - expect(service.passwordUseMap.get("123")).toBe(3); - }); - - it("should detect exposed passwords", () => { - expect(service.exposedPasswordMap.size).toBeGreaterThan(0); - expect(service.exposedPasswordMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(100); - }); - - it("should calculate total members per cipher", () => { - expect(service.totalMembersMap.size).toBeGreaterThan(0); - expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab1")).toBe(2); - expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228ab2")).toBe(4); - expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228cd3")).toBe(5); - expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm5")).toBe(4); - expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001227nm7")).toBe(1); - expect(service.totalMembersMap.get("cbea34a8-bde4-46ad-9d19-b05001228xy4")).toBe(6); - }); - }); - - describe("findWeakPassword", () => { - it("should add weak passwords to passwordStrengthMap", () => { - const weakCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView; - service.findWeakPassword(weakCipher); - expect(service.passwordStrengthMap.get(weakCipher.id)).toEqual(["veryWeak", "danger"]); - }); - }); - - describe("findReusedPassword", () => { - it("should detect password reuse", () => { - mockCiphers.forEach((cipher) => { - service.findReusedPassword(cipher as CipherView); - }); - const reuseCounts = Array.from(service.passwordUseMap.values()).filter((count) => count > 1); - expect(reuseCounts.length).toBeGreaterThan(0); - }); - }); - - describe("findExposedPassword", () => { - it("should add exposed passwords to exposedPasswordMap", async () => { - const exposedCipher = mockCiphers.find((c) => c.login?.password === "123") as CipherView; - await service.findExposedPassword(exposedCipher); - expect(service.exposedPasswordMap.get(exposedCipher.id)).toBe(100); - }); - }); }); diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.spec.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.spec.ts new file mode 100644 index 00000000000..7505b692a8f --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.spec.ts @@ -0,0 +1,148 @@ +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom } from "rxjs"; + +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; + +import { mockCiphers } from "./ciphers.mock"; +import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; +import { mockMemberCipherDetails } from "./member-cipher-details-api.service.spec"; +import { RiskInsightsReportService } from "./risk-insights-report.service"; + +describe("RiskInsightsReportService", () => { + let service: RiskInsightsReportService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + RiskInsightsReportService, + { + provide: PasswordStrengthServiceAbstraction, + useValue: { + getPasswordStrength: (password: string) => { + const score = password.length < 4 ? 1 : 4; + return { score }; + }, + }, + }, + { + provide: AuditService, + useValue: { + passwordLeaked: (password: string) => Promise.resolve(password === "123" ? 100 : 0), + }, + }, + { + provide: CipherService, + useValue: { + getAllFromApiForOrganization: jest.fn().mockResolvedValue(mockCiphers), + }, + }, + { + provide: MemberCipherDetailsApiService, + useValue: { + getMemberCipherDetails: jest.fn().mockResolvedValue(mockMemberCipherDetails), + }, + }, + ], + }); + + service = TestBed.inject(RiskInsightsReportService); + }); + + it("should generate the raw data report correctly", async () => { + const result = await firstValueFrom(service.generateRawDataReport$("orgId")); + + expect(result).toHaveLength(6); + + let testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001228ab1"); + expect(testCaseResults).toHaveLength(1); + let testCase = testCaseResults[0]; + expect(testCase).toBeTruthy(); + expect(testCase.cipherMembers).toHaveLength(2); + expect(testCase.trimmedUris).toHaveLength(3); + expect(testCase.weakPasswordDetail).toBeTruthy(); + expect(testCase.exposedPasswordDetail).toBeTruthy(); + expect(testCase.reusedPasswordCount).toEqual(2); + + testCaseResults = result.filter((x) => x.id === "cbea34a8-bde4-46ad-9d19-b05001227tt1"); + expect(testCaseResults).toHaveLength(1); + testCase = testCaseResults[0]; + expect(testCase).toBeTruthy(); + expect(testCase.cipherMembers).toHaveLength(1); + expect(testCase.trimmedUris).toHaveLength(1); + expect(testCase.weakPasswordDetail).toBeFalsy(); + expect(testCase.exposedPasswordDetail).toBeFalsy(); + expect(testCase.reusedPasswordCount).toEqual(1); + }); + + it("should generate the raw data + uri report correctly", async () => { + const result = await firstValueFrom(service.generateRawDataUriReport$("orgId")); + + expect(result).toHaveLength(9); + + // Two ciphers that have google.com as their uri. There should be 2 results + const googleResults = result.filter((x) => x.trimmedUri === "google.com"); + expect(googleResults).toHaveLength(2); + + // Verify the details for one of the googles matches the password health info + // expected + const firstGoogle = googleResults.filter( + (x) => x.cipherId === "cbea34a8-bde4-46ad-9d19-b05001228ab1" && x.trimmedUri === "google.com", + )[0]; + expect(firstGoogle.weakPasswordDetail).toBeTruthy(); + expect(firstGoogle.exposedPasswordDetail).toBeTruthy(); + expect(firstGoogle.reusedPasswordCount).toEqual(2); + }); + + it("should generate applications health report data correctly", async () => { + const result = await firstValueFrom(service.generateApplicationsReport$("orgId")); + + expect(result).toHaveLength(6); + + // Two ciphers have google.com associated with them. The first cipher + // has 2 members and the second has 4. However, the 2 members in the first + // cipher are also associated with the second. The total amount of members + // should be 4 not 6 + const googleTestResults = result.filter((x) => x.applicationName === "google.com"); + expect(googleTestResults).toHaveLength(1); + const googleTest = googleTestResults[0]; + expect(googleTest.memberCount).toEqual(4); + + // Both ciphers have at risk passwords + expect(googleTest.passwordCount).toEqual(2); + + // All members are at risk since both ciphers are at risk + expect(googleTest.atRiskMemberDetails).toHaveLength(4); + expect(googleTest.atRiskPasswordCount).toEqual(2); + + // There are 2 ciphers associated with 101domain.com + const domain101TestResults = result.filter((x) => x.applicationName === "101domain.com"); + expect(domain101TestResults).toHaveLength(1); + const domain101Test = domain101TestResults[0]; + expect(domain101Test.passwordCount).toEqual(2); + + // The first cipher is at risk. The second cipher is not at risk + expect(domain101Test.atRiskPasswordCount).toEqual(1); + + // The first cipher has 2 members. The second cipher the second + // cipher has 4. One of the members in the first cipher is associated + // with the second. So there should be 5 members total. + expect(domain101Test.memberCount).toEqual(5); + + // The first cipher is at risk. The total at risk members is 2 and + // at risk password count is 1. + expect(domain101Test.atRiskMemberDetails).toHaveLength(2); + expect(domain101Test.atRiskPasswordCount).toEqual(1); + }); + + it("should generate applications summary data correctly", async () => { + const reportResult = await firstValueFrom(service.generateApplicationsReport$("orgId")); + const reportSummary = service.generateApplicationsSummary(reportResult); + + expect(reportSummary.totalMemberCount).toEqual(7); + expect(reportSummary.totalAtRiskMemberCount).toEqual(6); + expect(reportSummary.totalApplicationCount).toEqual(6); + expect(reportSummary.totalAtRiskApplicationCount).toEqual(5); + }); +}); diff --git a/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.ts new file mode 100644 index 00000000000..f4b30735584 --- /dev/null +++ b/bitwarden_license/bit-common/src/tools/reports/risk-insights/services/risk-insights-report.service.ts @@ -0,0 +1,395 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore + +import { Injectable } from "@angular/core"; +import { concatMap, first, from, map, Observable, zip } from "rxjs"; + +import { AuditService } from "@bitwarden/common/abstractions/audit.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + +import { + ApplicationHealthReportDetail, + ApplicationHealthReportSummary, + CipherHealthReportDetail, + CipherHealthReportUriDetail, + ExposedPasswordDetail, + MemberDetailsFlat, + WeakPasswordDetail, + WeakPasswordScore, +} from "../models/password-health"; + +import { MemberCipherDetailsApiService } from "./member-cipher-details-api.service"; + +@Injectable() +export class RiskInsightsReportService { + constructor( + private passwordStrengthService: PasswordStrengthServiceAbstraction, + private auditService: AuditService, + private cipherService: CipherService, + private memberCipherDetailsApiService: MemberCipherDetailsApiService, + ) {} + + /** + * Report data from raw cipher health data. + * Can be used in the Raw Data diagnostic tab (just exclude the members in the view) + * and can be used in the raw data + members tab when including the members in the view + * @param organizationId + * @returns Cipher health report data with members and trimmed uris + */ + generateRawDataReport$(organizationId: string): Observable { + const allCiphers$ = from(this.cipherService.getAllFromApiForOrganization(organizationId)); + const memberCiphers$ = from( + this.memberCipherDetailsApiService.getMemberCipherDetails(organizationId), + ); + + const results$ = zip(allCiphers$, memberCiphers$).pipe( + map(([allCiphers, memberCiphers]) => { + const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) => + dtl.cipherIds.map((c) => this.getMemberDetailsFlat(dtl.userName, dtl.email, c)), + ); + return [allCiphers, details] as const; + }), + concatMap(([ciphers, flattenedDetails]) => this.getCipherDetails(ciphers, flattenedDetails)), + first(), + ); + + return results$; + } + + /** + * Report data for raw cipher health broken out into the uris + * Can be used in the raw data + members + uri diagnostic report + * @param organizationId Id of the organization + * @returns Cipher health report data flattened to the uris + */ + generateRawDataUriReport$(organizationId: string): Observable { + const cipherHealthDetails$ = this.generateRawDataReport$(organizationId); + const results$ = cipherHealthDetails$.pipe( + map((healthDetails) => this.getCipherUriDetails(healthDetails)), + first(), + ); + + return results$; + } + + /** + * Report data for the aggregation of uris to like uris and getting password/member counts, + * members, and at risk statuses. + * @param organizationId Id of the organization + * @returns The all applications health report data + */ + generateApplicationsReport$(organizationId: string): Observable { + const cipherHealthUriReport$ = this.generateRawDataUriReport$(organizationId); + const results$ = cipherHealthUriReport$.pipe( + map((uriDetails) => this.getApplicationHealthReport(uriDetails)), + first(), + ); + + return results$; + } + + /** + * Gets the summary from the application health report. Returns total members and applications as well + * as the total at risk members and at risk applications + * @param reports The previously calculated application health report data + * @returns A summary object containing report totals + */ + generateApplicationsSummary( + reports: ApplicationHealthReportDetail[], + ): ApplicationHealthReportSummary { + const totalMembers = reports.flatMap((x) => x.memberDetails); + const uniqueMembers = this.getUniqueMembers(totalMembers); + + const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails); + const uniqueAtRiskMembers = this.getUniqueMembers(atRiskMembers); + + return { + totalMemberCount: uniqueMembers.length, + totalAtRiskMemberCount: uniqueAtRiskMembers.length, + totalApplicationCount: reports.length, + totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length, + }; + } + + /** + * Associates the members with the ciphers they have access to. Calculates the password health. + * Finds the trimmed uris. + * @param ciphers Org ciphers + * @param memberDetails Org members + * @returns Cipher password health data with trimmed uris and associated members + */ + private async getCipherDetails( + ciphers: CipherView[], + memberDetails: MemberDetailsFlat[], + ): Promise { + const cipherHealthReports: CipherHealthReportDetail[] = []; + const passwordUseMap = new Map(); + for (const cipher of ciphers) { + if (this.validateCipher(cipher)) { + const weakPassword = this.findWeakPassword(cipher); + // Looping over all ciphers needs to happen first to determine reused passwords over all ciphers. + // Store in the set and evaluate later + if (passwordUseMap.has(cipher.login.password)) { + passwordUseMap.set( + cipher.login.password, + (passwordUseMap.get(cipher.login.password) || 0) + 1, + ); + } else { + passwordUseMap.set(cipher.login.password, 1); + } + + const exposedPassword = await this.findExposedPassword(cipher); + + // Get the cipher members + const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id); + + // Trim uris to host name and create the cipher health report + const cipherTrimmedUris = this.getTrimmedCipherUris(cipher); + const cipherHealth = { + ...cipher, + weakPasswordDetail: weakPassword, + exposedPasswordDetail: exposedPassword, + cipherMembers: cipherMembers, + trimmedUris: cipherTrimmedUris, + } as CipherHealthReportDetail; + + cipherHealthReports.push(cipherHealth); + } + } + + // loop for reused passwords + cipherHealthReports.forEach((detail) => { + detail.reusedPasswordCount = passwordUseMap.get(detail.login.password) ?? 0; + }); + return cipherHealthReports; + } + + /** + * Flattens the cipher to trimmed uris. Used for the raw data + uri + * @param cipherHealthReport Cipher health report with uris and members + * @returns Flattened cipher health details to uri + */ + private getCipherUriDetails( + cipherHealthReport: CipherHealthReportDetail[], + ): CipherHealthReportUriDetail[] { + return cipherHealthReport.flatMap((rpt) => + rpt.trimmedUris.map((u) => this.getFlattenedCipherDetails(rpt, u)), + ); + } + + /** + * Loop through the flattened cipher to uri data. If the item exists it's values need to be updated with the new item. + * If the item is new, create and add the object with the flattened details + * @param cipherHealthUriReport Cipher and password health info broken out into their uris + * @returns Application health reports + */ + private getApplicationHealthReport( + cipherHealthUriReport: CipherHealthReportUriDetail[], + ): ApplicationHealthReportDetail[] { + const appReports: ApplicationHealthReportDetail[] = []; + cipherHealthUriReport.forEach((uri) => { + const index = appReports.findIndex((item) => item.applicationName === uri.trimmedUri); + + let atRisk: boolean = false; + if (uri.exposedPasswordDetail || uri.weakPasswordDetail || uri.reusedPasswordCount > 1) { + atRisk = true; + } + + if (index === -1) { + appReports.push(this.getApplicationReportDetail(uri, atRisk)); + } else { + appReports[index] = this.getApplicationReportDetail(uri, atRisk, appReports[index]); + } + }); + return appReports; + } + + private async findExposedPassword(cipher: CipherView): Promise { + const exposedCount = await this.auditService.passwordLeaked(cipher.login.password); + if (exposedCount > 0) { + const exposedDetail = { exposedXTimes: exposedCount } as ExposedPasswordDetail; + return exposedDetail; + } + return null; + } + + private findWeakPassword(cipher: CipherView): WeakPasswordDetail { + const hasUserName = this.isUserNameNotEmpty(cipher); + let userInput: string[] = []; + if (hasUserName) { + const atPosition = cipher.login.username.indexOf("@"); + if (atPosition > -1) { + userInput = userInput + .concat( + cipher.login.username + .substring(0, atPosition) + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/), + ) + .filter((i) => i.length >= 3); + } else { + userInput = cipher.login.username + .trim() + .toLowerCase() + .split(/[^A-Za-z0-9]/) + .filter((i) => i.length >= 3); + } + } + const { score } = this.passwordStrengthService.getPasswordStrength( + cipher.login.password, + null, + userInput.length > 0 ? userInput : null, + ); + + if (score != null && score <= 2) { + const scoreValue = this.weakPasswordScore(score); + const weakPasswordDetail = { score: score, detailValue: scoreValue } as WeakPasswordDetail; + return weakPasswordDetail; + } + return null; + } + + private weakPasswordScore(score: number): WeakPasswordScore { + switch (score) { + case 4: + return { label: "strong", badgeVariant: "success" }; + case 3: + return { label: "good", badgeVariant: "primary" }; + case 2: + return { label: "weak", badgeVariant: "warning" }; + default: + return { label: "veryWeak", badgeVariant: "danger" }; + } + } + + /** + * Create the new application health report detail object with the details from the cipher health report uri detail object + * update or create the at risk values if the item is at risk. + * @param newUriDetail New cipher uri detail + * @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk + * @param existingUriDetail The previously processed Uri item + * @returns The new or updated application health report detail + */ + private getApplicationReportDetail( + newUriDetail: CipherHealthReportUriDetail, + isAtRisk: boolean, + existingUriDetail?: ApplicationHealthReportDetail, + ): ApplicationHealthReportDetail { + const reportDetail = { + applicationName: existingUriDetail + ? existingUriDetail.applicationName + : newUriDetail.trimmedUri, + passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1, + memberDetails: existingUriDetail + ? this.getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers)) + : newUriDetail.cipherMembers, + atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [], + atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0, + } as ApplicationHealthReportDetail; + + if (isAtRisk) { + (reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1), + (reportDetail.atRiskMemberDetails = this.getUniqueMembers( + reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers), + )); + } + + reportDetail.memberCount = reportDetail.memberDetails.length; + + return reportDetail; + } + + /** + * Get a distinct array of members from a combined list. Input list may contain + * duplicate members. + * @param orgMembers Input list of members + * @returns Distinct array of members + */ + private getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] { + const existingEmails = new Set(); + const distinctUsers = orgMembers.filter((member) => { + if (existingEmails.has(member.email)) { + return false; + } + existingEmails.add(member.email); + return true; + }); + return distinctUsers; + } + + private getFlattenedCipherDetails( + detail: CipherHealthReportDetail, + uri: string, + ): CipherHealthReportUriDetail { + return { + cipherId: detail.id, + reusedPasswordCount: detail.reusedPasswordCount, + weakPasswordDetail: detail.weakPasswordDetail, + exposedPasswordDetail: detail.exposedPasswordDetail, + cipherMembers: detail.cipherMembers, + trimmedUri: uri, + }; + } + + private getMemberDetailsFlat( + userName: string, + email: string, + cipherId: string, + ): MemberDetailsFlat { + return { + userName: userName, + email: email, + cipherId: cipherId, + }; + } + + /** + * Trim the cipher uris down to get the password health application. + * The uri should only exist once after being trimmed. No duplication. + * Example: + * - Untrimmed Uris: https://gmail.com, gmail.com/login + * - Both would trim to gmail.com + * - The cipher trimmed uri list should only return on instance in the list + * @param cipher + * @returns distinct list of trimmed cipher uris + */ + private getTrimmedCipherUris(cipher: CipherView): string[] { + const cipherUris: string[] = []; + const uris = cipher.login?.uris ?? []; + uris.map((u: { uri: string }) => { + const uri = Utils.getHostname(u.uri).replace("www.", ""); + if (!cipherUris.includes(uri)) { + cipherUris.push(uri); + } + }); + return cipherUris; + } + + private isUserNameNotEmpty(c: CipherView): boolean { + return !Utils.isNullOrWhitespace(c.login.username); + } + + /** + * Validates that the cipher is a login item, has a password + * is not deleted, and the user can view the password + * @param c the input cipher + */ + private validateCipher(c: CipherView): boolean { + const { type, login, isDeleted, viewPassword } = c; + if ( + type !== CipherType.Login || + login.password == null || + login.password === "" || + isDeleted || + !viewPassword + ) { + return false; + } + return true; + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html index 15120eed92a..7226c957598 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-add-edit-dialog/domain-add-edit-dialog.component.html @@ -6,24 +6,37 @@ {{ "newDomain" | i18n }} - {{ "verifyDomain" | i18n }} + + {{ + ((accountDeprovisioningEnabled$ | async) ? "claimDomain" : "verifyDomain") | i18n + }} {{ data.orgDomain.domainName }} {{ - "domainStatusUnverified" | i18n + ((accountDeprovisioningEnabled$ | async) + ? "domainStatusUnderVerification" + : "domainStatusUnverified" + ) | i18n }} {{ - "domainStatusVerified" | i18n + ((accountDeprovisioningEnabled$ | async) ? "domainStatusClaimed" : "domainStatusVerified") + | i18n }}
      {{ "domainName" | i18n }} - {{ "domainNameInputHint" | i18n }} + {{ + ((accountDeprovisioningEnabled$ | async) + ? "claimDomainNameInputHint" + : "domainNameInputHint" + ) | i18n + }} @@ -42,18 +55,29 @@ - {{ "automaticDomainVerificationProcess" | i18n }} + {{ + ((accountDeprovisioningEnabled$ | async) + ? "automaticDomainClaimProcess" + : "automaticDomainVerificationProcess" + ) | i18n + }}
      +

      + {{ "claimedDomainsDesc" | i18n }} + + + +

      + {{ - "domainStatusUnverified" | i18n + ((accountDeprovisioningEnabled$ | async) + ? "domainStatusUnderVerification" + : "domainStatusUnverified" + ) | i18n }} {{ - "domainStatusVerified" | i18n + ((accountDeprovisioningEnabled$ | async) + ? "domainStatusClaimed" + : "domainStatusVerified" + ) | i18n }} @@ -70,7 +90,10 @@ type="button" > - {{ "verifyDomain" | i18n }} + {{ + ((accountDeprovisioningEnabled$ | async) ? "claimDomain" : "verifyDomain") + | i18n + }} + + + + + {{ "loading" | i18n }} + + + +

      {{ "noClientsInList" | i18n }}

      + + + + {{ "name" | i18n }} + {{ "numberOfUsers" | i18n }} + {{ "billingPlan" | i18n }} + + + + + + + + {{ row.organizationName }} + + + {{ row.userCount }} + / {{ row.seats }} + + + {{ row.plan }} + + + + + + + +
      diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/vnext-clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/vnext-clients.component.ts new file mode 100644 index 00000000000..2be38477d4c --- /dev/null +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/vnext-clients.component.ts @@ -0,0 +1,167 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl } from "@angular/forms"; +import { ActivatedRoute, Router, RouterModule } from "@angular/router"; +import { firstValueFrom, from, map } from "rxjs"; +import { debounceTime, first, switchMap } from "rxjs/operators"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; +import { PlanType } from "@bitwarden/common/billing/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { + AvatarModule, + DialogService, + TableDataSource, + TableModule, + ToastService, +} from "@bitwarden/components"; +import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/shared"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; + +import { WebProviderService } from "../services/web-provider.service"; + +import { AddOrganizationComponent } from "./add-organization.component"; + +const DisallowedPlanTypes = [ + PlanType.Free, + PlanType.FamiliesAnnually2019, + PlanType.FamiliesAnnually, + PlanType.TeamsStarter2023, + PlanType.TeamsStarter, +]; + +@Component({ + templateUrl: "vnext-clients.component.html", + standalone: true, + imports: [ + SharedOrganizationModule, + HeaderModule, + CommonModule, + JslibModule, + AvatarModule, + RouterModule, + TableModule, + ], +}) +export class vNextClientsComponent { + providerId: string = ""; + addableOrganizations: Organization[] = []; + loading = true; + manageOrganizations = false; + showAddExisting = false; + dataSource: TableDataSource = + new TableDataSource(); + protected searchControl = new FormControl("", { nonNullable: true }); + + constructor( + private router: Router, + private providerService: ProviderService, + private apiService: ApiService, + private organizationService: OrganizationService, + private organizationApiService: OrganizationApiServiceAbstraction, + private activatedRoute: ActivatedRoute, + private dialogService: DialogService, + private i18nService: I18nService, + private toastService: ToastService, + private validationService: ValidationService, + private webProviderService: WebProviderService, + ) { + this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => { + this.searchControl.setValue(queryParams.search); + }); + + this.activatedRoute.parent?.params + ?.pipe( + switchMap((params) => { + this.providerId = params.providerId; + return this.providerService.get$(this.providerId).pipe( + map((provider) => provider?.providerStatus === ProviderStatusType.Billable), + map((isBillable) => { + if (isBillable) { + return from( + this.router.navigate(["../manage-client-organizations"], { + relativeTo: this.activatedRoute, + }), + ); + } else { + return from(this.load()); + } + }), + ); + }), + takeUntilDestroyed(), + ) + .subscribe(); + + this.searchControl.valueChanges + .pipe(debounceTime(200), takeUntilDestroyed()) + .subscribe((searchText) => { + this.dataSource.filter = (data) => + data.organizationName.toLowerCase().indexOf(searchText.toLowerCase()) > -1; + }); + } + + async remove(organization: ProviderOrganizationOrganizationDetailsResponse) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: organization.organizationName, + content: { key: "detachOrganizationConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + try { + await this.webProviderService.detachOrganization(this.providerId, organization.id); + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("detachedOrganization", organization.organizationName), + }); + await this.load(); + } catch (e) { + this.validationService.showError(e); + } + } + + async load() { + const response = await this.apiService.getProviderClients(this.providerId); + const clients = response.data != null && response.data.length > 0 ? response.data : []; + this.dataSource.data = clients; + this.manageOrganizations = + (await this.providerService.get(this.providerId)).type === ProviderUserType.ProviderAdmin; + const candidateOrgs = (await this.organizationService.getAll()).filter( + (o) => o.isOwner && o.providerId == null, + ); + const allowedOrgsIds = await Promise.all( + candidateOrgs.map((o) => this.organizationApiService.get(o.id)), + ).then((orgs) => + orgs.filter((o) => !DisallowedPlanTypes.includes(o.planType)).map((o) => o.id), + ); + this.addableOrganizations = candidateOrgs.filter((o) => allowedOrgsIds.includes(o.id)); + + this.showAddExisting = this.addableOrganizations.length !== 0; + this.loading = false; + } + + async addExistingOrganization() { + const dialogRef = AddOrganizationComponent.open(this.dialogService, { + providerId: this.providerId, + organizations: this.addableOrganizations, + }); + + if (await firstValueFrom(dialogRef.closed)) { + await this.load(); + } + } +} diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts index 00c944e69bb..09276263332 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-routing.module.ts @@ -2,8 +2,10 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { authGuard } from "@bitwarden/angular/auth/guards"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { AnonLayoutWrapperComponent } from "@bitwarden/auth/angular"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component"; import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component"; @@ -12,10 +14,12 @@ import { ProviderSubscriptionComponent, hasConsolidatedBilling, ProviderBillingHistoryComponent, + vNextManageClientsComponent, } from "../../billing/providers"; import { ClientsComponent } from "./clients/clients.component"; import { CreateOrganizationComponent } from "./clients/create-organization.component"; +import { vNextClientsComponent } from "./clients/vnext-clients.component"; import { providerPermissionsGuard } from "./guards/provider-permissions.guard"; import { AcceptProviderComponent } from "./manage/accept-provider.component"; import { EventsComponent } from "./manage/events.component"; @@ -82,13 +86,25 @@ const routes: Routes = [ children: [ { path: "", pathMatch: "full", redirectTo: "clients" }, { path: "clients/create", component: CreateOrganizationComponent }, - { path: "clients", component: ClientsComponent, data: { titleId: "clients" } }, - { - path: "manage-client-organizations", - canActivate: [hasConsolidatedBilling], - component: ManageClientsComponent, - data: { titleId: "clients" }, - }, + ...featureFlaggedRoute({ + defaultComponent: ClientsComponent, + flaggedComponent: vNextClientsComponent, + featureFlag: FeatureFlag.PM12443RemovePagingLogic, + routeOptions: { + path: "clients", + data: { titleId: "clients" }, + }, + }), + ...featureFlaggedRoute({ + defaultComponent: ManageClientsComponent, + flaggedComponent: vNextManageClientsComponent, + featureFlag: FeatureFlag.PM12443RemovePagingLogic, + routeOptions: { + path: "manage-client-organizations", + data: { titleId: "clients" }, + canActivate: [hasConsolidatedBilling], + }, + }), { path: "manage", children: [ diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html index fa9665ff457..0731820e413 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html @@ -31,7 +31,10 @@ {{ "ssoIdentifierHintPartOne" | i18n }} - {{ "domainVerification" | i18n }} + {{ + ((accountDeprovisioningEnabled$ | async) ? "claimedDomains" : "domainVerification") + | i18n + }} diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts index 87f2a3dd9d9..6449ef7a701 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.ts @@ -9,7 +9,7 @@ import { Validators, } from "@angular/forms"; import { ActivatedRoute } from "@angular/router"; -import { concatMap, Subject, takeUntil } from "rxjs"; +import { concatMap, Observable, Subject, takeUntil } from "rxjs"; import { ControlsOf } from "@bitwarden/angular/types/controls-of"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -28,6 +28,7 @@ import { SsoConfigApi } from "@bitwarden/common/auth/models/api/sso-config.api"; import { OrganizationSsoRequest } from "@bitwarden/common/auth/models/request/organization-sso.request"; import { OrganizationSsoResponse } from "@bitwarden/common/auth/models/response/organization-sso.response"; import { SsoConfigView } from "@bitwarden/common/auth/models/view/sso-config.view"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -185,6 +186,8 @@ export class SsoComponent implements OnInit, OnDestroy { return this.ssoConfigForm?.controls?.configType as FormControl; } + accountDeprovisioningEnabled$: Observable; + constructor( private formBuilder: FormBuilder, private route: ActivatedRoute, @@ -195,7 +198,11 @@ export class SsoComponent implements OnInit, OnDestroy { private organizationApiService: OrganizationApiServiceAbstraction, private configService: ConfigService, private toastService: ToastService, - ) {} + ) { + this.accountDeprovisioningEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.AccountDeprovisioning, + ); + } async ngOnInit() { this.enabledCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((enabled) => { diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts index ae7bf487f99..f8b344372ef 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts @@ -3,3 +3,4 @@ export * from "./manage-clients.component"; export * from "./manage-client-name-dialog.component"; export * from "./manage-client-subscription-dialog.component"; export * from "./no-clients.component"; +export * from "./vnext-manage-clients.component"; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.html new file mode 100644 index 00000000000..99de9352f62 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.html @@ -0,0 +1,83 @@ + + + + + {{ "addNewOrganization" | i18n }} + + + + + + {{ "loading" | i18n }} + + + + + + {{ "client" | i18n }} + {{ "assigned" | i18n }} + {{ "used" | i18n }} + {{ "remaining" | i18n }} + {{ "billingPlan" | i18n }} + + + + + + + + + + + {{ row.seats }} + + + {{ row.occupiedSeats }} + + + {{ row.remainingSeats }} + + + {{ row.plan }} + + + + + + + + + + + +
      + +
      +
      diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.ts new file mode 100644 index 00000000000..4c0837d6da2 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-manage-clients.component.ts @@ -0,0 +1,201 @@ +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl } from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom, from, lastValueFrom, map } from "rxjs"; +import { debounceTime, first, switchMap } from "rxjs/operators"; + +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; +import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { + AvatarModule, + DialogService, + TableDataSource, + TableModule, + ToastService, +} from "@bitwarden/components"; +import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/shared"; +import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module"; + +import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; + +import { + CreateClientDialogResultType, + openCreateClientDialog, +} from "./create-client-dialog.component"; +import { + ManageClientNameDialogResultType, + openManageClientNameDialog, +} from "./manage-client-name-dialog.component"; +import { + ManageClientSubscriptionDialogResultType, + openManageClientSubscriptionDialog, +} from "./manage-client-subscription-dialog.component"; +import { vNextNoClientsComponent } from "./vnext-no-clients.component"; + +@Component({ + templateUrl: "vnext-manage-clients.component.html", + standalone: true, + imports: [ + AvatarModule, + TableModule, + HeaderModule, + SharedOrganizationModule, + vNextNoClientsComponent, + ], +}) +export class vNextManageClientsComponent { + providerId: string = ""; + provider: Provider | undefined; + loading = true; + isProviderAdmin = false; + dataSource: TableDataSource = + new TableDataSource(); + + protected searchControl = new FormControl("", { nonNullable: true }); + protected plans: PlanResponse[] = []; + + constructor( + private billingApiService: BillingApiServiceAbstraction, + private providerService: ProviderService, + private router: Router, + private activatedRoute: ActivatedRoute, + private dialogService: DialogService, + private i18nService: I18nService, + private toastService: ToastService, + private validationService: ValidationService, + private webProviderService: WebProviderService, + ) { + this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => { + this.searchControl.setValue(queryParams.search); + }); + + this.activatedRoute.parent?.params + ?.pipe( + switchMap((params) => { + this.providerId = params.providerId; + return this.providerService.get$(this.providerId).pipe( + map((provider: Provider) => provider?.providerStatus === ProviderStatusType.Billable), + map((isBillable) => { + if (!isBillable) { + return from( + this.router.navigate(["../clients"], { + relativeTo: this.activatedRoute, + }), + ); + } else { + return from(this.load()); + } + }), + ); + }), + takeUntilDestroyed(), + ) + .subscribe(); + + this.searchControl.valueChanges + .pipe(debounceTime(200), takeUntilDestroyed()) + .subscribe((searchText) => { + this.dataSource.filter = (data) => + data.organizationName.toLowerCase().indexOf(searchText.toLowerCase()) > -1; + }); + } + + async load() { + this.provider = await firstValueFrom(this.providerService.get$(this.providerId)); + + this.isProviderAdmin = this.provider?.type === ProviderUserType.ProviderAdmin; + + const clients = (await this.billingApiService.getProviderClientOrganizations(this.providerId)) + .data; + + clients.forEach((client) => (client.plan = client.plan?.replace(" (Monthly)", ""))); + + this.dataSource.data = clients; + + this.plans = (await this.billingApiService.getPlans()).data; + + this.loading = false; + } + + createClient = async () => { + const reference = openCreateClientDialog(this.dialogService, { + data: { + providerId: this.providerId, + plans: this.plans, + }, + }); + + const result = await lastValueFrom(reference.closed); + + if (result === CreateClientDialogResultType.Submitted) { + await this.load(); + } + }; + + manageClientName = async (organization: ProviderOrganizationOrganizationDetailsResponse) => { + const dialogRef = openManageClientNameDialog(this.dialogService, { + data: { + providerId: this.providerId, + organization: { + id: organization.id, + name: organization.organizationName, + seats: organization.seats ? organization.seats : 0, + }, + }, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result === ManageClientNameDialogResultType.Submitted) { + await this.load(); + } + }; + + manageClientSubscription = async ( + organization: ProviderOrganizationOrganizationDetailsResponse, + ) => { + const dialogRef = openManageClientSubscriptionDialog(this.dialogService, { + data: { + organization, + provider: this.provider!, + }, + }); + + const result = await firstValueFrom(dialogRef.closed); + + if (result === ManageClientSubscriptionDialogResultType.Submitted) { + await this.load(); + } + }; + + async remove(organization: ProviderOrganizationOrganizationDetailsResponse) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: organization.organizationName, + content: { key: "detachOrganizationConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + try { + await this.webProviderService.detachOrganization(this.providerId, organization.id); + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("detachedOrganization", organization.organizationName), + }); + await this.load(); + } catch (e) { + this.validationService.showError(e); + } + } +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-no-clients.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-no-clients.component.ts new file mode 100644 index 00000000000..5ad19945c51 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/vnext-no-clients.component.ts @@ -0,0 +1,50 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; + +import { svgIcon } from "@bitwarden/components"; +import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/shared"; + +const gearIcon = svgIcon` + + + + + + + + + + + + + + + + +`; + +@Component({ + selector: "app-no-clients", + standalone: true, + imports: [SharedOrganizationModule], + template: `
      + +

      {{ "noClients" | i18n }}

      + + + {{ "addNewOrganization" | i18n }} + +
      `, +}) +export class vNextNoClientsComponent { + icon = gearIcon; + @Input() showAddOrganizationButton = true; + @Output() addNewOrganizationClicked = new EventEmitter(); + + addNewOrganization = () => this.addNewOrganizationClicked.emit(); +} diff --git a/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts b/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts index 53098cdcc27..e1b2a5759a1 100644 --- a/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts +++ b/libs/admin-console/src/common/collections/abstractions/vnext-collection.service.ts @@ -9,8 +9,8 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CollectionData, Collection, CollectionView } from "../models"; export abstract class vNextCollectionService { - encryptedCollections$: (userId$: Observable) => Observable; - decryptedCollections$: (userId$: Observable) => Observable; + encryptedCollections$: (userId: UserId) => Observable; + decryptedCollections$: (userId: UserId) => Observable; upsert: (collection: CollectionData | CollectionData[], userId: UserId) => Promise; replace: (collections: { [id: string]: CollectionData }, userId: UserId) => Promise; /** @@ -22,7 +22,7 @@ export abstract class vNextCollectionService { * Clear decrypted and encrypted state. * Used for logging out. */ - clear: (userId: string) => Promise; + clear: (userId: UserId) => Promise; delete: (id: string | string[], userId: UserId) => Promise; encrypt: (model: CollectionView) => Promise; /** @@ -30,7 +30,7 @@ export abstract class vNextCollectionService { */ decryptMany: ( collections: Collection[], - orgKeys?: Record, + orgKeys?: Record | null, ) => Promise; /** * Transforms the input CollectionViews into TreeNodes diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts index 54c4470d414..4aa54429aad 100644 --- a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts +++ b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.spec.ts @@ -1,5 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { firstValueFrom, of, ReplaySubject } from "rxjs"; +import { first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -87,7 +87,7 @@ describe("DefaultvNextCollectionService", () => { [org2]: orgKey2, }); - const result = await firstValueFrom(collectionService.decryptedCollections$(of(userId))); + const result = await firstValueFrom(collectionService.decryptedCollections$(userId)); // Assert emitted values expect(result.length).toBe(2); @@ -121,11 +121,38 @@ describe("DefaultvNextCollectionService", () => { cryptoKeys.next({}); const encryptedCollections = await firstValueFrom( - collectionService.encryptedCollections$(of(userId)), + collectionService.encryptedCollections$(userId), ); expect(encryptedCollections.length).toBe(0); }); + + it("handles undefined orgKeys", (done) => { + // Arrange test data + const org1 = Utils.newGuid() as OrganizationId; + const collection1 = collectionDataFactory(org1); + + const org2 = Utils.newGuid() as OrganizationId; + const collection2 = collectionDataFactory(org2); + + // Emit a non-null value after the first undefined value has propagated + // This will cause the collections to emit, calling done() + cryptoKeys.pipe(first()).subscribe((val) => { + cryptoKeys.next({}); + }); + + collectionService + .decryptedCollections$(userId) + .pipe(takeWhile((val) => val.length != 2)) + .subscribe({ complete: () => done() }); + + // Arrange dependencies + void setEncryptedState([collection1, collection2]).then(() => { + // Act: emit undefined + cryptoKeys.next(undefined); + keyService.activeUserOrgKeys$ = of(undefined); + }); + }); }); describe("encryptedCollections$", () => { @@ -137,7 +164,7 @@ describe("DefaultvNextCollectionService", () => { // Arrange dependencies await setEncryptedState([collection1, collection2]); - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(result.length).toBe(2); expect(result).toIncludeAllPartialMembers([ @@ -156,7 +183,7 @@ describe("DefaultvNextCollectionService", () => { await setEncryptedState(null); const decryptedCollections = await firstValueFrom( - collectionService.encryptedCollections$(of(userId)), + collectionService.encryptedCollections$(userId), ); expect(decryptedCollections.length).toBe(0); }); @@ -176,7 +203,7 @@ describe("DefaultvNextCollectionService", () => { await collectionService.upsert([updatedCollection1, newCollection3], userId); - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(result.length).toBe(3); expect(result).toIncludeAllPartialMembers([ { @@ -201,7 +228,7 @@ describe("DefaultvNextCollectionService", () => { await collectionService.upsert(collection1, userId); - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(result.length).toBe(1); expect(result).toIncludeAllPartialMembers([ { @@ -224,7 +251,7 @@ describe("DefaultvNextCollectionService", () => { userId, ); - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(result.length).toBe(1); expect(result).toIncludeAllPartialMembers([ { @@ -241,15 +268,11 @@ describe("DefaultvNextCollectionService", () => { await collectionService.clearDecryptedState(userId); // Encrypted state remains - const encryptedState = await firstValueFrom( - collectionService.encryptedCollections$(of(userId)), - ); + const encryptedState = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(encryptedState.length).toEqual(2); // Decrypted state is cleared - const decryptedState = await firstValueFrom( - collectionService.decryptedCollections$(of(userId)), - ); + const decryptedState = await firstValueFrom(collectionService.decryptedCollections$(userId)); expect(decryptedState.length).toEqual(0); }); @@ -260,15 +283,11 @@ describe("DefaultvNextCollectionService", () => { await collectionService.clear(userId); // Encrypted state is cleared - const encryptedState = await firstValueFrom( - collectionService.encryptedCollections$(of(userId)), - ); + const encryptedState = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(encryptedState.length).toEqual(0); // Decrypted state is cleared - const decryptedState = await firstValueFrom( - collectionService.decryptedCollections$(of(userId)), - ); + const decryptedState = await firstValueFrom(collectionService.decryptedCollections$(userId)); expect(decryptedState.length).toEqual(0); }); @@ -280,7 +299,7 @@ describe("DefaultvNextCollectionService", () => { await collectionService.delete(collection1.id, userId); - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(result.length).toEqual(1); expect(result[0]).toMatchObject({ id: collection2.id }); }); @@ -293,7 +312,7 @@ describe("DefaultvNextCollectionService", () => { await collectionService.delete([collection1.id, collection3.id], userId); - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(result.length).toEqual(1); expect(result[0]).toMatchObject({ id: collection2.id }); }); @@ -304,7 +323,7 @@ describe("DefaultvNextCollectionService", () => { await collectionService.delete(collection1.id, userId); - const result = await firstValueFrom(collectionService.encryptedCollections$(of(userId))); + const result = await firstValueFrom(collectionService.encryptedCollections$(userId)); expect(result.length).toEqual(0); }); }); diff --git a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts index 5f2985b8400..2d5a083592b 100644 --- a/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts +++ b/libs/admin-console/src/common/collections/services/default-vnext-collection.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; +import { combineLatest, filter, firstValueFrom, map } from "rxjs"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -30,9 +30,8 @@ export class DefaultvNextCollectionService implements vNextCollectionService { protected stateProvider: StateProvider, ) {} - encryptedCollections$(userId$: Observable) { - return userId$.pipe( - switchMap((userId) => this.encryptedState(userId).state$), + encryptedCollections$(userId: UserId) { + return this.encryptedState(userId).state$.pipe( map((collections) => { if (collections == null) { return []; @@ -43,11 +42,8 @@ export class DefaultvNextCollectionService implements vNextCollectionService { ); } - decryptedCollections$(userId$: Observable) { - return userId$.pipe( - switchMap((userId) => this.decryptedState(userId).state$), - map((collections) => collections ?? []), - ); + decryptedCollections$(userId: UserId) { + return this.decryptedState(userId).state$.pipe(map((collections) => collections ?? [])); } async upsert(toUpdate: CollectionData | CollectionData[], userId: UserId): Promise { @@ -78,14 +74,14 @@ export class DefaultvNextCollectionService implements vNextCollectionService { throw new Error("User ID is required."); } - await this.decryptedState(userId).forceValue(null); + await this.decryptedState(userId).forceValue([]); } async clear(userId: UserId): Promise { await this.encryptedState(userId).update(() => null); // This will propagate from the encrypted state update, but by doing it explicitly // the promise doesn't resolve until the update is complete. - await this.decryptedState(userId).forceValue(null); + await this.decryptedState(userId).forceValue([]); } async delete(id: CollectionId | CollectionId[], userId: UserId): Promise { @@ -125,7 +121,7 @@ export class DefaultvNextCollectionService implements vNextCollectionService { // See https://bitwarden.atlassian.net/browse/PM-12375 async decryptMany( collections: Collection[], - orgKeys?: Record, + orgKeys?: Record | null, ): Promise { if (collections == null || collections.length === 0) { return []; @@ -153,7 +149,7 @@ export class DefaultvNextCollectionService implements vNextCollectionService { collectionCopy.id = c.id; collectionCopy.organizationId = c.organizationId; const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; - ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); + ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, undefined, NestingDelimiter); }); return nodes; } @@ -181,14 +177,14 @@ export class DefaultvNextCollectionService implements vNextCollectionService { * @returns a SingleUserState for decrypted collection data. */ private decryptedState(userId: UserId): DerivedState { - const encryptedCollectionsWithKeys = this.encryptedState(userId).combinedState$.pipe( - switchMap(([userId, collectionData]) => - combineLatest([of(collectionData), this.keyService.orgKeys$(userId)]), - ), - ); + const encryptedCollectionsWithKeys$ = combineLatest([ + this.encryptedCollections$(userId), + // orgKeys$ can emit null during brief moments on unlock and lock/logout, we want to ignore those intermediate states + this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => orgKeys != null)), + ]); return this.stateProvider.getDerived( - encryptedCollectionsWithKeys, + encryptedCollectionsWithKeys$, DECRYPTED_COLLECTION_DATA_KEY, { collectionService: this, diff --git a/libs/admin-console/src/common/collections/services/vnext-collection.state.ts b/libs/admin-console/src/common/collections/services/vnext-collection.state.ts index 533308f3cc7..331c80436f7 100644 --- a/libs/admin-console/src/common/collections/services/vnext-collection.state.ts +++ b/libs/admin-console/src/common/collections/services/vnext-collection.state.ts @@ -21,7 +21,7 @@ export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record, Record], + [Collection[], Record | null], CollectionView[], { collectionService: vNextCollectionService } >(COLLECTION_DATA, "decryptedCollections", { @@ -31,7 +31,6 @@ export const DECRYPTED_COLLECTION_DATA_KEY = new DeriveDefinition< return []; } - const data = Object.values(collections).map((c) => new Collection(c)); - return await collectionService.decryptMany(data, orgKeys); + return await collectionService.decryptMany(collections, orgKeys); }, }); diff --git a/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.spec.ts b/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.spec.ts index 6a19f1ace7b..887f528d547 100644 --- a/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.spec.ts +++ b/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.spec.ts @@ -40,15 +40,14 @@ describe("unauthUiRefreshRedirect", () => { it("returns UrlTree when UnauthenticatedExtensionUIRefresh flag is enabled and preserves query params", async () => { configService.getFeatureFlag.mockResolvedValue(true); - const queryParams = { test: "test" }; + const urlTree = new UrlTree(); + urlTree.queryParams = { test: "test" }; const navigation: Navigation = { - extras: { - queryParams: queryParams, - }, + extras: {}, id: 0, initialUrl: new UrlTree(), - extractedUrl: new UrlTree(), + extractedUrl: urlTree, trigger: "imperative", previousNavigation: undefined, }; @@ -60,6 +59,8 @@ describe("unauthUiRefreshRedirect", () => { expect(configService.getFeatureFlag).toHaveBeenCalledWith( FeatureFlag.UnauthenticatedExtensionUIRefresh, ); - expect(router.createUrlTree).toHaveBeenCalledWith(["/redirect"], { queryParams }); + expect(router.createUrlTree).toHaveBeenCalledWith(["/redirect"], { + queryParams: urlTree.queryParams, + }); }); }); diff --git a/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.ts b/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.ts index a54bad11479..2cb53d5324f 100644 --- a/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.ts +++ b/libs/angular/src/auth/functions/unauth-ui-refresh-redirect.ts @@ -17,7 +17,7 @@ export function unauthUiRefreshRedirect(redirectUrl: string): () => Promise, diff --git a/libs/angular/src/directives/text-drag.directive.ts b/libs/angular/src/directives/text-drag.directive.ts index da3e70d1de2..443fbdac157 100644 --- a/libs/angular/src/directives/text-drag.directive.ts +++ b/libs/angular/src/directives/text-drag.directive.ts @@ -17,6 +17,6 @@ export class TextDragDirective { @HostListener("dragstart", ["$event"]) onDragStart(event: DragEvent) { - event.dataTransfer.setData("text", this.data); + event.dataTransfer?.setData("text", this.data); } } diff --git a/libs/angular/src/utils/extension-refresh-redirect.spec.ts b/libs/angular/src/utils/extension-refresh-redirect.spec.ts new file mode 100644 index 00000000000..3291a4496ff --- /dev/null +++ b/libs/angular/src/utils/extension-refresh-redirect.spec.ts @@ -0,0 +1,62 @@ +import { TestBed } from "@angular/core/testing"; +import { Navigation, Router, UrlTree } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { extensionRefreshRedirect } from "./extension-refresh-redirect"; + +describe("extensionRefreshRedirect", () => { + let configService: MockProxy; + let router: MockProxy; + + beforeEach(() => { + configService = mock(); + router = mock(); + + TestBed.configureTestingModule({ + providers: [ + { provide: ConfigService, useValue: configService }, + { provide: Router, useValue: router }, + ], + }); + }); + + it("returns true when ExtensionRefresh flag is disabled", async () => { + configService.getFeatureFlag.mockResolvedValue(false); + + const result = await TestBed.runInInjectionContext(() => + extensionRefreshRedirect("/redirect")(), + ); + + expect(result).toBe(true); + expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.ExtensionRefresh); + expect(router.parseUrl).not.toHaveBeenCalled(); + }); + + it("returns UrlTree when ExtensionRefresh flag is enabled and preserves query params", async () => { + configService.getFeatureFlag.mockResolvedValue(true); + + const urlTree = new UrlTree(); + urlTree.queryParams = { test: "test" }; + + const navigation: Navigation = { + extras: {}, + id: 0, + initialUrl: new UrlTree(), + extractedUrl: urlTree, + trigger: "imperative", + previousNavigation: undefined, + }; + + router.getCurrentNavigation.mockReturnValue(navigation); + + await TestBed.runInInjectionContext(() => extensionRefreshRedirect("/redirect")()); + + expect(configService.getFeatureFlag).toHaveBeenCalledWith(FeatureFlag.ExtensionRefresh); + expect(router.createUrlTree).toHaveBeenCalledWith(["/redirect"], { + queryParams: urlTree.queryParams, + }); + }); +}); diff --git a/libs/angular/src/utils/extension-refresh-redirect.ts b/libs/angular/src/utils/extension-refresh-redirect.ts index 81c50ceca1c..2baa3a3ec89 100644 --- a/libs/angular/src/utils/extension-refresh-redirect.ts +++ b/libs/angular/src/utils/extension-refresh-redirect.ts @@ -16,7 +16,7 @@ export function extensionRefreshRedirect(redirectUrl: string): () => Promise diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts index 95b45ffe7b3..04dc3b6dfd2 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts @@ -35,6 +35,10 @@ export interface AnonLayoutWrapperData { * Optional flag to set the max-width of the page. Defaults to 'md' if not provided. */ maxWidth?: "md" | "3xl"; + /** + * Optional flag to set the max-width of the title area. Defaults to null if not provided. + */ + titleAreaMaxWidth?: "md"; } @Component({ @@ -50,6 +54,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { protected pageIcon: Icon; protected showReadonlyHostname: boolean; protected maxWidth: "md" | "3xl"; + protected titleAreaMaxWidth: "md"; constructor( private router: Router, @@ -100,6 +105,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); this.maxWidth = firstChildRouteData["maxWidth"]; + this.titleAreaMaxWidth = firstChildRouteData["titleAreaMaxWidth"]; } private listenForServiceDataChanges() { @@ -157,6 +163,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy { this.pageIcon = null; this.showReadonlyHostname = null; this.maxWidth = null; + this.titleAreaMaxWidth = null; } ngOnDestroy() { diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index 3323b6eca08..cb3445abd96 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -13,7 +13,10 @@ -
      +
      diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.ts b/libs/auth/src/angular/anon-layout/anon-layout.component.ts index 9f3a9a0eea6..91229f38ab2 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.ts @@ -34,6 +34,13 @@ export class AnonLayoutComponent implements OnInit, OnChanges { @Input() hideLogo: boolean = false; @Input() hideFooter: boolean = false; + /** + * Max width of the title area content + * + * @default null + */ + @Input() titleAreaMaxWidth?: "md"; + /** * Max width of the layout content * @@ -60,6 +67,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { async ngOnInit() { this.maxWidth = this.maxWidth ?? "md"; + this.titleAreaMaxWidth = this.titleAreaMaxWidth ?? null; this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname(); this.version = await this.platformUtilsService.getApplicationVersion(); diff --git a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts b/libs/auth/src/angular/anon-layout/anon-layout.stories.ts index 77dc082c052..27eb27c53b9 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.stories.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout.stories.ts @@ -190,3 +190,22 @@ export const HideFooter: Story = { `, }), }; + +export const WithTitleAreaMaxWidth: Story = { + render: (args) => ({ + props: { + ...args, + title: "This is a very long long title to demonstrate titleAreaMaxWidth set to 'md'", + subtitle: + "This is a very long subtitle that demonstrates how the max width container handles longer text content with the titleAreaMaxWidth input set to 'md'. Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?", + }, + template: ` + +
      +
      Primary Projected Content Area (customizable)
      +
      Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?
      +
      +
      + `, + }), +}; diff --git a/libs/auth/src/angular/icons/index.ts b/libs/auth/src/angular/icons/index.ts index 05bb630fcb3..0e86ee7fc8e 100644 --- a/libs/auth/src/angular/icons/index.ts +++ b/libs/auth/src/angular/icons/index.ts @@ -10,4 +10,5 @@ export * from "./vault.icon"; export * from "./registration-user-add.icon"; export * from "./registration-lock-alt.icon"; export * from "./registration-expired-link.icon"; +export * from "./sso-key.icon"; export * from "./two-factor-timeout.icon"; diff --git a/libs/auth/src/angular/icons/sso-key.icon.ts b/libs/auth/src/angular/icons/sso-key.icon.ts new file mode 100644 index 00000000000..38ae8a66525 --- /dev/null +++ b/libs/auth/src/angular/icons/sso-key.icon.ts @@ -0,0 +1,10 @@ +import { svgIcon } from "@bitwarden/components"; + +export const SsoKeyIcon = svgIcon` + + + + + + +`; diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index ac2c75325c6..66111f3e5af 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -60,6 +60,11 @@ export * from "./user-verification/user-verification-form-input.component"; // vault timeout export * from "./vault-timeout-input/vault-timeout-input.component"; +// sso +export * from "./sso/sso.component"; +export * from "./sso/sso-component.service"; +export * from "./sso/default-sso-component.service"; + // self hosted environment configuration dialog export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.component"; diff --git a/libs/auth/src/angular/sso/default-sso-component.service.ts b/libs/auth/src/angular/sso/default-sso-component.service.ts new file mode 100644 index 00000000000..1af7fe3948a --- /dev/null +++ b/libs/auth/src/angular/sso/default-sso-component.service.ts @@ -0,0 +1,3 @@ +import { SsoComponentService } from "./sso-component.service"; + +export class DefaultSsoComponentService implements SsoComponentService {} diff --git a/libs/auth/src/angular/sso/sso-component.service.ts b/libs/auth/src/angular/sso/sso-component.service.ts new file mode 100644 index 00000000000..b5712dfacc9 --- /dev/null +++ b/libs/auth/src/angular/sso/sso-component.service.ts @@ -0,0 +1,20 @@ +import { ClientType } from "@bitwarden/common/enums"; + +export type SsoClientType = ClientType.Web | ClientType.Browser | ClientType.Desktop; + +/** + * Abstract class for SSO component services. + */ +export abstract class SsoComponentService { + /** + * Sets the cookies for the SSO component service. + * Used to pass translation messages to the SSO connector page (apps/web/src/connectors/sso.ts) during the SSO handoff process. + * See implementation in WebSsoComponentService for example usage. + */ + setDocumentCookies?(): void; + + /** + * Closes the window. + */ + closeWindow?(): Promise; +} diff --git a/libs/auth/src/angular/sso/sso.component.html b/libs/auth/src/angular/sso/sso.component.html new file mode 100644 index 00000000000..7a3fa8db973 --- /dev/null +++ b/libs/auth/src/angular/sso/sso.component.html @@ -0,0 +1,18 @@ +
      +
      + + {{ "loading" | i18n }} +
      +
      + + {{ "ssoIdentifier" | i18n }} + + +
      +
      + +
      +
      +
      diff --git a/libs/auth/src/angular/sso/sso.component.ts b/libs/auth/src/angular/sso/sso.component.ts new file mode 100644 index 00000000000..aad0df4e397 --- /dev/null +++ b/libs/auth/src/angular/sso/sso.component.ts @@ -0,0 +1,591 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms"; +import { ActivatedRoute, Router, RouterModule } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + LoginStrategyServiceAbstraction, + SsoLoginCredentials, + TrustedDeviceUserDecryptionOption, + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction"; +import { OrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain-sso-details.response"; +import { VerifiedOrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/verified-organization-domain-sso-details.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response"; +import { ClientType, HttpStatusCode } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { + AsyncActionsModule, + ButtonModule, + CheckboxModule, + FormFieldModule, + IconButtonModule, + LinkModule, + ToastService, +} from "@bitwarden/components"; +import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; + +import { SsoClientType, SsoComponentService } from "./sso-component.service"; + +interface QueryParams { + code?: string; + state?: string; + redirectUri?: string; + clientId?: string; + codeChallenge?: string; + identifier?: string; + email?: string; +} + +/** + * This component handles the SSO flow. + */ +@Component({ + standalone: true, + templateUrl: "sso.component.html", + imports: [ + AsyncActionsModule, + ButtonModule, + CheckboxModule, + CommonModule, + FormFieldModule, + IconButtonModule, + LinkModule, + JslibModule, + ReactiveFormsModule, + RouterModule, + ], +}) +export class SsoComponent implements OnInit { + protected formGroup = new FormGroup({ + identifier: new FormControl(null, [Validators.required]), + }); + + protected redirectUri: string | undefined; + protected loggingIn = false; + protected identifier: string | undefined; + protected state: string | undefined; + protected codeChallenge: string | undefined; + protected clientId: SsoClientType | undefined; + + formPromise: Promise | undefined; + initiateSsoFormPromise: Promise | undefined; + + get identifierFormControl() { + return this.formGroup.controls.identifier; + } + + constructor( + private ssoLoginService: SsoLoginServiceAbstraction, + private loginStrategyService: LoginStrategyServiceAbstraction, + private router: Router, + private i18nService: I18nService, + private route: ActivatedRoute, + private orgDomainApiService: OrgDomainApiServiceAbstraction, + private validationService: ValidationService, + private configService: ConfigService, + private platformUtilsService: PlatformUtilsService, + private apiService: ApiService, + private cryptoFunctionService: CryptoFunctionService, + private environmentService: EnvironmentService, + private passwordGenerationService: PasswordGenerationServiceAbstraction, + private logService: LogService, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, + private accountService: AccountService, + private toastService: ToastService, + private ssoComponentService: SsoComponentService, + private syncService: SyncService, + ) { + environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => { + this.redirectUri = env.getWebVaultUrl() + "/sso-connector.html"; + }); + + const clientType = this.platformUtilsService.getClientType(); + if (this.isValidSsoClientType(clientType)) { + this.clientId = clientType as SsoClientType; + } + } + + async ngOnInit() { + const qParams: QueryParams = await firstValueFrom(this.route.queryParams); + + // This if statement will pass on the second portion of the SSO flow + // where the user has already authenticated with the identity provider + if (this.hasCodeOrStateParams(qParams)) { + await this.handleCodeAndStateParams(qParams); + return; + } + + // This if statement will pass on the first portion of the SSO flow + if (this.hasRequiredSsoParams(qParams)) { + this.setRequiredSsoVariables(qParams); + return; + } + + if (qParams.identifier != null) { + // SSO Org Identifier in query params takes precedence over claimed domains + this.identifierFormControl.setValue(qParams.identifier); + this.loggingIn = true; + await this.submit(); + return; + } + + await this.initializeIdentifierFromEmailOrStorage(qParams); + } + + /** + * Sets the required SSO variables from the query params + * @param qParams - The query params + */ + private setRequiredSsoVariables(qParams: QueryParams): void { + this.redirectUri = qParams.redirectUri ?? ""; + this.state = qParams.state ?? ""; + this.codeChallenge = qParams.codeChallenge ?? ""; + const clientId = qParams.clientId ?? ""; + if (this.isValidSsoClientType(clientId)) { + this.clientId = clientId; + } else { + throw new Error(`Invalid SSO client type: ${qParams.clientId}`); + } + } + + /** + * Checks if the value is a valid SSO client type + * @param value - The value to check + * @returns True if the value is a valid SSO client type, otherwise false + */ + private isValidSsoClientType(value: string): value is SsoClientType { + return [ClientType.Web, ClientType.Browser, ClientType.Desktop].includes(value as ClientType); + } + + /** + * Checks if the query params have the required SSO params + * @param qParams - The query params + * @returns True if the query params have the required SSO params, false otherwise + */ + private hasRequiredSsoParams(qParams: QueryParams): boolean { + return ( + qParams.clientId != null && + qParams.redirectUri != null && + qParams.state != null && + qParams.codeChallenge != null + ); + } + + /** + * Handles the code and state params + * @param qParams - The query params + */ + private async handleCodeAndStateParams(qParams: QueryParams): Promise { + const codeVerifier = await this.ssoLoginService.getCodeVerifier(); + const state = await this.ssoLoginService.getSsoState(); + await this.ssoLoginService.setCodeVerifier(""); + await this.ssoLoginService.setSsoState(""); + + if (qParams.redirectUri != null) { + this.redirectUri = qParams.redirectUri; + } + + if ( + qParams.code != null && + codeVerifier != null && + state != null && + this.checkState(state, qParams.state ?? "") + ) { + const ssoOrganizationIdentifier = this.getOrgIdentifierFromState(qParams.state ?? ""); + await this.logIn(qParams.code, codeVerifier, ssoOrganizationIdentifier); + } + } + + /** + * Checks if the query params have a code or state + * @param qParams - The query params + * @returns True if the query params have a code or state, false otherwise + */ + private hasCodeOrStateParams(qParams: QueryParams): boolean { + return qParams.code != null && qParams.state != null; + } + + private handleGetClaimedDomainByEmailError(error: unknown): void { + if (error instanceof ErrorResponse) { + const errorResponse: ErrorResponse = error as ErrorResponse; + switch (errorResponse.statusCode) { + case HttpStatusCode.NotFound: + //this is a valid case for a domain not found + return; + + default: + this.validationService.showError(errorResponse); + break; + } + } + } + + submit = async (): Promise => { + if (this.formGroup.invalid) { + return; + } + + const autoSubmit = (await firstValueFrom(this.route.queryParams)).identifier != null; + + this.identifier = this.identifierFormControl.value ?? ""; + await this.ssoLoginService.setOrganizationSsoIdentifier(this.identifier); + this.ssoComponentService.setDocumentCookies?.(); + try { + await this.submitSso(); + } catch (error) { + if (autoSubmit) { + await this.router.navigate(["/login"]); + } else { + this.validationService.showError(error); + } + } + }; + + private async submitSso(returnUri?: string, includeUserIdentifier?: boolean) { + if (this.identifier == null || this.identifier === "") { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("ssoValidationFailed"), + message: this.i18nService.t("ssoIdentifierRequired"), + }); + return; + } + + if (this.clientId == null) { + throw new Error("Client ID is required"); + } + + this.initiateSsoFormPromise = this.apiService.preValidateSso(this.identifier); + const response = await this.initiateSsoFormPromise; + + const authorizeUrl = await this.buildAuthorizeUrl( + returnUri, + includeUserIdentifier, + response.token, + ); + this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true }); + } + + private async buildAuthorizeUrl( + returnUri?: string, + includeUserIdentifier?: boolean, + token?: string, + ): Promise { + let codeChallenge = this.codeChallenge; + let state = this.state; + + const passwordOptions = { + type: "password" as const, + length: 64, + uppercase: true, + lowercase: true, + numbers: true, + special: false, + }; + + if (codeChallenge == null) { + const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); + const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256"); + codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + await this.ssoLoginService.setCodeVerifier(codeVerifier); + } + + if (state == null) { + state = await this.passwordGenerationService.generatePassword(passwordOptions); + if (returnUri) { + state += `_returnUri='${returnUri}'`; + } + } + + // Add Organization Identifier to state + state += `_identifier=${this.identifier}`; + + // Save state (regardless of new or existing) + await this.ssoLoginService.setSsoState(state); + + const env = await firstValueFrom(this.environmentService.environment$); + + let authorizeUrl = + env.getIdentityUrl() + + "/connect/authorize?" + + "client_id=" + + this.clientId + + "&redirect_uri=" + + encodeURIComponent(this.redirectUri ?? "") + + "&" + + "response_type=code&scope=api offline_access&" + + "state=" + + state + + "&code_challenge=" + + codeChallenge + + "&" + + "code_challenge_method=S256&response_mode=query&" + + "domain_hint=" + + encodeURIComponent(this.identifier ?? "") + + "&ssoToken=" + + encodeURIComponent(token ?? ""); + + if (includeUserIdentifier) { + const userIdentifier = await this.apiService.getSsoUserIdentifier(); + authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`; + } + + return authorizeUrl; + } + + private async logIn(code: string, codeVerifier: string, orgSsoIdentifier: string): Promise { + this.loggingIn = true; + try { + const email = await this.ssoLoginService.getSsoEmail(); + const redirectUri = this.redirectUri ?? ""; + const credentials = new SsoLoginCredentials( + code, + codeVerifier, + redirectUri, + orgSsoIdentifier, + email, + ); + this.formPromise = this.loginStrategyService.logIn(credentials); + const authResult = await this.formPromise; + + if (authResult.requiresTwoFactor) { + return await this.handleTwoFactorRequired(orgSsoIdentifier); + } + + // Everything after the 2FA check is considered a successful login + // Just have to figure out where to send the user + + await this.syncService.fullSync(true); + + // Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere) + // - TDE login decryption options component + // - Browser SSO on extension open + // Note: you cannot set this in state before 2FA b/c there won't be an account in state. + await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier); + + // Users enrolled in admin acct recovery can be forced to set a new password after + // having the admin set a temp password for them (affects TDE & standard users) + if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) { + // Weak password is not a valid scenario here b/c we cannot have evaluated a MP yet + return await this.handleForcePasswordReset(orgSsoIdentifier); + } + + // must come after 2fa check since user decryption options aren't available if 2fa is required + const userDecryptionOpts = await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptions$, + ); + + const tdeEnabled = userDecryptionOpts.trustedDeviceOption + ? await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption) + : false; + + if (tdeEnabled) { + return await this.handleTrustedDeviceEncryptionEnabled(userDecryptionOpts); + } + + // In the standard, non TDE case, a user must set password if they don't + // have one and they aren't using key connector. + // Note: TDE & Key connector are mutually exclusive org config options. + const requireSetPassword = + !userDecryptionOpts.hasMasterPassword && + userDecryptionOpts.keyConnectorOption === undefined; + + if (requireSetPassword || authResult.resetMasterPassword) { + // Change implies going no password -> password in this case + return await this.handleChangePasswordRequired(orgSsoIdentifier); + } + + // Standard SSO login success case + return await this.handleSuccessfulLogin(); + } catch (e) { + await this.handleLoginError(e); + } + } + + private async isTrustedDeviceEncEnabled( + trustedDeviceOption: TrustedDeviceUserDecryptionOption, + ): Promise { + return trustedDeviceOption !== undefined; + } + + private async handleTwoFactorRequired(orgIdentifier: string) { + await this.router.navigate(["2fa"], { + queryParams: { + identifier: orgIdentifier, + sso: "true", + }, + }); + } + + private async handleTrustedDeviceEncryptionEnabled( + userDecryptionOpts: UserDecryptionOptions, + ): Promise { + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + + if (!userId) { + return; + } + + // Tde offboarding takes precedence + if ( + !userDecryptionOpts.hasMasterPassword && + userDecryptionOpts.trustedDeviceOption?.isTdeOffboarding + ) { + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.TdeOffboarding, + userId, + ); + } else if ( + // If user doesn't have a MP, but has reset password permission, they must set a MP + !userDecryptionOpts.hasMasterPassword && + userDecryptionOpts.trustedDeviceOption?.hasManageResetPasswordPermission + ) { + // Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device) + // Note: we cannot directly navigate in this scenario as we are in a pre-decryption state, and + // if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key. + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, + ); + } + + if (this.ssoComponentService?.closeWindow) { + await this.ssoComponentService.closeWindow(); + } else { + await this.router.navigate(["login-initiated"]); + } + } + + private async handleChangePasswordRequired(orgIdentifier: string) { + const emailVerification = await this.configService.getFeatureFlag( + FeatureFlag.EmailVerification, + ); + + let route = "set-password"; + if (emailVerification) { + route = "set-password-jit"; + } + + await this.router.navigate([route], { + queryParams: { + identifier: orgIdentifier, + }, + }); + } + + private async handleForcePasswordReset(orgIdentifier: string) { + await this.router.navigate(["update-temp-password"], { + queryParams: { + identifier: orgIdentifier, + }, + }); + } + + private async handleSuccessfulLogin() { + await this.router.navigate(["lock"]); + } + + private async handleLoginError(e: unknown) { + this.logService.error(e); + + // TODO: Key Connector Service should pass this error message to the logout callback instead of displaying here + if (e instanceof Error && e.message === "Key Connector error") { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("ssoKeyConnectorError"), + }); + } + } + + private getOrgIdentifierFromState(state: string): string { + if (state === null || state === undefined) { + return ""; + } + + const stateSplit = state.split("_identifier="); + return stateSplit.length > 1 ? stateSplit[1] : ""; + } + + private checkState(state: string, checkState: string): boolean { + if (state === null || state === undefined) { + return false; + } + if (checkState === null || checkState === undefined) { + return false; + } + + const stateSplit = state.split("_identifier="); + const checkStateSplit = checkState.split("_identifier="); + return stateSplit[0] === checkStateSplit[0]; + } + + /** + * Attempts to initialize the SSO identifier from email or storage. + * Note: this flow is written for web but both browser and desktop + * redirect here on SSO button click. + * @param qParams - The query params + */ + private async initializeIdentifierFromEmailOrStorage(qParams: QueryParams): Promise { + // Check if email matches any claimed domains + if (qParams.email) { + // show loading spinner + this.loggingIn = true; + try { + if (await this.configService.getFeatureFlag(FeatureFlag.VerifiedSsoDomainEndpoint)) { + const response: ListResponse = + await this.orgDomainApiService.getVerifiedOrgDomainsByEmail(qParams.email); + + if (response.data.length > 0) { + this.identifierFormControl.setValue(response.data[0].organizationIdentifier); + await this.submit(); + return; + } + } else { + const response: OrganizationDomainSsoDetailsResponse = + await this.orgDomainApiService.getClaimedOrgDomainByEmail(qParams.email); + + if (response?.ssoAvailable && response?.verifiedDate) { + this.identifierFormControl.setValue(response.organizationIdentifier); + await this.submit(); + return; + } + } + } catch (error) { + this.handleGetClaimedDomainByEmailError(error); + } + + this.loggingIn = false; + } + + // Fallback to state svc if domain is unclaimed + const storedIdentifier = await this.ssoLoginService.getOrganizationSsoIdentifier(); + if (storedIdentifier != null) { + this.identifierFormControl.setValue(storedIdentifier); + } + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index f79ebf8aa55..2c3f81c9c75 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -38,8 +38,10 @@ export enum FeatureFlag { NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss", NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss", DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship", + InlineMenuTotp = "inline-menu-totp", MacOsNativeCredentialSync = "macos-native-credential-sync", PM11360RemoveProviderExportPermission = "pm-11360-remove-provider-export-permission", + PM12443RemovePagingLogic = "pm-12443-remove-paging-logic", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -88,8 +90,10 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE, [FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE, [FeatureFlag.DisableFreeFamiliesSponsorship]: FALSE, + [FeatureFlag.InlineMenuTotp]: FALSE, [FeatureFlag.MacOsNativeCredentialSync]: FALSE, [FeatureFlag.PM11360RemoveProviderExportPermission]: FALSE, + [FeatureFlag.PM12443RemovePagingLogic]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index ad40466034b..dc0a8d61f64 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1948,7 +1948,6 @@ export class ApiService implements ApiServiceAbstraction { responseJson.error === "invalid_grant") ) { await this.logoutCallback("invalidGrantError"); - return null; } } diff --git a/libs/key-management/src/angular/lock/components/lock.component.ts b/libs/key-management/src/angular/lock/components/lock.component.ts index 0c2abbefa89..85f618a2478 100644 --- a/libs/key-management/src/angular/lock/components/lock.component.ts +++ b/libs/key-management/src/angular/lock/components/lock.component.ts @@ -533,8 +533,14 @@ export class LockV2Component implements OnInit, OnDestroy { if (this.clientType === "browser") { const previousUrl = this.lockComponentService.getPreviousUrl(); + /** + * In a passkey flow, the `previousUrl` will still be `/fido2?` at this point + * because the `/lockV2` route doesn't save the URL in the `BrowserRouterService`. This is + * handled by the `doNotSaveUrl` property on the `lockV2` route in `app-routing.module.ts`. + */ if (previousUrl) { await this.router.navigateByUrl(previousUrl); + return; } } diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index 93229bda6c3..32c1e7417e4 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -91,7 +91,8 @@ describe("ItemDetailsSectionComponent", () => { id: "col1", name: "Collection 1", organizationId: "org1", - canEditItems: (_org) => true, + assigned: true, + readOnly: false, } as CollectionView, ]; component.originalCipherView = { @@ -125,13 +126,15 @@ describe("ItemDetailsSectionComponent", () => { id: "col1", name: "Collection 1", organizationId: "org1", - canEditItems: (_org) => false, + assigned: true, + readOnly: true, } as CollectionView, { id: "col2", name: "Collection 2", organizationId: "org1", - canEditItems: (_org) => true, + assigned: true, + readOnly: false, } as CollectionView, ]; component.originalCipherView = { @@ -386,19 +389,22 @@ describe("ItemDetailsSectionComponent", () => { id: "col1", name: "Collection 1", organizationId: "org1", - canEditItems: (_org) => true, + assigned: true, + readOnly: false, } as CollectionView, { id: "col2", name: "Collection 2", organizationId: "org1", - canEditItems: (_org) => true, + assigned: true, + readOnly: false, } as CollectionView, { id: "col3", name: "Collection 3", organizationId: "org1", - canEditItems: (_org) => true, + assigned: true, + readOnly: false, } as CollectionView, ]; @@ -421,7 +427,8 @@ describe("ItemDetailsSectionComponent", () => { id: "col1", name: "Collection 1", organizationId: "org1", - canEditItems: (_org) => true, + assigned: true, + readOnly: false, } as CollectionView, ]; @@ -453,20 +460,22 @@ describe("ItemDetailsSectionComponent", () => { id: "col1", name: "Collection 1", organizationId: "org1", - canEditItems: (_org) => true, + assigned: true, + readOnly: false, } as CollectionView, { id: "col2", name: "Collection 2", organizationId: "org1", - canEditItems: (_org) => true, + assigned: true, + readOnly: false, } as CollectionView, { id: "col3", name: "Collection 3", organizationId: "org1", readOnly: true, - canEditItems: (_org) => true, + assigned: true, } as CollectionView, ]; @@ -490,21 +499,21 @@ describe("ItemDetailsSectionComponent", () => { name: "Collection 1", organizationId: "org1", readOnly: true, - canEditItems: (_org) => false, + assigned: false, } as CollectionView, { id: "col2", name: "Collection 2", organizationId: "org1", readOnly: true, - canEditItems: (_org) => false, + assigned: false, } as CollectionView, { id: "col3", name: "Collection 3", organizationId: "org1", readOnly: false, - canEditItems: (_org) => false, + assigned: true, } as CollectionView, ]; @@ -527,20 +536,20 @@ describe("ItemDetailsSectionComponent", () => { name: "Collection 1", organizationId: "org1", readOnly: true, - canEditItems: (_org) => false, + assigned: false, } as CollectionView, { id: "col2", name: "Collection 2", organizationId: "org1", - canEditItems: (_org) => false, + assigned: false, } as CollectionView, { id: "col3", name: "Collection 3", organizationId: "org1", readOnly: true, - canEditItems: (_org) => false, + assigned: false, } as CollectionView, ]; component.originalCipherView = { diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index b068b85c93d..f7fd228232e 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -273,19 +273,25 @@ export class ItemDetailsSectionComponent implements OnInit { this.showCollectionsControl = true; } - const organization = this.organizations.find((o) => o.id === orgId); - this.collectionOptions = this.collections .filter((c) => { - // Filter criteria: - // - The collection belongs to the organization - // - When in partial edit mode, show all org collections because the control is disabled. - // - The user can edit items within the collection - // - When viewing as an admin, all collections should be shown, even readonly. When non-admin, filter out readonly collections - return ( - c.organizationId === orgId && - (this.partialEdit || c.canEditItems(organization) || this.config.admin) - ); + // The collection belongs to the organization + if (c.organizationId !== orgId) { + return false; + } + + // When in partial edit mode, show all org collections because the control is disabled. + if (this.partialEdit) { + return true; + } + + // When viewing as an admin, all collections should be shown, even readonly. (AC Only) + if (this.config.admin) { + return true; + } + + // Non-admins can only select assigned collections that are not read only. (Non-AC) + return c.assigned && !c.readOnly; }) .map((c) => ({ id: c.id, diff --git a/package-lock.json b/package-lock.json index 64a7c926ca2..ff7dac2c461 100644 --- a/package-lock.json +++ b/package-lock.json @@ -132,7 +132,7 @@ "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", "css-loader": "7.1.2", - "electron": "32.1.1", + "electron": "33.2.1", "electron-builder": "24.13.3", "electron-log": "5.2.4", "electron-reload": "2.0.0-alpha.1", @@ -230,7 +230,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2024.12.0", + "version": "2024.12.1", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -15745,9 +15745,9 @@ } }, "node_modules/electron": { - "version": "32.1.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-32.1.1.tgz", - "integrity": "sha512-NlWvG6kXOJbZbELmzP3oV7u50I3NHYbCeh+AkUQ9vGyP7b74cFMx9HdTzejODeztW1jhr3SjIBbUZzZ45zflfQ==", + "version": "33.2.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.2.1.tgz", + "integrity": "sha512-SG/nmSsK9Qg1p6wAW+ZfqU+AV8cmXMTIklUL18NnOKfZLlum4ZsDoVdmmmlL39ZmeCaq27dr7CgslRPahfoVJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -15986,9 +15986,9 @@ } }, "node_modules/electron/node_modules/@types/node": { - "version": "20.17.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz", - "integrity": "sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==", + "version": "20.17.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.8.tgz", + "integrity": "sha512-ahz2g6/oqbKalW9sPv6L2iRbhLnojxjYWspAqhjvqSWBgGebEJT5GvRmk0QXPj3sbC6rU0GTQjPLQkmR8CObvA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 5573332db1a..aa567f18df6 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "copy-webpack-plugin": "12.0.2", "cross-env": "7.0.3", "css-loader": "7.1.2", - "electron": "32.1.1", + "electron": "33.2.1", "electron-builder": "24.13.3", "electron-log": "5.2.4", "electron-reload": "2.0.0-alpha.1",