From 5dbcb18b6a77d12b02ba3b6a72b218d5e8742bc3 Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:16:21 -0500 Subject: [PATCH 01/60] [PM-25037] add optional size input to app-vault-icon to prevent zoom issues (#17640) --- .../app-table-row-scrollable.component.html | 6 ++++- .../src/vault/components/icon.component.html | 11 +++----- .../src/vault/components/icon.component.ts | 26 ++++++++++++++++++- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html index 76a03e0c525..0494f77bd46 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html @@ -45,7 +45,11 @@ tabindex="0" [attr.aria-label]="'viewItem' | i18n" > - + - } @else { -
- + +
+
+ +
+
- } diff --git a/apps/browser/src/popup/scss/base.scss b/apps/browser/src/popup/scss/base.scss deleted file mode 100644 index 01b9d3f05d5..00000000000 --- a/apps/browser/src/popup/scss/base.scss +++ /dev/null @@ -1,453 +0,0 @@ -@import "variables.scss"; - -* { - box-sizing: border-box; - padding: 0; - margin: 0; -} - -html { - overflow: hidden; - min-height: 600px; - height: 100%; - - &.body-sm { - min-height: 500px; - } - - &.body-xs { - min-height: 400px; - } - - &.body-xxs { - min-height: 300px; - } - - &.body-3xs { - min-height: 240px; - } - - &.body-full { - min-height: unset; - width: 100%; - height: 100%; - - & body { - width: 100%; - } - } -} - -html, -body { - font-family: $font-family-sans-serif; - font-size: $font-size-base; - line-height: $line-height-base; - -webkit-font-smoothing: antialiased; -} - -body { - width: 380px; - height: 100%; - position: relative; - min-height: inherit; - overflow: hidden; - color: $text-color; - background-color: $background-color; - - @include themify($themes) { - color: themed("textColor"); - background-color: themed("backgroundColor"); - } -} - -h1, -h2, -h3, -h4, -h5, -h6 { - font-family: $font-family-sans-serif; - font-size: $font-size-base; - font-weight: normal; -} - -p { - margin-bottom: 10px; -} - -ul, -ol { - margin-bottom: 10px; -} - -img { - border: none; -} - -a:not(popup-page a, popup-tab-navigation a) { - text-decoration: none; - - @include themify($themes) { - color: themed("primaryColor"); - } - - &:hover, - &:focus { - @include themify($themes) { - color: darken(themed("primaryColor"), 6%); - } - } -} - -input:not(bit-form-field input, bit-search input, input[bitcheckbox]), -select:not(bit-form-field select), -textarea:not(bit-form-field textarea) { - @include themify($themes) { - color: themed("textColor"); - background-color: themed("inputBackgroundColor"); - } -} - -input:not(input[bitcheckbox]), -select, -textarea, -button:not(bit-chip-select button) { - font-size: $font-size-base; - font-family: $font-family-sans-serif; -} - -input[type*="date"] { - @include themify($themes) { - color-scheme: themed("dateInputColorScheme"); - } -} - -::-webkit-calendar-picker-indicator { - @include themify($themes) { - filter: themed("webkitCalendarPickerFilter"); - } -} - -::-webkit-calendar-picker-indicator:hover { - @include themify($themes) { - filter: themed("webkitCalendarPickerHoverFilter"); - } - cursor: pointer; -} - -select { - width: 100%; - padding: 0.35rem; -} - -button { - cursor: pointer; -} - -textarea { - resize: vertical; -} - -app-root > div { - height: 100%; - width: 100%; -} - -main::-webkit-scrollbar, -cdk-virtual-scroll-viewport::-webkit-scrollbar, -.vault-select::-webkit-scrollbar { - width: 10px; - height: 10px; -} - -main::-webkit-scrollbar-track, -.vault-select::-webkit-scrollbar-track { - background-color: transparent; -} - -cdk-virtual-scroll-viewport::-webkit-scrollbar-track { - @include themify($themes) { - background-color: themed("backgroundColor"); - } -} - -main::-webkit-scrollbar-thumb, -cdk-virtual-scroll-viewport::-webkit-scrollbar-thumb, -.vault-select::-webkit-scrollbar-thumb { - border-radius: 10px; - margin-right: 1px; - - @include themify($themes) { - background-color: themed("scrollbarColor"); - } - - &:hover { - @include themify($themes) { - background-color: themed("scrollbarHoverColor"); - } - } -} - -header:not(bit-callout header, bit-dialog header, popup-page header) { - height: 44px; - display: flex; - - &:not(.no-theme) { - border-bottom: 1px solid #000000; - - @include themify($themes) { - color: themed("headerColor"); - background-color: themed("headerBackgroundColor"); - border-bottom-color: themed("headerBorderColor"); - } - } - - .header-content { - display: flex; - flex: 1 1 auto; - } - - .header-content > .right, - .header-content > .right > .right { - height: 100%; - } - - .left, - .right { - flex: 1; - display: flex; - min-width: -webkit-min-content; /* Workaround to Chrome bug */ - .header-icon { - margin-right: 5px; - } - } - - .right { - justify-content: flex-end; - align-items: center; - app-avatar { - max-height: 30px; - margin-right: 5px; - } - } - - .center { - display: flex; - align-items: center; - text-align: center; - min-width: 0; - } - - .login-center { - margin: auto; - } - - app-pop-out > button, - div > button:not(app-current-account button):not(.home-acc-switcher-btn), - div > a { - border: none; - padding: 0 10px; - text-decoration: none; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - height: 100%; - white-space: pre; - - &:not(.home-acc-switcher-btn):hover, - &:not(.home-acc-switcher-btn):focus { - @include themify($themes) { - background-color: themed("headerBackgroundHoverColor"); - color: themed("headerColor"); - } - } - - &[disabled] { - opacity: 0.65; - cursor: default !important; - background-color: inherit !important; - } - - i + span { - margin-left: 5px; - } - } - - app-pop-out { - display: flex; - padding-right: 0.5em; - } - - .title { - font-weight: bold; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .search { - padding: 7px 10px; - width: 100%; - text-align: left; - position: relative; - display: flex; - - .bwi { - position: absolute; - top: 15px; - left: 20px; - - @include themify($themes) { - color: themed("headerInputPlaceholderColor"); - } - } - - input:not(bit-form-field input) { - width: 100%; - margin: 0; - border: none; - padding: 5px 10px 5px 30px; - border-radius: $border-radius; - - @include themify($themes) { - background-color: themed("headerInputBackgroundColor"); - color: themed("headerInputColor"); - } - - &::selection { - @include themify($themes) { - // explicitly set text selection to invert foreground/background - background-color: themed("headerInputColor"); - color: themed("headerInputBackgroundColor"); - } - } - - &:focus { - border-radius: $border-radius; - outline: none; - - @include themify($themes) { - background-color: themed("headerInputBackgroundFocusColor"); - } - } - - &::-webkit-input-placeholder { - @include themify($themes) { - color: themed("headerInputPlaceholderColor"); - } - } - /** make the cancel button visible in both dark/light themes **/ - &[type="search"]::-webkit-search-cancel-button { - -webkit-appearance: none; - appearance: none; - height: 15px; - width: 15px; - background-repeat: no-repeat; - mask-image: url("../images/close-button-white.svg"); - -webkit-mask-image: url("../images/close-button-white.svg"); - @include themify($themes) { - background-color: themed("headerInputColor"); - } - } - } - } - - .left + .search, - .left + .sr-only + .search { - padding-left: 0; - - .bwi { - left: 10px; - } - } - - .search + .right { - margin-left: -10px; - } -} - -.content { - padding: 15px 5px; -} - -app-root { - width: 100%; - height: 100vh; - display: flex; - - @include themify($themes) { - background-color: themed("backgroundColor"); - } -} - -main:not(popup-page main):not(auth-anon-layout main) { - position: absolute; - top: 44px; - bottom: 0; - left: 0; - right: 0; - overflow-y: auto; - overflow-x: hidden; - - @include themify($themes) { - background-color: themed("backgroundColor"); - } - - &.no-header { - top: 0; - } - - &.flex { - display: flex; - flex-flow: column; - height: calc(100% - 44px); - } -} - -.center-content, -.no-items, -.full-loading-spinner { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - flex-direction: column; - flex-grow: 1; -} - -.no-items, -.full-loading-spinner { - text-align: center; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - - .no-items-image { - @include themify($themes) { - content: url("../images/search-desktop" + themed("svgSuffix")); - } - } - - .bwi { - margin-bottom: 10px; - - @include themify($themes) { - color: themed("disabledIconColor"); - } - } -} - -// cdk-virtual-scroll -.cdk-virtual-scroll-viewport { - width: 100%; - height: 100%; - overflow-y: auto; - overflow-x: hidden; -} - -.cdk-virtual-scroll-content-wrapper { - width: 100%; -} diff --git a/apps/browser/src/popup/scss/box.scss b/apps/browser/src/popup/scss/box.scss deleted file mode 100644 index 763f73a15cb..00000000000 --- a/apps/browser/src/popup/scss/box.scss +++ /dev/null @@ -1,620 +0,0 @@ -@import "variables.scss"; - -.box { - position: relative; - width: 100%; - - &.first { - margin-top: 0; - } - - .box-header { - margin: 0 10px 5px 10px; - text-transform: uppercase; - display: flex; - - @include themify($themes) { - color: themed("headingColor"); - } - } - - .box-content { - @include themify($themes) { - background-color: themed("backgroundColor"); - border-color: themed("borderColor"); - } - - &.box-content-padded { - padding: 10px 15px; - } - - &.condensed .box-content-row, - .box-content-row.condensed { - padding-top: 5px; - padding-bottom: 5px; - } - - &.no-hover .box-content-row, - .box-content-row.no-hover { - &:hover, - &:focus { - @include themify($themes) { - background-color: themed("boxBackgroundColor") !important; - } - } - } - - &.single-line .box-content-row, - .box-content-row.single-line { - padding-top: 10px; - padding-bottom: 10px; - margin: 5px; - } - - &.row-top-padding { - padding-top: 10px; - } - } - - .box-footer { - margin: 0 5px 5px 5px; - padding: 0 10px 5px 10px; - font-size: $font-size-small; - - button.btn { - font-size: $font-size-small; - padding: 0; - } - - button.btn.primary { - font-size: $font-size-base; - padding: 7px 15px; - width: 100%; - - &:hover { - @include themify($themes) { - border-color: themed("borderHoverColor") !important; - } - } - } - - @include themify($themes) { - color: themed("mutedColor"); - } - } - - &.list { - margin: 10px 0 20px 0; - .box-content { - .virtual-scroll-item { - display: inline-block; - width: 100%; - } - - .box-content-row { - text-decoration: none; - border-radius: $border-radius; - // background-color: $background-color; - - @include themify($themes) { - color: themed("textColor"); - background-color: themed("boxBackgroundColor"); - } - - &.padded { - padding-top: 10px; - padding-bottom: 10px; - } - - &.no-hover { - &:hover { - @include themify($themes) { - background-color: themed("boxBackgroundColor") !important; - } - } - } - - &:hover, - &:focus, - &.active { - @include themify($themes) { - background-color: themed("listItemBackgroundHoverColor"); - } - } - - &:focus { - border-left: 5px solid #000000; - padding-left: 5px; - - @include themify($themes) { - border-left-color: themed("mutedColor"); - } - } - - .action-buttons { - .row-btn { - padding-left: 5px; - padding-right: 5px; - } - } - - .text:not(.no-ellipsis), - .detail { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .row-main { - display: flex; - min-width: 0; - align-items: normal; - - .row-main-content { - min-width: 0; - } - } - } - - &.single-line { - .box-content-row { - display: flex; - padding-top: 10px; - padding-bottom: 10px; - margin: 5px; - border-radius: $border-radius; - } - } - } - } -} - -.box-content-row { - display: block; - padding: 5px 10px; - position: relative; - z-index: 1; - border-radius: $border-radius; - margin: 3px 5px; - - @include themify($themes) { - background-color: themed("boxBackgroundColor"); - } - - &:last-child { - &:before { - border: none; - height: 0; - } - } - - &.override-last:last-child:before { - border-bottom: 1px solid #000000; - @include themify($themes) { - border-bottom-color: themed("boxBorderColor"); - } - } - - &.last:last-child:before { - border-bottom: 1px solid #000000; - @include themify($themes) { - border-bottom-color: themed("boxBorderColor"); - } - } - - &:after { - content: ""; - display: table; - clear: both; - } - - &:hover, - &:focus, - &.active { - @include themify($themes) { - background-color: themed("boxBackgroundHoverColor"); - } - } - - &.pre { - white-space: pre; - overflow-x: auto; - } - - &.pre-wrap { - white-space: pre-wrap; - overflow-x: auto; - } - - .row-label, - label { - font-size: $font-size-small; - display: block; - width: 100%; - margin-bottom: 5px; - - @include themify($themes) { - color: themed("mutedColor"); - } - - .sub-label { - margin-left: 10px; - } - } - - .flex-label { - font-size: $font-size-small; - display: flex; - flex-grow: 1; - margin-bottom: 5px; - - @include themify($themes) { - color: themed("mutedColor"); - } - - > a { - flex-grow: 0; - } - } - - .text, - .detail { - display: block; - text-align: left; - - @include themify($themes) { - color: themed("textColor"); - } - } - - .detail { - font-size: $font-size-small; - - @include themify($themes) { - color: themed("mutedColor"); - } - } - - .img-right, - .txt-right { - float: right; - margin-left: 10px; - } - - .row-main { - flex-grow: 1; - min-width: 0; - } - - &.box-content-row-flex, - .box-content-row-flex, - &.box-content-row-checkbox, - &.box-content-row-link, - &.box-content-row-input, - &.box-content-row-slider, - &.box-content-row-multi { - display: flex; - align-items: center; - word-break: break-all; - - &.box-content-row-word-break { - word-break: normal; - } - } - - &.box-content-row-multi { - input:not([type="checkbox"]) { - width: 100%; - } - - input + label.sr-only + select { - margin-top: 5px; - } - - > a, - > button { - padding: 8px 8px 8px 4px; - margin: 0; - - @include themify($themes) { - color: themed("dangerColor"); - } - } - } - - &.box-content-row-multi, - &.box-content-row-newmulti { - padding-left: 10px; - } - - &.box-content-row-newmulti { - @include themify($themes) { - color: themed("primaryColor"); - } - } - - &.box-content-row-checkbox, - &.box-content-row-link, - &.box-content-row-input, - &.box-content-row-slider { - padding-top: 10px; - padding-bottom: 10px; - margin: 5px; - - label, - .row-label { - font-size: $font-size-base; - display: block; - width: initial; - margin-bottom: 0; - - @include themify($themes) { - color: themed("textColor"); - } - } - - > span { - @include themify($themes) { - color: themed("mutedColor"); - } - } - - > input { - margin: 0 0 0 auto; - padding: 0; - } - - > * { - margin-right: 15px; - - &:last-child { - margin-right: 0; - } - } - } - - &.box-content-row-checkbox-left { - justify-content: flex-start; - - > input { - margin: 0 15px 0 0; - } - } - - &.box-content-row-input { - label { - white-space: nowrap; - } - - input { - text-align: right; - - &[type="number"] { - max-width: 50px; - } - } - } - - &.box-content-row-slider { - input[type="range"] { - height: 10px; - } - - input[type="number"] { - width: 45px; - } - - label { - white-space: nowrap; - } - } - - input:not([type="checkbox"]):not([type="radio"]), - textarea { - border: none; - width: 100%; - background-color: transparent !important; - - &::-webkit-input-placeholder { - @include themify($themes) { - color: themed("inputPlaceholderColor"); - } - } - - &:not([type="file"]):focus { - outline: none; - } - } - - select { - width: 100%; - border: 1px solid #000000; - border-radius: $border-radius; - padding: 7px 4px; - - @include themify($themes) { - border-color: themed("inputBorderColor"); - } - } - - .action-buttons { - display: flex; - margin-left: 5px; - - &.action-buttons-fixed { - align-self: start; - margin-top: 2px; - } - - .row-btn { - cursor: pointer; - padding: 10px 8px; - background: none; - border: none; - - @include themify($themes) { - color: themed("boxRowButtonColor"); - } - - &:hover, - &:focus { - @include themify($themes) { - color: themed("boxRowButtonHoverColor"); - } - } - - &.disabled, - &[disabled] { - @include themify($themes) { - color: themed("disabledIconColor"); - opacity: themed("disabledBoxOpacity"); - } - - &:hover { - @include themify($themes) { - color: themed("disabledIconColor"); - opacity: themed("disabledBoxOpacity"); - } - } - cursor: default !important; - } - } - - &.no-pad .row-btn { - padding-top: 0; - padding-bottom: 0; - } - } - - &:not(.box-draggable-row) { - .action-buttons .row-btn:last-child { - margin-right: -3px; - } - } - - &.box-draggable-row { - &.box-content-row-checkbox { - input[type="checkbox"] + .drag-handle { - margin-left: 10px; - } - } - } - - .drag-handle { - cursor: move; - padding: 10px 2px 10px 8px; - user-select: none; - - @include themify($themes) { - color: themed("mutedColor"); - } - } - - &.cdk-drag-preview { - position: relative; - display: flex; - align-items: center; - opacity: 0.8; - - @include themify($themes) { - background-color: themed("boxBackgroundColor"); - } - } - - select.field-type { - margin: 5px 0 0 25px; - width: calc(100% - 25px); - } - - .icon { - display: flex; - justify-content: center; - align-items: center; - min-width: 34px; - margin-left: -5px; - - @include themify($themes) { - color: themed("mutedColor"); - } - - &.icon-small { - min-width: 25px; - } - - img { - border-radius: $border-radius; - max-height: 20px; - max-width: 20px; - } - } - - .progress { - display: flex; - height: 5px; - overflow: hidden; - margin: 5px -15px -10px; - - .progress-bar { - display: flex; - flex-direction: column; - justify-content: center; - white-space: nowrap; - background-color: $brand-primary; - } - } - - .radio-group { - display: flex; - justify-content: flex-start; - align-items: center; - margin-bottom: 5px; - - input { - flex-grow: 0; - } - - label { - margin: 0 0 0 5px; - flex-grow: 1; - font-size: $font-size-base; - display: block; - width: 100%; - - @include themify($themes) { - color: themed("textColor"); - } - } - - &.align-start { - align-items: start; - margin-top: 10px; - - label { - margin-top: -4px; - } - } - } -} - -.truncate { - display: inline-block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -form { - .box { - .box-content { - .box-content-row { - &.no-hover { - &:hover { - @include themify($themes) { - background-color: themed("transparentColor") !important; - } - } - } - } - } - } -} diff --git a/apps/browser/src/popup/scss/buttons.scss b/apps/browser/src/popup/scss/buttons.scss deleted file mode 100644 index e9af536dc3d..00000000000 --- a/apps/browser/src/popup/scss/buttons.scss +++ /dev/null @@ -1,118 +0,0 @@ -@import "variables.scss"; - -.btn { - border-radius: $border-radius; - padding: 7px 15px; - border: 1px solid #000000; - font-size: $font-size-base; - text-align: center; - cursor: pointer; - - @include themify($themes) { - background-color: themed("buttonBackgroundColor"); - border-color: themed("buttonBorderColor"); - color: themed("buttonColor"); - } - - &.primary { - @include themify($themes) { - color: themed("buttonPrimaryColor"); - } - } - - &.danger { - @include themify($themes) { - color: themed("buttonDangerColor"); - } - } - - &.callout-half { - font-weight: bold; - max-width: 50%; - } - - &:hover:not([disabled]) { - cursor: pointer; - - @include themify($themes) { - background-color: darken(themed("buttonBackgroundColor"), 1.5%); - border-color: darken(themed("buttonBorderColor"), 17%); - color: darken(themed("buttonColor"), 10%); - } - - &.primary { - @include themify($themes) { - color: darken(themed("buttonPrimaryColor"), 6%); - } - } - - &.danger { - @include themify($themes) { - color: darken(themed("buttonDangerColor"), 6%); - } - } - } - - &:focus:not([disabled]) { - cursor: pointer; - outline: 0; - - @include themify($themes) { - background-color: darken(themed("buttonBackgroundColor"), 6%); - border-color: darken(themed("buttonBorderColor"), 25%); - } - } - - &[disabled] { - opacity: 0.65; - cursor: default !important; - } - - &.block { - display: block; - width: calc(100% - 10px); - margin: 0 auto; - } - - &.link, - &.neutral { - border: none !important; - background: none !important; - - &:focus { - text-decoration: underline; - } - } -} - -.action-buttons { - .btn { - &:focus { - outline: auto; - } - } -} - -button.box-content-row { - display: block; - width: calc(100% - 10px); - text-align: left; - border-color: none; - - @include themify($themes) { - background-color: themed("boxBackgroundColor"); - } -} - -button { - border: none; - background: transparent; - color: inherit; -} - -.login-buttons { - .btn.block { - width: 100%; - margin-bottom: 10px; - } -} diff --git a/apps/browser/src/popup/scss/environment.scss b/apps/browser/src/popup/scss/environment.scss deleted file mode 100644 index cd8f6379e2c..00000000000 --- a/apps/browser/src/popup/scss/environment.scss +++ /dev/null @@ -1,43 +0,0 @@ -@import "variables.scss"; - -html.browser_safari { - &.safari_height_fix { - body { - height: 360px !important; - - &.body-xs { - height: 300px !important; - } - - &.body-full { - height: 100% !important; - } - } - } - - header { - .search .bwi { - left: 20px; - } - - .left + .search .bwi { - left: 10px; - } - } - - .content { - &.login-page { - padding-top: 100px; - } - } - - app-root { - border-width: 1px; - border-style: solid; - border-color: #000000; - } - - &.theme_light app-root { - border-color: #777777; - } -} diff --git a/apps/browser/src/popup/scss/grid.scss b/apps/browser/src/popup/scss/grid.scss deleted file mode 100644 index 8cdb29bb52c..00000000000 --- a/apps/browser/src/popup/scss/grid.scss +++ /dev/null @@ -1,11 +0,0 @@ -.row { - display: flex; - margin: 0 -15px; - width: 100%; -} - -.col { - flex-basis: 0; - flex-grow: 1; - padding: 0 15px; -} diff --git a/apps/browser/src/popup/scss/misc.scss b/apps/browser/src/popup/scss/misc.scss deleted file mode 100644 index 006e1d35f6a..00000000000 --- a/apps/browser/src/popup/scss/misc.scss +++ /dev/null @@ -1,348 +0,0 @@ -@import "variables.scss"; - -small, -.small { - font-size: $font-size-small; -} - -.bg-primary { - @include themify($themes) { - background-color: themed("primaryColor") !important; - } -} - -.bg-success { - @include themify($themes) { - background-color: themed("successColor") !important; - } -} - -.bg-danger { - @include themify($themes) { - background-color: themed("dangerColor") !important; - } -} - -.bg-info { - @include themify($themes) { - background-color: themed("infoColor") !important; - } -} - -.bg-warning { - @include themify($themes) { - background-color: themed("warningColor") !important; - } -} - -.text-primary { - @include themify($themes) { - color: themed("primaryColor") !important; - } -} - -.text-success { - @include themify($themes) { - color: themed("successColor") !important; - } -} - -.text-muted { - @include themify($themes) { - color: themed("mutedColor") !important; - } -} - -.text-default { - @include themify($themes) { - color: themed("textColor") !important; - } -} - -.text-danger { - @include themify($themes) { - color: themed("dangerColor") !important; - } -} - -.text-info { - @include themify($themes) { - color: themed("infoColor") !important; - } -} - -.text-warning { - @include themify($themes) { - color: themed("warningColor") !important; - } -} - -.text-center { - text-align: center; -} - -.font-weight-semibold { - font-weight: 600; -} - -p.lead { - font-size: $font-size-large; - margin-bottom: 20px; - font-weight: normal; -} - -.flex-right { - margin-left: auto; -} - -.flex-bottom { - margin-top: auto; -} - -.no-margin { - margin: 0 !important; -} - -.display-block { - display: block !important; -} - -.monospaced { - font-family: $font-family-monospace; -} - -.show-whitespace { - white-space: pre-wrap; -} - -.img-responsive { - display: block; - max-width: 100%; - height: auto; -} - -.img-rounded { - border-radius: $border-radius; -} - -.select-index-top { - position: relative; - z-index: 100; -} - -.sr-only { - position: absolute !important; - width: 1px !important; - height: 1px !important; - padding: 0 !important; - margin: -1px !important; - overflow: hidden !important; - clip: rect(0, 0, 0, 0) !important; - border: 0 !important; -} - -:not(:focus) > .exists-only-on-parent-focus { - display: none; -} - -.password-wrapper { - overflow-wrap: break-word; - white-space: pre-wrap; - min-width: 0; -} - -.password-number { - @include themify($themes) { - color: themed("passwordNumberColor"); - } -} - -.password-special { - @include themify($themes) { - color: themed("passwordSpecialColor"); - } -} - -.password-character { - display: inline-flex; - flex-direction: column; - align-items: center; - width: 30px; - height: 36px; - font-weight: 600; - - &:nth-child(odd) { - @include themify($themes) { - background-color: themed("backgroundColor"); - } - } -} - -.password-count { - white-space: nowrap; - font-size: 8px; - - @include themify($themes) { - color: themed("passwordCountText") !important; - } -} - -#duo-frame { - background: url("../images/loading.svg") 0 0 no-repeat; - width: 100%; - height: 470px; - margin-bottom: -10px; - - iframe { - width: 100%; - height: 100%; - border: none; - } -} - -#web-authn-frame { - width: 100%; - height: 40px; - - iframe { - border: none; - height: 100%; - width: 100%; - } -} - -body.linux-webauthn { - width: 485px !important; - #web-authn-frame { - iframe { - width: 375px; - margin: 0 55px; - } - } -} - -app-root > #loading { - display: flex; - text-align: center; - justify-content: center; - align-items: center; - height: 100%; - width: 100%; - color: $text-muted; - - @include themify($themes) { - color: themed("mutedColor"); - } -} - -app-vault-icon, -.app-vault-icon { - display: flex; -} - -.logo-image { - margin: 0 auto; - width: 142px; - height: 21px; - background-size: 142px 21px; - background-repeat: no-repeat; - @include themify($themes) { - background-image: url("../images/logo-" + themed("logoSuffix") + "@2x.png"); - } - @media (min-width: 219px) { - width: 189px; - height: 28px; - background-size: 189px 28px; - } - @media (min-width: 314px) { - width: 284px; - height: 43px; - background-size: 284px 43px; - } -} - -[hidden] { - display: none !important; -} - -.draggable { - cursor: move; -} - -input[type="password"]::-ms-reveal { - display: none; -} - -.flex { - display: flex; - - &.flex-grow { - > * { - flex: 1; - } - } -} - -// Text selection styles -// Set explicit selection styles (assumes primary accent color has sufficient -// contrast against the background, so its inversion is also still readable) -// and suppress user selection for most elements (to make it more app-like) - -:not(bit-form-field input)::selection { - @include themify($themes) { - color: themed("backgroundColor"); - background-color: themed("primaryAccentColor"); - } -} - -h1, -h2, -h3, -label, -a, -button, -p, -img, -.box-header, -.box-footer, -.callout, -.row-label, -.modal-title, -.overlay-container { - user-select: none; - - &.user-select { - user-select: auto; - } -} - -/* tweak for inconsistent line heights in cipher view */ -.box-footer button, -.box-footer a { - line-height: 1; -} - -// Workaround for slow performance on external monitors on Chrome + MacOS -// See: https://bugs.chromium.org/p/chromium/issues/detail?id=971701#c64 -@keyframes redraw { - 0% { - opacity: 0.99; - } - 100% { - opacity: 1; - } -} -html.force_redraw { - animation: redraw 1s linear infinite; -} - -/* override for vault icon in browser (pre extension refresh) */ -app-vault-icon:not(app-vault-list-items-container app-vault-icon) > div { - display: flex; - justify-content: center; - align-items: center; - float: left; - height: 36px; - width: 34px; - margin-left: -5px; -} diff --git a/apps/browser/src/popup/scss/pages.scss b/apps/browser/src/popup/scss/pages.scss deleted file mode 100644 index 56c5f80c86c..00000000000 --- a/apps/browser/src/popup/scss/pages.scss +++ /dev/null @@ -1,144 +0,0 @@ -@import "variables.scss"; - -app-home { - position: fixed; - height: 100%; - width: 100%; - - .center-content { - margin-top: -50px; - height: calc(100% + 50px); - } - - img { - width: 284px; - margin: 0 auto; - } - - p.lead { - margin: 30px 0; - } - - .btn + .btn { - margin-top: 10px; - } - - button.settings-icon { - position: absolute; - top: 10px; - left: 10px; - - @include themify($themes) { - color: themed("mutedColor"); - } - - &:not(:hover):not(:focus) { - span { - clip: rect(0 0 0 0); - clip-path: inset(50%); - height: 1px; - overflow: hidden; - position: absolute; - white-space: nowrap; - width: 1px; - } - } - - &:hover, - &:focus { - text-decoration: none; - - @include themify($themes) { - color: themed("primaryColor"); - } - } - } -} - -body.body-sm, -body.body-xs { - app-home { - .center-content { - margin-top: 0; - height: 100%; - } - - p.lead { - margin: 15px 0; - } - } -} - -body.body-full { - app-home { - .center-content { - margin-top: -80px; - height: calc(100% + 80px); - } - } -} - -.createAccountLink { - padding: 30px 10px 0 10px; -} - -.remember-email-check { - padding-top: 18px; - padding-left: 10px; - padding-bottom: 18px; -} - -.login-buttons > button { - margin: 15px 0 15px 0; -} - -.useBrowserlink { - margin-left: 5px; - margin-top: 20px; - - span { - font-weight: 700; - font-size: $font-size-small; - } -} - -.fido2-browser-selector-dropdown { - @include themify($themes) { - background-color: themed("boxBackgroundColor"); - } - padding: 8px; - width: 100%; - box-shadow: - 0 2px 2px 0 rgba(0, 0, 0, 0.14), - 0 3px 1px -2px rgba(0, 0, 0, 0.12), - 0 1px 5px 0 rgba(0, 0, 0, 0.2); - border-radius: $border-radius; -} - -.fido2-browser-selector-dropdown-item { - @include themify($themes) { - color: themed("textColor") !important; - } - width: 100%; - text-align: left; - padding: 0px 15px 0px 5px; - margin-bottom: 5px; - border-radius: 3px; - border: 1px solid transparent; - transition: all 0.2s ease-in-out; - - &:hover { - @include themify($themes) { - background-color: themed("listItemBackgroundHoverColor") !important; - } - } - - &:last-child { - margin-bottom: 0; - } -} - -/** Temporary fix for avatar, will not be required once we migrate to tailwind preflight **/ -bit-avatar svg { - display: block; -} diff --git a/apps/browser/src/popup/scss/plugins.scss b/apps/browser/src/popup/scss/plugins.scss deleted file mode 100644 index 591e8a1bd0c..00000000000 --- a/apps/browser/src/popup/scss/plugins.scss +++ /dev/null @@ -1,23 +0,0 @@ -@import "variables.scss"; - -@each $mfaType in $mfaTypes { - .mfaType#{$mfaType} { - content: url("../images/two-factor/" + $mfaType + ".png"); - max-width: 100px; - } -} - -.mfaType1 { - @include themify($themes) { - content: url("../images/two-factor/1" + themed("mfaLogoSuffix")); - max-width: 100px; - max-height: 45px; - } -} - -.mfaType7 { - @include themify($themes) { - content: url("../images/two-factor/7" + themed("mfaLogoSuffix")); - max-width: 100px; - } -} diff --git a/apps/browser/src/popup/scss/popup.scss b/apps/browser/src/popup/scss/popup.scss index b150de2c75d..59b4d472f23 100644 --- a/apps/browser/src/popup/scss/popup.scss +++ b/apps/browser/src/popup/scss/popup.scss @@ -1,13 +1,50 @@ @import "../../../../../libs/angular/src/scss/bwicons/styles/style.scss"; @import "variables.scss"; @import "../../../../../libs/angular/src/scss/icons.scss"; -@import "base.scss"; -@import "grid.scss"; -@import "box.scss"; -@import "buttons.scss"; -@import "misc.scss"; -@import "environment.scss"; -@import "pages.scss"; -@import "plugins.scss"; @import "@angular/cdk/overlay-prebuilt.css"; @import "../../../../../libs/components/src/multi-select/scss/bw.theme"; + +.cdk-virtual-scroll-content-wrapper { + width: 100%; +} + +// MFA Types for logo styling with no dark theme alternative +$mfaTypes: 0, 2, 3, 4, 6; + +@each $mfaType in $mfaTypes { + .mfaType#{$mfaType} { + content: url("../images/two-factor/" + $mfaType + ".png"); + max-width: 100px; + } +} + +.mfaType0 { + content: url("../images/two-factor/0.png"); + max-width: 100px; + max-height: 45px; +} + +.mfaType1 { + max-width: 100px; + max-height: 45px; + + &:is(.theme_light *) { + content: url("../images/two-factor/1.png"); + } + + &:is(.theme_dark *) { + content: url("../images/two-factor/1-w.png"); + } +} + +.mfaType7 { + max-width: 100px; + + &:is(.theme_light *) { + content: url("../images/two-factor/7.png"); + } + + &:is(.theme_dark *) { + content: url("../images/two-factor/7-w.png"); + } +} diff --git a/apps/browser/src/popup/scss/tailwind.css b/apps/browser/src/popup/scss/tailwind.css index 54139990356..f58950cc86a 100644 --- a/apps/browser/src/popup/scss/tailwind.css +++ b/apps/browser/src/popup/scss/tailwind.css @@ -1,4 +1,104 @@ -@import "../../../../../libs/components/src/tw-theme.css"; +@import "../../../../../libs/components/src/tw-theme-preflight.css"; + +@layer base { + html { + overflow: hidden; + min-height: 600px; + height: 100%; + + &.body-sm { + min-height: 500px; + } + + &.body-xs { + min-height: 400px; + } + + &.body-xxs { + min-height: 300px; + } + + &.body-3xs { + min-height: 240px; + } + + &.body-full { + min-height: unset; + width: 100%; + height: 100%; + + & body { + width: 100%; + } + } + } + + html.browser_safari { + &.safari_height_fix { + body { + height: 360px !important; + + &.body-xs { + height: 300px !important; + } + + &.body-full { + height: 100% !important; + } + } + } + + app-root { + border-width: 1px; + border-style: solid; + border-color: #000000; + } + + &.theme_light app-root { + border-color: #777777; + } + } + + body { + width: 380px; + height: 100%; + position: relative; + min-height: inherit; + overflow: hidden; + @apply tw-bg-background-alt; + } + + /** + * Workaround for slow performance on external monitors on Chrome + MacOS + * See: https://bugs.chromium.org/p/chromium/issues/detail?id=971701#c64 + */ + @keyframes redraw { + 0% { + opacity: 0.99; + } + 100% { + opacity: 1; + } + } + html.force_redraw { + animation: redraw 1s linear infinite; + } + + /** + * Text selection style: + * suppress user selection for most elements (to make it more app-like) + */ + h1, + h2, + h3, + label, + a, + button, + p, + img { + user-select: none; + } +} @layer components { /** Safari Support */ @@ -19,4 +119,59 @@ html:not(.browser_safari) .tw-styled-scrollbar { scrollbar-color: rgb(var(--color-secondary-500)) rgb(var(--color-background-alt)); } + + #duo-frame { + background: url("../images/loading.svg") 0 0 no-repeat; + width: 100%; + height: 470px; + margin-bottom: -10px; + + iframe { + width: 100%; + height: 100%; + border: none; + } + } + + #web-authn-frame { + width: 100%; + height: 40px; + + iframe { + border: none; + height: 100%; + width: 100%; + } + } + + body.linux-webauthn { + width: 485px !important; + #web-authn-frame { + iframe { + width: 375px; + margin: 0 55px; + } + } + } + + app-root > #loading { + display: flex; + text-align: center; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + + @apply tw-text-muted; + } + + /** + * Text selection style: + * Set explicit selection styles (assumes primary accent color has sufficient + * contrast against the background, so its inversion is also still readable) + */ + :not(bit-form-field input)::selection { + @apply tw-text-contrast; + @apply tw-bg-primary-700; + } } diff --git a/apps/browser/src/popup/scss/variables.scss b/apps/browser/src/popup/scss/variables.scss index e57e98fd0cc..02a10521bca 100644 --- a/apps/browser/src/popup/scss/variables.scss +++ b/apps/browser/src/popup/scss/variables.scss @@ -1,178 +1,42 @@ -$dark-icon-themes: "theme_dark"; +/** + * DEPRECATED: DO NOT MODIFY OR USE! + */ + +$dark-icon-themes: "theme_dark"; $font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; $font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace; -$font-size-base: 16px; -$font-size-large: 18px; -$font-size-xlarge: 22px; -$font-size-xxlarge: 28px; -$font-size-small: 12px; $text-color: #000000; -$border-color: #f0f0f0; $border-color-dark: #ddd; -$list-item-hover: #fbfbfb; -$list-icon-color: #767679; -$disabled-box-opacity: 1; -$border-radius: 6px; -$line-height-base: 1.42857143; -$icon-hover-color: lighten($text-color, 50%); - -$mfaTypes: 0, 2, 3, 4, 6; - -$gray: #555; -$gray-light: #777; -$text-muted: $gray-light; - $brand-primary: #175ddc; -$brand-danger: #c83522; $brand-success: #017e45; -$brand-info: #555555; -$brand-warning: #8b6609; -$brand-primary-accent: #1252a3; - $background-color: #f0f0f0; - -$box-background-color: white; -$box-background-hover-color: $list-item-hover; -$box-border-color: $border-color; -$border-color-alt: #c3c5c7; - -$button-border-color: darken($border-color-dark, 12%); -$button-background-color: white; -$button-color: lighten($text-color, 40%); $button-color-primary: darken($brand-primary, 8%); -$button-color-danger: darken($brand-danger, 10%); - -$code-color: #c01176; -$code-color-dark: #f08dc7; $themes: ( light: ( textColor: $text-color, - hoverColorTransparent: rgba($text-color, 0.15), borderColor: $border-color-dark, backgroundColor: $background-color, - borderColorAlt: $border-color-alt, - backgroundColorAlt: #ffffff, - scrollbarColor: rgba(100, 100, 100, 0.2), - scrollbarHoverColor: rgba(100, 100, 100, 0.4), - boxBackgroundColor: $box-background-color, - boxBackgroundHoverColor: $box-background-hover-color, - boxBorderColor: $box-border-color, - tabBackgroundColor: #ffffff, - tabBackgroundHoverColor: $list-item-hover, - headerColor: #ffffff, - headerBackgroundColor: $brand-primary, - headerBackgroundHoverColor: rgba(255, 255, 255, 0.1), - headerBorderColor: $brand-primary, - headerInputBackgroundColor: darken($brand-primary, 8%), - headerInputBackgroundFocusColor: darken($brand-primary, 10%), - headerInputColor: #ffffff, - headerInputPlaceholderColor: lighten($brand-primary, 35%), - listItemBackgroundHoverColor: $list-item-hover, - disabledIconColor: $list-icon-color, - disabledBoxOpacity: $disabled-box-opacity, - headingColor: $gray-light, - labelColor: $gray-light, - mutedColor: $text-muted, - totpStrokeColor: $brand-primary, - boxRowButtonColor: $brand-primary, - boxRowButtonHoverColor: darken($brand-primary, 10%), inputBorderColor: darken($border-color-dark, 7%), inputBackgroundColor: #ffffff, - inputPlaceholderColor: lighten($gray-light, 35%), - buttonBackgroundColor: $button-background-color, - buttonBorderColor: $button-border-color, - buttonColor: $button-color, buttonPrimaryColor: $button-color-primary, - buttonDangerColor: $button-color-danger, primaryColor: $brand-primary, - primaryAccentColor: $brand-primary-accent, - dangerColor: $brand-danger, successColor: $brand-success, - infoColor: $brand-info, - warningColor: $brand-warning, - logoSuffix: "dark", - mfaLogoSuffix: ".png", passwordNumberColor: #007fde, passwordSpecialColor: #c40800, - passwordCountText: #212529, - calloutBorderColor: $border-color-dark, - calloutBackgroundColor: $box-background-color, - toastTextColor: #ffffff, - svgSuffix: "-light.svg", - transparentColor: rgba(0, 0, 0, 0), - dateInputColorScheme: light, - // https://stackoverflow.com/a/53336754 - webkitCalendarPickerFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg) - brightness(85%) contrast(103%), - // light has no hover so use same color - webkitCalendarPickerHoverFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg) - brightness(85%) contrast(103%), - codeColor: $code-color, ), dark: ( textColor: #ffffff, - hoverColorTransparent: rgba($text-color, 0.15), borderColor: #161c26, backgroundColor: #161c26, - borderColorAlt: #6e788a, - backgroundColorAlt: #2f343d, - scrollbarColor: #6e788a, - scrollbarHoverColor: #8d94a5, - boxBackgroundColor: #2f343d, - boxBackgroundHoverColor: #3c424e, - boxBorderColor: #4c525f, - tabBackgroundColor: #2f343d, - tabBackgroundHoverColor: #3c424e, - headerColor: #ffffff, - headerBackgroundColor: #2f343d, - headerBackgroundHoverColor: #3c424e, - headerBorderColor: #161c26, - headerInputBackgroundColor: #3c424e, - headerInputBackgroundFocusColor: #4c525f, - headerInputColor: #ffffff, - headerInputPlaceholderColor: #bac0ce, - listItemBackgroundHoverColor: #3c424e, - disabledIconColor: #bac0ce, - disabledBoxOpacity: 0.5, - headingColor: #bac0ce, - labelColor: #bac0ce, - mutedColor: #bac0ce, - totpStrokeColor: #4c525f, - boxRowButtonColor: #bac0ce, - boxRowButtonHoverColor: #ffffff, inputBorderColor: #4c525f, inputBackgroundColor: #2f343d, - inputPlaceholderColor: #bac0ce, - buttonBackgroundColor: #3c424e, - buttonBorderColor: #4c525f, - buttonColor: #bac0ce, buttonPrimaryColor: #6f9df1, - buttonDangerColor: #ff8d85, primaryColor: #6f9df1, - primaryAccentColor: #6f9df1, - dangerColor: #ff8d85, successColor: #52e07c, - infoColor: #a4b0c6, - warningColor: #ffeb66, - logoSuffix: "white", - mfaLogoSuffix: "-w.png", passwordNumberColor: #6f9df1, passwordSpecialColor: #ff8d85, - passwordCountText: #ffffff, - calloutBorderColor: #4c525f, - calloutBackgroundColor: #3c424e, - toastTextColor: #1f242e, - svgSuffix: "-dark.svg", - transparentColor: rgba(0, 0, 0, 0), - dateInputColorScheme: dark, - // https://stackoverflow.com/a/53336754 - must prepend brightness(0) saturate(100%) to dark themed date inputs - webkitCalendarPickerFilter: brightness(0) saturate(100%) invert(86%) sepia(19%) saturate(152%) - hue-rotate(184deg) brightness(87%) contrast(93%), - webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(100%) sepia(0%) - saturate(0%) hue-rotate(93deg) brightness(103%) contrast(103%), - codeColor: $code-color-dark, ), ); diff --git a/apps/browser/tailwind.config.js b/apps/browser/tailwind.config.js index 134001bbf13..faaa7fa4128 100644 --- a/apps/browser/tailwind.config.js +++ b/apps/browser/tailwind.config.js @@ -12,5 +12,6 @@ config.content = [ "../../libs/vault/src/**/*.{html,ts}", "../../libs/pricing/src/**/*.{html,ts}", ]; +config.corePlugins.preflight = true; module.exports = config; From d95dd709b1cd6cb7d6cac1d177cf3d2b48947092 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:44:13 -0600 Subject: [PATCH 10/60] [deps]: Update Rust crate thiserror to v2.0.17 (#17574) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 16 ++++++++-------- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 7b763e274b4..8cf64c9ea76 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -877,7 +877,7 @@ dependencies = [ "sha2", "ssh-key", "sysinfo", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", @@ -2648,7 +2648,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", + "thiserror 2.0.17", ] [[package]] @@ -2858,7 +2858,7 @@ dependencies = [ "libc", "rustix 1.0.7", "rustix-linux-procfs", - "thiserror 2.0.12", + "thiserror 2.0.17", "windows", ] @@ -3227,11 +3227,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.17", ] [[package]] @@ -3247,9 +3247,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index c1a3e98a0de..d85f35141b8 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -61,7 +61,7 @@ sha2 = "=0.10.8" ssh-encoding = "=0.2.0" ssh-key = { version = "=0.6.7", default-features = false } sysinfo = "=0.37.2" -thiserror = "=2.0.12" +thiserror = "=2.0.17" tokio = "=1.45.0" tokio-util = "=0.7.13" tracing = "=0.1.41" From 717cf93cc8526e6a8c1aacd5ab7a7669f6fb3856 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:53:30 +0000 Subject: [PATCH 11/60] [deps]: Update Rust crate cc to v1.2.49 (#17893) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 4 ++-- apps/desktop/desktop_native/objc/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 8cf64c9ea76..8ac04eb9a65 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -556,9 +556,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.48" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", diff --git a/apps/desktop/desktop_native/objc/Cargo.toml b/apps/desktop/desktop_native/objc/Cargo.toml index 5d7174894e7..dd808537c28 100644 --- a/apps/desktop/desktop_native/objc/Cargo.toml +++ b/apps/desktop/desktop_native/objc/Cargo.toml @@ -14,7 +14,7 @@ tokio = { workspace = true } tracing = { workspace = true } [target.'cfg(target_os = "macos")'.build-dependencies] -cc = "=1.2.48" +cc = "=1.2.49" glob = "=0.3.3" [lints] From 22338632be9e6628b166b962f4a776ed0d29328f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:57:23 +0000 Subject: [PATCH 12/60] [deps] Platform: Update Rust crate zbus to v5.12.0 (#17035) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 89 ++++++++++++++++++++++++-- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 8ac04eb9a65..cf946f7f204 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -501,6 +501,12 @@ dependencies = [ "cipher", ] +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + [[package]] name = "byteorder" version = "1.5.0" @@ -1663,6 +1669,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2786,6 +2802,12 @@ dependencies = [ "rustix 1.0.7", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.20" @@ -3668,6 +3690,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3733,6 +3766,51 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wayland-backend" version = "0.3.10" @@ -4369,9 +4447,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" dependencies = [ "async-broadcast", "async-executor", @@ -4394,7 +4472,8 @@ dependencies = [ "tokio", "tracing", "uds_windows", - "windows-sys 0.60.2", + "uuid", + "windows-sys 0.61.2", "winnow", "zbus_macros", "zbus_names", @@ -4403,9 +4482,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" dependencies = [ "proc-macro-crate", "proc-macro2", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index d85f35141b8..59df7ba57fb 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -77,7 +77,7 @@ windows = "=0.61.1" windows-core = "=0.61.0" windows-future = "=0.2.0" windows-registry = "=0.6.1" -zbus = "=5.11.0" +zbus = "=5.12.0" zbus_polkit = "=5.0.0" zeroizing-alloc = "=0.1.0" From 6dba3ac3772ee7c718d5b56cbe94e4249aa29ec1 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 9 Dec 2025 17:28:04 -0500 Subject: [PATCH 13/60] [PM-27663] Create VaultItemTransferModalComponent and confirmation dialogs (#17883) * Created item transfer dialogs * Added empty line --- apps/browser/src/_locales/en/messages.json | 48 ++++++++++++++ apps/desktop/src/locales/en/messages.json | 48 ++++++++++++++ apps/web/src/locales/en/messages.json | 48 ++++++++++++++ .../components/vault-items-transfer/index.ts | 13 ++++ .../leave-confirmation-dialog.component.html | 33 ++++++++++ .../leave-confirmation-dialog.component.ts | 64 +++++++++++++++++++ .../transfer-items-dialog.component.html | 22 +++++++ .../transfer-items-dialog.component.ts | 64 +++++++++++++++++++ libs/vault/src/index.ts | 1 + 9 files changed, 341 insertions(+) create mode 100644 libs/vault/src/components/vault-items-transfer/index.ts create mode 100644 libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html create mode 100644 libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts create mode 100644 libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html create mode 100644 libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a5c204ffc99..2d229092787 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5937,5 +5937,53 @@ }, "upgrade": { "message": "Upgrade" + }, + "leaveConfirmationDialogTitle": { + "message": "Are you sure you want to leave?" + }, + "leaveConfirmationDialogContentOne": { + "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." + }, + "leaveConfirmationDialogContentTwo": { + "message": "Contact your admin to regain access." + }, + "leaveConfirmationDialogConfirmButton": { + "message": "Leave $ORGANIZATION$", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "howToManageMyVault": { + "message": "How do I manage my vault?" + }, + "transferItemsToOrganizationTitle": { + "message": "Transfer items to $ORGANIZATION$", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "transferItemsToOrganizationContent": { + "message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "acceptTransfer": { + "message": "Accept transfer" + }, + "declineAndLeave": { + "message": "Decline and leave" + }, + "whyAmISeeingThis": { + "message": "Why am I seeing this?" } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 4c3833e28c3..f6a679d9c7c 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4383,5 +4383,53 @@ }, "upgrade": { "message": "Upgrade" + }, + "leaveConfirmationDialogTitle": { + "message": "Are you sure you want to leave?" + }, + "leaveConfirmationDialogContentOne": { + "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." + }, + "leaveConfirmationDialogContentTwo": { + "message": "Contact your admin to regain access." + }, + "leaveConfirmationDialogConfirmButton": { + "message": "Leave $ORGANIZATION$", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "howToManageMyVault": { + "message": "How do I manage my vault?" + }, + "transferItemsToOrganizationTitle": { + "message": "Transfer items to $ORGANIZATION$", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "transferItemsToOrganizationContent": { + "message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "acceptTransfer": { + "message": "Accept transfer" + }, + "declineAndLeave": { + "message": "Decline and leave" + }, + "whyAmISeeingThis": { + "message": "Why am I seeing this?" } } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4be70b102d1..bbbf548b8e1 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12287,5 +12287,53 @@ }, "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { "message": "Set an unlock method to change your timeout action" + }, + "leaveConfirmationDialogTitle": { + "message": "Are you sure you want to leave?" + }, + "leaveConfirmationDialogContentOne": { + "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." + }, + "leaveConfirmationDialogContentTwo": { + "message": "Contact your admin to regain access." + }, + "leaveConfirmationDialogConfirmButton": { + "message": "Leave $ORGANIZATION$", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "howToManageMyVault": { + "message": "How do I manage my vault?" + }, + "transferItemsToOrganizationTitle": { + "message": "Transfer items to $ORGANIZATION$", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "transferItemsToOrganizationContent": { + "message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "acceptTransfer": { + "message": "Accept transfer" + }, + "declineAndLeave": { + "message": "Decline and leave" + }, + "whyAmISeeingThis": { + "message": "Why am I seeing this?" } } diff --git a/libs/vault/src/components/vault-items-transfer/index.ts b/libs/vault/src/components/vault-items-transfer/index.ts new file mode 100644 index 00000000000..f2ffb9f9c22 --- /dev/null +++ b/libs/vault/src/components/vault-items-transfer/index.ts @@ -0,0 +1,13 @@ +export { + TransferItemsDialogComponent, + TransferItemsDialogParams, + TransferItemsDialogResult, + TransferItemsDialogResultType, +} from "./transfer-items-dialog.component"; + +export { + LeaveConfirmationDialogComponent, + LeaveConfirmationDialogParams, + LeaveConfirmationDialogResult, + LeaveConfirmationDialogResultType, +} from "./leave-confirmation-dialog.component"; diff --git a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html new file mode 100644 index 00000000000..f0d644fecff --- /dev/null +++ b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.html @@ -0,0 +1,33 @@ + + + + {{ "leaveConfirmationDialogTitle" | i18n }} + + +

+ {{ "leaveConfirmationDialogContentOne" | i18n }} +

+

+ {{ "leaveConfirmationDialogContentTwo" | i18n }} +

+
+ + + + + + + + {{ "howToManageMyVault" | i18n }} + + + +
diff --git a/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts new file mode 100644 index 00000000000..bd32a1ea6dd --- /dev/null +++ b/libs/vault/src/components/vault-items-transfer/leave-confirmation-dialog.component.ts @@ -0,0 +1,64 @@ +import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + DialogService, + ButtonModule, + DialogModule, + LinkModule, + TypographyModule, +} from "@bitwarden/components"; + +export interface LeaveConfirmationDialogParams { + organizationName: string; +} + +export const LeaveConfirmationDialogResult = Object.freeze({ + /** + * User confirmed they want to leave the organization. + */ + Confirmed: "confirmed", + /** + * User chose to go back instead of leaving the organization. + */ + Back: "back", +} as const); + +export type LeaveConfirmationDialogResultType = UnionOfValues; + +@Component({ + templateUrl: "./leave-confirmation-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule], +}) +export class LeaveConfirmationDialogComponent { + private readonly params = inject(DIALOG_DATA); + private readonly dialogRef = inject(DialogRef); + private readonly platformUtilsService = inject(PlatformUtilsService); + + protected readonly organizationName = this.params.organizationName; + + protected confirmLeave() { + this.dialogRef.close(LeaveConfirmationDialogResult.Confirmed); + } + + protected goBack() { + this.dialogRef.close(LeaveConfirmationDialogResult.Back); + } + + protected openLearnMore(e: Event) { + e.preventDefault(); + this.platformUtilsService.launchUri("https://bitwarden.com/help/transfer-ownership/"); + } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(LeaveConfirmationDialogComponent, { + ...config, + }); + } +} diff --git a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html new file mode 100644 index 00000000000..0b77d4ba7d8 --- /dev/null +++ b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.html @@ -0,0 +1,22 @@ + + {{ "transferItemsToOrganizationTitle" | i18n: organizationName }} + + + {{ "transferItemsToOrganizationContent" | i18n: organizationName }} + + + + + + + + + {{ "whyAmISeeingThis" | i18n }} + + + + diff --git a/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts new file mode 100644 index 00000000000..f28ad2ab3ec --- /dev/null +++ b/libs/vault/src/components/vault-items-transfer/transfer-items-dialog.component.ts @@ -0,0 +1,64 @@ +import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { + DIALOG_DATA, + DialogConfig, + DialogRef, + DialogService, + ButtonModule, + DialogModule, + LinkModule, + TypographyModule, +} from "@bitwarden/components"; + +export interface TransferItemsDialogParams { + organizationName: string; +} + +export const TransferItemsDialogResult = Object.freeze({ + /** + * User accepted the transfer of items. + */ + Accepted: "accepted", + /** + * User declined the transfer of items. + */ + Declined: "declined", +} as const); + +export type TransferItemsDialogResultType = UnionOfValues; + +@Component({ + templateUrl: "./transfer-items-dialog.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ButtonModule, DialogModule, LinkModule, TypographyModule, JslibModule], +}) +export class TransferItemsDialogComponent { + private readonly params = inject(DIALOG_DATA); + private readonly dialogRef = inject(DialogRef); + private readonly platformUtilsService = inject(PlatformUtilsService); + + protected readonly organizationName = this.params.organizationName; + + protected acceptTransfer() { + this.dialogRef.close(TransferItemsDialogResult.Accepted); + } + + protected decline() { + this.dialogRef.close(TransferItemsDialogResult.Declined); + } + + protected openLearnMore(e: Event) { + e.preventDefault(); + this.platformUtilsService.launchUri("https://bitwarden.com/help/transfer-ownership/"); + } + + static open(dialogService: DialogService, config: DialogConfig) { + return dialogService.open(TransferItemsDialogComponent, { + ...config, + }); + } +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 93a72ba14e0..be0daad3637 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -29,6 +29,7 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon export * from "./components/carousel"; export * from "./components/new-cipher-menu/new-cipher-menu.component"; export * from "./components/permit-cipher-details-popover/permit-cipher-details-popover.component"; +export * from "./components/vault-items-transfer"; export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service"; export { SshImportPromptService } from "./services/ssh-import-prompt.service"; From f161a8c454afdaf4d84679fc683157032236fdba Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Tue, 9 Dec 2025 15:14:40 -0800 Subject: [PATCH 14/60] [PM-27662] Introduce vault item transfer service (#17876) * [PM-27662] Add revision date to policy response * [PM-27662] Introduce vault item transfer service * [PM-27662] Add feature flag check * [PM-27662] Add tests * [PM-27662] Add basic implementation to Web vault * [PM-27662] Remove redundant for loop * [PM-27662] Remove unnecessary distinctUntilChanged * [PM-27662] Avoid subscribing to userMigrationInfo$ if feature flag disabled * [PM-27662] Make UserMigrationInfo type more strict * [PM-27662] Typo * [PM-27662] Fix missing i18n * [PM-27662] Fix tests * [PM-27662] Fix tests/types related to policy changes * [PM-27662] Use getById operator --- apps/browser/src/_locales/en/messages.json | 3 + apps/desktop/src/locales/en/messages.json | 3 + .../vault/individual-vault/vault.component.ts | 9 +- apps/web/src/locales/en/messages.json | 3 + .../admin-console/models/data/policy.data.ts | 2 + .../src/admin-console/models/domain/policy.ts | 3 + .../models/response/policy.response.ts | 2 + .../policy/default-policy.service.spec.ts | 40 + libs/common/src/enums/feature-flag.enum.ts | 2 + .../available-algorithms-policy.spec.ts | 7 + .../passphrase-least-privilege.spec.ts | 1 + .../policies/password-least-privilege.spec.ts | 1 + .../generator-profile-provider.spec.ts | 1 + ...fault-generator-navigation.service.spec.ts | 1 + .../src/generator-navigation-policy.spec.ts | 1 + .../vault-items-transfer.service.ts | 59 ++ libs/vault/src/index.ts | 2 + ...fault-vault-items-transfer.service.spec.ts | 721 ++++++++++++++++++ .../default-vault-items-transfer.service.ts | 231 ++++++ 19 files changed, 1090 insertions(+), 2 deletions(-) create mode 100644 libs/vault/src/abstractions/vault-items-transfer.service.ts create mode 100644 libs/vault/src/services/default-vault-items-transfer.service.spec.ts create mode 100644 libs/vault/src/services/default-vault-items-transfer.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 2d229092787..a90fbcbf332 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1475,6 +1475,9 @@ "selectFile": { "message": "Select a file" }, + "itemsTransferred": { + "message": "Items transferred" + }, "maxFileSize": { "message": "Maximum file size is 500 MB." }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f6a679d9c7c..7a3abe528e8 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -708,6 +708,9 @@ "addAttachment": { "message": "Add attachment" }, + "itemsTransferred": { + "message": "Items transferred" + }, "fixEncryption": { "message": "Fix encryption" }, diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index b0685a028df..78a8889bc8f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -84,7 +84,7 @@ import { CipherViewLikeUtils, } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; -import { DialogRef, DialogService, ToastService, BannerComponent } from "@bitwarden/components"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { CipherListView } from "@bitwarden/sdk-internal"; import { AddEditFolderDialogComponent, @@ -97,6 +97,8 @@ import { DecryptionFailureDialogComponent, DefaultCipherFormConfigService, PasswordRepromptService, + VaultItemsTransferService, + DefaultVaultItemsTransferService, } from "@bitwarden/vault"; import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; @@ -177,12 +179,12 @@ type EmptyStateMap = Record; VaultItemsModule, SharedModule, OrganizationWarningsModule, - BannerComponent, ], providers: [ RoutedVaultFilterService, RoutedVaultFilterBridgeService, DefaultCipherFormConfigService, + { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }, ], }) export class VaultComponent implements OnInit, OnDestroy { @@ -349,6 +351,7 @@ export class VaultComponent implements OnInit, OnDestr private premiumUpgradePromptService: PremiumUpgradePromptService, private autoConfirmService: AutomaticUserConfirmationService, private configService: ConfigService, + private vaultItemTransferService: VaultItemsTransferService, ) {} async ngOnInit() { @@ -644,6 +647,8 @@ export class VaultComponent implements OnInit, OnDestr void this.unifiedUpgradePromptService.displayUpgradePromptConditionally(); this.setupAutoConfirm(); + + void this.vaultItemTransferService.enforceOrganizationDataOwnership(activeUserId); } ngOnDestroy() { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index bbbf548b8e1..a755e4de556 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5185,6 +5185,9 @@ "oldAttachmentsNeedFixDesc": { "message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key." }, + "itemsTransferred": { + "message": "Items transferred" + }, "yourAccountsFingerprint": { "message": "Your account's fingerprint phrase", "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." diff --git a/libs/common/src/admin-console/models/data/policy.data.ts b/libs/common/src/admin-console/models/data/policy.data.ts index a8628e2f1ab..639fda1fa92 100644 --- a/libs/common/src/admin-console/models/data/policy.data.ts +++ b/libs/common/src/admin-console/models/data/policy.data.ts @@ -11,6 +11,7 @@ export class PolicyData { type: PolicyType; data: Record; enabled: boolean; + revisionDate: string; constructor(response?: PolicyResponse) { if (response == null) { @@ -22,6 +23,7 @@ export class PolicyData { this.type = response.type; this.data = response.data; this.enabled = response.enabled; + this.revisionDate = response.revisionDate; } static fromPolicy(policy: Policy): PolicyData { diff --git a/libs/common/src/admin-console/models/domain/policy.ts b/libs/common/src/admin-console/models/domain/policy.ts index eb67c4412e9..6b2d587a262 100644 --- a/libs/common/src/admin-console/models/domain/policy.ts +++ b/libs/common/src/admin-console/models/domain/policy.ts @@ -19,6 +19,8 @@ export class Policy extends Domain { */ enabled: boolean; + revisionDate: Date; + constructor(obj?: PolicyData) { super(); if (obj == null) { @@ -30,6 +32,7 @@ export class Policy extends Domain { this.type = obj.type; this.data = obj.data; this.enabled = obj.enabled; + this.revisionDate = new Date(obj.revisionDate); } static fromResponse(response: PolicyResponse): Policy { diff --git a/libs/common/src/admin-console/models/response/policy.response.ts b/libs/common/src/admin-console/models/response/policy.response.ts index 0544cd996f4..7cca63a19d3 100644 --- a/libs/common/src/admin-console/models/response/policy.response.ts +++ b/libs/common/src/admin-console/models/response/policy.response.ts @@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse { data: any; enabled: boolean; canToggleState: boolean; + revisionDate: string; constructor(response: any) { super(response); @@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse { this.data = this.getResponseProperty("Data"); this.enabled = this.getResponseProperty("Enabled"); this.canToggleState = this.getResponseProperty("CanToggleState") ?? true; + this.revisionDate = this.getResponseProperty("RevisionDate"); } } diff --git a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts index 4b59683ec0a..2ff649e6533 100644 --- a/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts +++ b/libs/common/src/admin-console/services/policy/default-policy.service.spec.ts @@ -83,12 +83,15 @@ describe("PolicyService", () => { type: PolicyType.MaximumVaultTimeout, enabled: true, data: { minutes: 14 }, + revisionDate: expect.any(Date), }, { id: "99", organizationId: "test-organization", type: PolicyType.DisableSend, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -113,6 +116,8 @@ describe("PolicyService", () => { organizationId: "test-organization", type: PolicyType.DisableSend, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -242,6 +247,8 @@ describe("PolicyService", () => { organizationId: "org1", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }); }); @@ -331,24 +338,32 @@ describe("PolicyService", () => { organizationId: "org4", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy2", organizationId: "org1", type: PolicyType.ActivateAutofill, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy3", organizationId: "org5", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy4", organizationId: "org1", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -371,24 +386,32 @@ describe("PolicyService", () => { organizationId: "org4", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy2", organizationId: "org1", type: PolicyType.ActivateAutofill, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy3", organizationId: "org5", type: PolicyType.DisablePersonalVaultExport, enabled: false, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy4", organizationId: "org1", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -411,24 +434,32 @@ describe("PolicyService", () => { organizationId: "org4", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy2", organizationId: "org1", type: PolicyType.ActivateAutofill, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy3", organizationId: "org5", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy4", organizationId: "org2", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -451,24 +482,32 @@ describe("PolicyService", () => { organizationId: "org4", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy2", organizationId: "org1", type: PolicyType.ActivateAutofill, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy3", organizationId: "org3", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, { id: "policy4", organizationId: "org1", type: PolicyType.DisablePersonalVaultExport, enabled: true, + data: undefined, + revisionDate: expect.any(Date), }, ]); }); @@ -788,6 +827,7 @@ describe("PolicyService", () => { policyData.type = type; policyData.enabled = enabled; policyData.data = data; + policyData.revisionDate = new Date().toISOString(); return policyData; } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 371081a89d9..1727d3da712 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -64,6 +64,7 @@ export enum FeatureFlag { RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", + MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -123,6 +124,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.RiskInsightsForPremium]: FALSE, [FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.BrowserPremiumSpotlight]: FALSE, + [FeatureFlag.MigrateMyVaultToMyItems]: FALSE, /* Auth */ [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts index 5f699974fba..7de8c708dcf 100644 --- a/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts +++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.spec.ts @@ -24,6 +24,7 @@ describe("availableAlgorithms_vNextPolicy", () => { overridePasswordType: override, }, enabled: true, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([policy]); @@ -44,6 +45,7 @@ describe("availableAlgorithms_vNextPolicy", () => { overridePasswordType: override, }, enabled: true, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([policy, policy]); @@ -64,6 +66,7 @@ describe("availableAlgorithms_vNextPolicy", () => { overridePasswordType: "password", }, enabled: true, + revisionDate: new Date().toISOString(), }); const passphrase = new Policy({ id: "" as PolicyId, @@ -73,6 +76,7 @@ describe("availableAlgorithms_vNextPolicy", () => { overridePasswordType: "passphrase", }, enabled: true, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([password, passphrase]); @@ -93,6 +97,7 @@ describe("availableAlgorithms_vNextPolicy", () => { some: "policy", }, enabled: true, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([policy]); @@ -111,6 +116,7 @@ describe("availableAlgorithms_vNextPolicy", () => { some: "policy", }, enabled: false, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([policy]); @@ -129,6 +135,7 @@ describe("availableAlgorithms_vNextPolicy", () => { some: "policy", }, enabled: true, + revisionDate: new Date().toISOString(), }); const result = availableAlgorithms([policy]); diff --git a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts index 0fbc1796e9e..c6ce189f620 100644 --- a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts +++ b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts @@ -17,6 +17,7 @@ function createPolicy( data, enabled, type, + revisionDate: new Date().toISOString(), }); } diff --git a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts index 7f8dce19b15..7885641c8e5 100644 --- a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts +++ b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts @@ -17,6 +17,7 @@ function createPolicy( data, enabled, type, + revisionDate: new Date().toISOString(), }); } diff --git a/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts index 32d99aa8a1f..924849b1c22 100644 --- a/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts +++ b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts @@ -57,6 +57,7 @@ const somePolicy = new Policy({ id: "" as PolicyId, organizationId: "" as OrganizationId, enabled: true, + revisionDate: new Date().toISOString(), }); const stateProvider = new FakeStateProvider(accountService); diff --git a/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts b/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts index 65f1669ebd1..37e8ec6e379 100644 --- a/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts +++ b/libs/tools/generator/extensions/navigation/src/default-generator-navigation.service.spec.ts @@ -70,6 +70,7 @@ describe("DefaultGeneratorNavigationService", () => { enabled: true, type: PolicyType.PasswordGenerator, data: { overridePasswordType: "password" }, + revisionDate: new Date().toISOString(), }), ]); }, diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts index e4f0b08a3d5..69a4e75d47d 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation-policy.spec.ts @@ -17,6 +17,7 @@ function createPolicy( data, enabled, type, + revisionDate: new Date().toISOString(), }); } diff --git a/libs/vault/src/abstractions/vault-items-transfer.service.ts b/libs/vault/src/abstractions/vault-items-transfer.service.ts new file mode 100644 index 00000000000..ced9f71eb83 --- /dev/null +++ b/libs/vault/src/abstractions/vault-items-transfer.service.ts @@ -0,0 +1,59 @@ +import { Observable } from "rxjs"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; +import { UserId } from "@bitwarden/user-core"; + +export type UserMigrationInfo = + | { + /** + * Whether the user requires migration of their vault items from My Vault to a My Items collection due to an + * organizational policy change. (Enforce organization data ownership policy enabled) + */ + requiresMigration: false; + } + | { + /** + * Whether the user requires migration of their vault items from My Vault to a My Items collection due to an + * organizational policy change. (Enforce organization data ownership policy enabled) + */ + requiresMigration: true; + + /** + * The organization that is enforcing data ownership policies for the given user. + */ + enforcingOrganization: Organization; + + /** + * The default collection ID for the user in the enforcing organization, if available. + */ + defaultCollectionId?: CollectionId; + }; + +export abstract class VaultItemsTransferService { + /** + * Gets information about whether the given user requires migration of their vault items + * from My Vault to a My Items collection, and whether they are capable of performing that migration. + * @param userId + */ + abstract userMigrationInfo$(userId: UserId): Observable; + + /** + * Enforces organization data ownership for the given user by transferring vault items. + * Checks if any organization policies require the transfer, and if so, prompts the user to confirm before proceeding. + * + * Rejecting the transfer will result in the user being revoked from the organization. + * + * @param userId + */ + abstract enforceOrganizationDataOwnership(userId: UserId): Promise; + + /** + * Begins transfer of vault items from My Vault to the specified default collection for the given user. + */ + abstract transferPersonalItems( + userId: UserId, + organizationId: OrganizationId, + defaultCollectionId: CollectionId, + ): Promise; +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index be0daad3637..391957d26d8 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -35,5 +35,7 @@ export { DefaultSshImportPromptService } from "./services/default-ssh-import-pro export { SshImportPromptService } from "./services/ssh-import-prompt.service"; export * from "./abstractions/change-login-password.service"; +export * from "./abstractions/vault-items-transfer.service"; +export * from "./services/default-vault-items-transfer.service"; export * from "./services/default-change-login-password.service"; export * from "./services/archive-cipher-utilities.service"; diff --git a/libs/vault/src/services/default-vault-items-transfer.service.spec.ts b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts new file mode 100644 index 00000000000..d85fe2ffd43 --- /dev/null +++ b/libs/vault/src/services/default-vault-items-transfer.service.spec.ts @@ -0,0 +1,721 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +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 { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { DefaultVaultItemsTransferService } from "./default-vault-items-transfer.service"; + +describe("DefaultVaultItemsTransferService", () => { + let service: DefaultVaultItemsTransferService; + + let mockCipherService: MockProxy; + let mockPolicyService: MockProxy; + let mockOrganizationService: MockProxy; + let mockCollectionService: MockProxy; + let mockLogService: MockProxy; + let mockI18nService: MockProxy; + let mockDialogService: MockProxy; + let mockToastService: MockProxy; + let mockConfigService: MockProxy; + + const userId = "user-id" as UserId; + const organizationId = "org-id" as OrganizationId; + const collectionId = "collection-id" as CollectionId; + + beforeEach(() => { + mockCipherService = mock(); + mockPolicyService = mock(); + mockOrganizationService = mock(); + mockCollectionService = mock(); + mockLogService = mock(); + mockI18nService = mock(); + mockDialogService = mock(); + mockToastService = mock(); + mockConfigService = mock(); + + mockI18nService.t.mockImplementation((key) => key); + + service = new DefaultVaultItemsTransferService( + mockCipherService, + mockPolicyService, + mockOrganizationService, + mockCollectionService, + mockLogService, + mockI18nService, + mockDialogService, + mockToastService, + mockConfigService, + ); + }); + + describe("userMigrationInfo$", () => { + // Helper to setup common mock scenario + function setupMocksForMigrationScenario(options: { + policies?: Policy[]; + organizations?: Organization[]; + ciphers?: CipherView[]; + collections?: CollectionView[]; + }): void { + mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? [])); + mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? [])); + mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? [])); + mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? [])); + } + + it("calls policiesByType$ with correct PolicyType", async () => { + setupMocksForMigrationScenario({ policies: [] }); + + await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(mockPolicyService.policiesByType$).toHaveBeenCalledWith( + PolicyType.OrganizationDataOwnership, + userId, + ); + }); + + describe("when no policy exists", () => { + beforeEach(() => { + setupMocksForMigrationScenario({ policies: [] }); + }); + + it("returns requiresMigration: false", async () => { + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: false, + }); + }); + }); + + describe("when policy exists", () => { + const policy = { + organizationId: organizationId, + revisionDate: new Date("2024-01-01"), + } as Policy; + const organization = { + id: organizationId, + name: "Test Org", + } as Organization; + + beforeEach(() => { + setupMocksForMigrationScenario({ + policies: [policy], + organizations: [organization], + }); + }); + + describe("and user has no personal ciphers", () => { + beforeEach(() => { + mockCipherService.cipherViews$.mockReturnValue(of([])); + }); + + it("returns requiresMigration: false", async () => { + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: false, + enforcingOrganization: organization, + defaultCollectionId: undefined, + }); + }); + }); + + describe("and user has personal ciphers", () => { + beforeEach(() => { + mockCipherService.cipherViews$.mockReturnValue(of([{ id: "cipher-1" } as CipherView])); + }); + + it("returns requiresMigration: true", async () => { + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: true, + enforcingOrganization: organization, + defaultCollectionId: undefined, + }); + }); + + it("includes defaultCollectionId when a default collection exists", async () => { + mockCollectionService.decryptedCollections$.mockReturnValue( + of([ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ]), + ); + + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: true, + enforcingOrganization: organization, + defaultCollectionId: collectionId, + }); + }); + + it("returns default collection only for the enforcing organization", async () => { + mockCollectionService.decryptedCollections$.mockReturnValue( + of([ + { + id: "wrong-collection-id" as CollectionId, + organizationId: "wrong-org-id" as OrganizationId, + isDefaultCollection: true, + } as CollectionView, + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ]), + ); + + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: true, + enforcingOrganization: organization, + defaultCollectionId: collectionId, + }); + }); + }); + + it("filters out organization ciphers when checking for personal ciphers", async () => { + mockCipherService.cipherViews$.mockReturnValue( + of([ + { + id: "cipher-1", + organizationId: organizationId as string, + } as CipherView, + ]), + ); + + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: false, + enforcingOrganization: organization, + defaultCollectionId: undefined, + }); + }); + }); + + describe("when multiple policies exist", () => { + const olderPolicy = { + organizationId: "older-org-id" as OrganizationId, + revisionDate: new Date("2024-01-01"), + } as Policy; + const newerPolicy = { + organizationId: organizationId, + revisionDate: new Date("2024-06-01"), + } as Policy; + const olderOrganization = { + id: "older-org-id" as OrganizationId, + name: "Older Org", + } as Organization; + const newerOrganization = { + id: organizationId, + name: "Newer Org", + } as Organization; + + beforeEach(() => { + setupMocksForMigrationScenario({ + policies: [newerPolicy, olderPolicy], + organizations: [olderOrganization, newerOrganization], + ciphers: [{ id: "cipher-1" } as CipherView], + }); + }); + + it("uses the oldest policy when selecting enforcing organization", async () => { + const result = await firstValueFrom(service.userMigrationInfo$(userId)); + + expect(result).toEqual({ + requiresMigration: true, + enforcingOrganization: olderOrganization, + defaultCollectionId: undefined, + }); + }); + }); + }); + + describe("transferPersonalItems", () => { + it("does nothing when user has no personal ciphers", async () => { + mockCipherService.cipherViews$.mockReturnValue(of([])); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + expect(mockLogService.info).not.toHaveBeenCalled(); + }); + + it("calls shareManyWithServer with correct parameters", async () => { + const personalCiphers = [{ id: "cipher-1" }, { id: "cipher-2" }] as CipherView[]; + + mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers)); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith( + personalCiphers, + organizationId, + [collectionId], + userId, + ); + }); + + it("transfers only personal ciphers, not organization ciphers", async () => { + const allCiphers = [ + { id: "cipher-1" }, + { id: "cipher-2", organizationId: "other-org-id" }, + { id: "cipher-3" }, + ] as CipherView[]; + + const expectedPersonalCiphers = [allCiphers[0], allCiphers[2]]; + + mockCipherService.cipherViews$.mockReturnValue(of(allCiphers)); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith( + expectedPersonalCiphers, + organizationId, + [collectionId], + userId, + ); + }); + + it("propagates errors from shareManyWithServer", async () => { + const personalCiphers = [{ id: "cipher-1" }] as CipherView[]; + + const error = new Error("Transfer failed"); + + mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers)); + mockCipherService.shareManyWithServer.mockRejectedValue(error); + + await expect( + service.transferPersonalItems(userId, organizationId, collectionId), + ).rejects.toThrow("Transfer failed"); + }); + }); + + describe("upgradeOldAttachments", () => { + it("upgrades old attachments before transferring", async () => { + const cipherWithOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + const upgradedCipher = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: false, + attachments: [{ key: "new-key" }], + } as unknown as CipherView; + + mockCipherService.cipherViews$ + .mockReturnValueOnce(of([cipherWithOldAttachment])) + .mockReturnValueOnce(of([upgradedCipher])); + mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith( + cipherWithOldAttachment, + userId, + ); + expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith( + [upgradedCipher], + organizationId, + [collectionId], + userId, + ); + }); + + it("upgrades multiple ciphers with old attachments", async () => { + const cipher1 = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + const cipher2 = { + id: "cipher-2", + name: "Cipher 2", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + const upgradedCipher1 = { ...cipher1, hasOldAttachments: false } as CipherView; + const upgradedCipher2 = { ...cipher2, hasOldAttachments: false } as CipherView; + + mockCipherService.cipherViews$ + .mockReturnValueOnce(of([cipher1, cipher2])) + .mockReturnValueOnce(of([upgradedCipher1, upgradedCipher2])); + mockCipherService.upgradeOldCipherAttachments + .mockResolvedValueOnce(upgradedCipher1) + .mockResolvedValueOnce(upgradedCipher2); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(2); + expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher1, userId); + expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher2, userId); + }); + + it("skips attachments that already have keys", async () => { + const cipherWithMixedAttachments = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: "existing-key" }, { key: null }], + } as unknown as CipherView; + + const upgradedCipher = { + ...cipherWithMixedAttachments, + hasOldAttachments: false, + } as unknown as CipherView; + + mockCipherService.cipherViews$ + .mockReturnValueOnce(of([cipherWithMixedAttachments])) + .mockReturnValueOnce(of([upgradedCipher])); + mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + // Should only be called once for the attachment without a key + expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(1); + }); + + it("throws error when upgradeOldCipherAttachments fails", async () => { + const cipherWithOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment])); + mockCipherService.upgradeOldCipherAttachments.mockRejectedValue(new Error("Upgrade failed")); + + await expect( + service.transferPersonalItems(userId, organizationId, collectionId), + ).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1"); + + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("throws error when upgrade returns cipher still having old attachments", async () => { + const cipherWithOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + // Upgrade returns but cipher still has old attachments + const stillOldCipher = { + ...cipherWithOldAttachment, + hasOldAttachments: true, + } as unknown as CipherView; + + mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment])); + mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(stillOldCipher); + + await expect( + service.transferPersonalItems(userId, organizationId, collectionId), + ).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1"); + + expect(mockLogService.error).toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("throws error when sanity check finds remaining old attachments after upgrade", async () => { + const cipherWithOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + const upgradedCipher = { + ...cipherWithOldAttachment, + hasOldAttachments: false, + } as unknown as CipherView; + + // First call returns cipher with old attachment, second call (after upgrade) still returns old attachment + mockCipherService.cipherViews$ + .mockReturnValueOnce(of([cipherWithOldAttachment])) + .mockReturnValueOnce(of([cipherWithOldAttachment])); // Still has old attachments after re-fetch + mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher); + + await expect( + service.transferPersonalItems(userId, organizationId, collectionId), + ).rejects.toThrow( + "Failed to upgrade all old attachments. 1 ciphers still have old attachments.", + ); + + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("logs info when upgrading old attachments", async () => { + const cipherWithOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: true, + attachments: [{ key: null }], + } as unknown as CipherView; + + const upgradedCipher = { + ...cipherWithOldAttachment, + hasOldAttachments: false, + } as unknown as CipherView; + + mockCipherService.cipherViews$ + .mockReturnValueOnce(of([cipherWithOldAttachment])) + .mockReturnValueOnce(of([upgradedCipher])); + mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockLogService.info).toHaveBeenCalledWith( + expect.stringContaining("Found 1 ciphers with old attachments needing upgrade"), + ); + expect(mockLogService.info).toHaveBeenCalledWith( + expect.stringContaining("Successfully upgraded 1 ciphers with old attachments"), + ); + }); + + it("does not upgrade when ciphers have no old attachments", async () => { + const cipherWithoutOldAttachment = { + id: "cipher-1", + name: "Cipher 1", + hasOldAttachments: false, + } as unknown as CipherView; + + mockCipherService.cipherViews$.mockReturnValue(of([cipherWithoutOldAttachment])); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.transferPersonalItems(userId, organizationId, collectionId); + + expect(mockCipherService.upgradeOldCipherAttachments).not.toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).toHaveBeenCalled(); + }); + }); + + describe("enforceOrganizationDataOwnership", () => { + const policy = { + organizationId: organizationId, + revisionDate: new Date("2024-01-01"), + } as Policy; + const organization = { + id: organizationId, + name: "Test Org", + } as Organization; + + function setupMocksForEnforcementScenario(options: { + featureEnabled?: boolean; + policies?: Policy[]; + organizations?: Organization[]; + ciphers?: CipherView[]; + collections?: CollectionView[]; + }): void { + mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true); + mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? [])); + mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? [])); + mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? [])); + mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? [])); + } + + it("does nothing when feature flag is disabled", async () => { + setupMocksForEnforcementScenario({ + featureEnabled: false, + policies: [policy], + organizations: [organization], + ciphers: [{ id: "cipher-1" } as CipherView], + collections: [ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ], + }); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.MigrateMyVaultToMyItems, + ); + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("does nothing when no migration is required", async () => { + setupMocksForEnforcementScenario({ policies: [] }); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("does nothing when user has no personal ciphers", async () => { + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: [], + }); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("logs warning and returns when default collection is missing", async () => { + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: [{ id: "cipher-1" } as CipherView], + collections: [], + }); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockLogService.warning).toHaveBeenCalledWith( + "Default collection is missing for user during organization data ownership enforcement", + ); + expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled(); + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("shows confirmation dialog when migration is required", async () => { + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: [{ id: "cipher-1" } as CipherView], + collections: [ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ], + }); + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: "Requires migration", + content: "Your vault requires migration of personal items to your organization.", + type: "warning", + }); + }); + + it("does not transfer items when user declines confirmation", async () => { + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: [{ id: "cipher-1" } as CipherView], + collections: [ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ], + }); + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled(); + }); + + it("transfers items and shows success toast when user confirms", async () => { + const personalCiphers = [{ id: "cipher-1" } as CipherView]; + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: personalCiphers, + collections: [ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ], + }); + mockDialogService.openSimpleDialog.mockResolvedValue(true); + mockCipherService.shareManyWithServer.mockResolvedValue(undefined); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith( + personalCiphers, + organizationId, + [collectionId], + userId, + ); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "itemsTransferred", + }); + }); + + it("shows error toast when transfer fails", async () => { + const personalCiphers = [{ id: "cipher-1" } as CipherView]; + setupMocksForEnforcementScenario({ + policies: [policy], + organizations: [organization], + ciphers: personalCiphers, + collections: [ + { + id: collectionId, + organizationId: organizationId, + isDefaultCollection: true, + } as CollectionView, + ], + }); + mockDialogService.openSimpleDialog.mockResolvedValue(true); + mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed")); + + await service.enforceOrganizationDataOwnership(userId); + + expect(mockLogService.error).toHaveBeenCalledWith( + "Error transferring personal items to organization", + expect.any(Error), + ); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "error", + message: "errorOccurred", + }); + }); + }); +}); diff --git a/libs/vault/src/services/default-vault-items-transfer.service.ts b/libs/vault/src/services/default-vault-items-transfer.service.ts new file mode 100644 index 00000000000..d9c490f870e --- /dev/null +++ b/libs/vault/src/services/default-vault-items-transfer.service.ts @@ -0,0 +1,231 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom, switchMap, map, of, Observable, combineLatest } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +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 { getById } from "@bitwarden/common/platform/misc"; +import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { + VaultItemsTransferService, + UserMigrationInfo, +} from "../abstractions/vault-items-transfer.service"; + +@Injectable() +export class DefaultVaultItemsTransferService implements VaultItemsTransferService { + constructor( + private cipherService: CipherService, + private policyService: PolicyService, + private organizationService: OrganizationService, + private collectionService: CollectionService, + private logService: LogService, + private i18nService: I18nService, + private dialogService: DialogService, + private toastService: ToastService, + private configService: ConfigService, + ) {} + + private enforcingOrganization$(userId: UserId): Observable { + return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe( + map( + (policies) => + policies.sort((a, b) => a.revisionDate.getTime() - b.revisionDate.getTime())?.[0], + ), + switchMap((policy) => { + if (policy == null) { + return of(undefined); + } + return this.organizationService.organizations$(userId).pipe(getById(policy.organizationId)); + }), + ); + } + + private personalCiphers$(userId: UserId): Observable { + return this.cipherService.cipherViews$(userId).pipe( + filterOutNullish(), + map((ciphers) => ciphers.filter((c) => c.organizationId == null)), + ); + } + + private defaultUserCollection$( + userId: UserId, + organizationId: OrganizationId, + ): Observable { + return this.collectionService.decryptedCollections$(userId).pipe( + map((collections) => { + return collections.find((c) => c.isDefaultCollection && c.organizationId === organizationId) + ?.id; + }), + ); + } + + userMigrationInfo$(userId: UserId): Observable { + return this.enforcingOrganization$(userId).pipe( + switchMap((enforcingOrganization) => { + if (enforcingOrganization == null) { + return of({ + requiresMigration: false, + }); + } + return combineLatest([ + this.personalCiphers$(userId), + this.defaultUserCollection$(userId, enforcingOrganization.id), + ]).pipe( + map(([personalCiphers, defaultCollectionId]): UserMigrationInfo => { + return { + requiresMigration: personalCiphers.length > 0, + enforcingOrganization, + defaultCollectionId, + }; + }), + ); + }), + ); + } + + async enforceOrganizationDataOwnership(userId: UserId): Promise { + const featureEnabled = await this.configService.getFeatureFlag( + FeatureFlag.MigrateMyVaultToMyItems, + ); + + if (!featureEnabled) { + return; + } + + const migrationInfo = await firstValueFrom(this.userMigrationInfo$(userId)); + + if (!migrationInfo.requiresMigration) { + return; + } + + if (migrationInfo.defaultCollectionId == null) { + // TODO: Handle creating the default collection if missing (to be handled by AC in future work) + this.logService.warning( + "Default collection is missing for user during organization data ownership enforcement", + ); + return; + } + + // Temporary confirmation dialog. Full implementation in PM-27663 + const confirmMigration = await this.dialogService.openSimpleDialog({ + title: "Requires migration", + content: "Your vault requires migration of personal items to your organization.", + type: "warning", + }); + + if (!confirmMigration) { + // TODO: Show secondary confirmation dialog in PM-27663, for now we just exit + // TODO: Revoke user from organization if they decline migration PM-29465 + return; + } + + try { + await this.transferPersonalItems( + userId, + migrationInfo.enforcingOrganization.id, + migrationInfo.defaultCollectionId, + ); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("itemsTransferred"), + }); + } catch (error) { + this.logService.error("Error transferring personal items to organization", error); + this.toastService.showToast({ + variant: "error", + message: this.i18nService.t("errorOccurred"), + }); + } + } + + async transferPersonalItems( + userId: UserId, + organizationId: OrganizationId, + defaultCollectionId: CollectionId, + ): Promise { + let personalCiphers = await firstValueFrom(this.personalCiphers$(userId)); + + if (personalCiphers.length === 0) { + return; + } + + const oldAttachmentCiphers = personalCiphers.filter((c) => c.hasOldAttachments); + + if (oldAttachmentCiphers.length > 0) { + await this.upgradeOldAttachments(oldAttachmentCiphers, userId, organizationId); + personalCiphers = await firstValueFrom(this.personalCiphers$(userId)); + + // Sanity check to ensure all old attachments were upgraded, though upgradeOldAttachments should throw if any fail + const remainingOldAttachments = personalCiphers.filter((c) => c.hasOldAttachments); + if (remainingOldAttachments.length > 0) { + throw new Error( + `Failed to upgrade all old attachments. ${remainingOldAttachments.length} ciphers still have old attachments.`, + ); + } + } + + this.logService.info( + `Starting transfer of ${personalCiphers.length} personal ciphers to organization ${organizationId} for user ${userId}`, + ); + + await this.cipherService.shareManyWithServer( + personalCiphers, + organizationId, + [defaultCollectionId], + userId, + ); + } + + /** + * Upgrades old attachments that don't have attachment keys. + * Throws an error if any attachment fails to upgrade as it is not possible to share with an organization without a key. + */ + private async upgradeOldAttachments( + ciphers: CipherView[], + userId: UserId, + organizationId: OrganizationId, + ): Promise { + this.logService.info( + `Found ${ciphers.length} ciphers with old attachments needing upgrade during transfer to organization ${organizationId} for user ${userId}`, + ); + + for (const cipher of ciphers) { + try { + if (!cipher.hasOldAttachments) { + continue; + } + + const upgraded = await this.cipherService.upgradeOldCipherAttachments(cipher, userId); + + if (upgraded.hasOldAttachments) { + this.logService.error( + `Attachment upgrade did not complete successfully for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}`, + ); + throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`); + } + } catch (e) { + this.logService.error( + `Failed to upgrade old attachments for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}: ${e}`, + ); + throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`); + } + } + + this.logService.info( + `Successfully upgraded ${ciphers.length} ciphers with old attachments during transfer to organization ${organizationId} for user ${userId}`, + ); + } +} From 42c09b325c532fb5a6fc55f2633a9c0b5233c57a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 21:45:44 -0500 Subject: [PATCH 15/60] [deps] Autofill: Update rimraf to v6.1.2 (#17295) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 48 ++++++++++++++++++++++++++++++++++++++++------- package.json | 2 +- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index cf9b6becf2f..5321edccd18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -164,7 +164,7 @@ "prettier-plugin-tailwindcss": "0.7.1", "process": "0.11.10", "remark-gfm": "4.0.1", - "rimraf": "6.0.1", + "rimraf": "6.1.2", "sass": "1.94.2", "sass-loader": "16.0.6", "storybook": "9.1.16", @@ -35750,14 +35750,14 @@ "license": "MIT" }, "node_modules/rimraf": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", - "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^11.0.0", - "package-json-from-dist": "^1.0.0" + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" }, "bin": { "rimraf": "dist/esm/bin.mjs" @@ -35769,6 +35769,40 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", diff --git a/package.json b/package.json index 58ff0ba8ea5..c7b04c434e7 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,7 @@ "prettier-plugin-tailwindcss": "0.7.1", "process": "0.11.10", "remark-gfm": "4.0.1", - "rimraf": "6.0.1", + "rimraf": "6.1.2", "sass": "1.94.2", "sass-loader": "16.0.6", "storybook": "9.1.16", From 3af19ad9340deffdcbf6df943d8b9fddf907c60c Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 10 Dec 2025 04:03:31 +0100 Subject: [PATCH 16/60] [PM-28813] Implement encryption diagnostics & recovery tool (#17673) * Implement data recovery tool * Fix tests * Move Sdkloadservice call and use bit action --- .../data-recovery.component.html | 75 ++++ .../data-recovery.component.spec.ts | 348 ++++++++++++++++++ .../data-recovery/data-recovery.component.ts | 208 +++++++++++ .../data-recovery/log-recorder.ts | 19 + .../data-recovery/steps/cipher-step.ts | 81 ++++ .../data-recovery/steps/folder-step.ts | 97 +++++ .../data-recovery/steps/index.ts | 6 + .../data-recovery/steps/private-key-step.ts | 93 +++++ .../data-recovery/steps/recovery-step.ts | 43 +++ .../data-recovery/steps/sync-step.ts | 43 +++ .../data-recovery/steps/user-info-step.ts | 49 +++ apps/web/src/app/oss-routing.module.ts | 7 + apps/web/src/locales/en/messages.json | 48 +++ libs/common/src/enums/feature-flag.enum.ts | 2 + ...ser-asymmetric-key-regeneration.service.ts | 7 + ...symmetric-key-regeneration.service.spec.ts | 49 +++ ...ser-asymmetric-key-regeneration.service.ts | 7 +- 17 files changed, 1180 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/app/key-management/data-recovery/data-recovery.component.html create mode 100644 apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts create mode 100644 apps/web/src/app/key-management/data-recovery/data-recovery.component.ts create mode 100644 apps/web/src/app/key-management/data-recovery/log-recorder.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/folder-step.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/index.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/sync-step.ts create mode 100644 apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.html b/apps/web/src/app/key-management/data-recovery/data-recovery.component.html new file mode 100644 index 00000000000..f357e516115 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.html @@ -0,0 +1,75 @@ +

{{ "dataRecoveryTitle" | i18n }}

+ +
+

+ {{ "dataRecoveryDescription" | i18n }} +

+ + @if (!diagnosticsCompleted() && !recoveryCompleted()) { + + } + +
+ @for (step of steps(); track $index) { + @if ( + ($index === 0 && hasStarted()) || + ($index > 0 && + (steps()[$index - 1].status === StepStatus.Completed || + steps()[$index - 1].status === StepStatus.Failed)) + ) { +
+
+ @if (step.status === StepStatus.Failed) { + + } @else if (step.status === StepStatus.Completed) { + + } @else if (step.status === StepStatus.InProgress) { + + } @else { + + } +
+
+ + {{ step.title }} + +
+
+ } + } +
+ + @if (diagnosticsCompleted()) { +
+ @if (hasIssues() && !recoveryCompleted()) { + + } + +
+ } +
diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts b/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts new file mode 100644 index 00000000000..1976a8dfe27 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.spec.ts @@ -0,0 +1,348 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { DialogService } from "@bitwarden/components"; +import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { DataRecoveryComponent, StepStatus } from "./data-recovery.component"; +import { RecoveryStep, RecoveryWorkingData } from "./steps"; + +// Mock SdkLoadService +jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk-load.service", () => ({ + SdkLoadService: { + Ready: Promise.resolve(), + }, +})); + +describe("DataRecoveryComponent", () => { + let component: DataRecoveryComponent; + let fixture: ComponentFixture; + + // Mock Services + let mockI18nService: MockProxy; + let mockApiService: MockProxy; + let mockAccountService: FakeAccountService; + let mockKeyService: MockProxy; + let mockFolderApiService: MockProxy; + let mockCipherEncryptService: MockProxy; + let mockDialogService: MockProxy; + let mockPrivateKeyRegenerationService: MockProxy; + let mockLogService: MockProxy; + let mockCryptoFunctionService: MockProxy; + let mockFileDownloadService: MockProxy; + + const mockUserId = "user-id" as UserId; + + beforeEach(async () => { + mockI18nService = mock(); + mockApiService = mock(); + mockAccountService = mockAccountServiceWith(mockUserId); + mockKeyService = mock(); + mockFolderApiService = mock(); + mockCipherEncryptService = mock(); + mockDialogService = mock(); + mockPrivateKeyRegenerationService = mock(); + mockLogService = mock(); + mockCryptoFunctionService = mock(); + mockFileDownloadService = mock(); + + mockI18nService.t.mockImplementation((key) => `${key}_used-i18n`); + + await TestBed.configureTestingModule({ + imports: [DataRecoveryComponent], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: ApiService, useValue: mockApiService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: KeyService, useValue: mockKeyService }, + { provide: FolderApiServiceAbstraction, useValue: mockFolderApiService }, + { provide: CipherEncryptionService, useValue: mockCipherEncryptService }, + { provide: DialogService, useValue: mockDialogService }, + { + provide: UserAsymmetricKeysRegenerationService, + useValue: mockPrivateKeyRegenerationService, + }, + { provide: LogService, useValue: mockLogService }, + { provide: CryptoFunctionService, useValue: mockCryptoFunctionService }, + { provide: FileDownloadService, useValue: mockFileDownloadService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DataRecoveryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe("Component Initialization", () => { + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should initialize with default signal values", () => { + expect(component.status()).toBe(StepStatus.NotStarted); + expect(component.hasStarted()).toBe(false); + expect(component.diagnosticsCompleted()).toBe(false); + expect(component.recoveryCompleted()).toBe(false); + expect(component.hasIssues()).toBe(false); + }); + + it("should initialize steps in correct order", () => { + const steps = component.steps(); + expect(steps.length).toBe(5); + expect(steps[0].title).toBe("recoveryStepUserInfoTitle_used-i18n"); + expect(steps[1].title).toBe("recoveryStepSyncTitle_used-i18n"); + expect(steps[2].title).toBe("recoveryStepPrivateKeyTitle_used-i18n"); + expect(steps[3].title).toBe("recoveryStepFoldersTitle_used-i18n"); + expect(steps[4].title).toBe("recoveryStepCipherTitle_used-i18n"); + }); + }); + + describe("runDiagnostics", () => { + let mockSteps: MockProxy[]; + + beforeEach(() => { + // Create mock steps + mockSteps = Array(5) + .fill(null) + .map(() => { + const mockStep = mock(); + mockStep.title = "mockStep"; + mockStep.runDiagnostics.mockResolvedValue(true); + mockStep.canRecover.mockReturnValue(false); + return mockStep; + }); + + // Replace recovery steps with mocks + component["recoverySteps"] = mockSteps; + }); + + it("should not run if already running", async () => { + component["status"].set(StepStatus.InProgress); + await component.runDiagnostics(); + + expect(mockSteps[0].runDiagnostics).not.toHaveBeenCalled(); + }); + + it("should set hasStarted, isRunning and initialize workingData", async () => { + await component.runDiagnostics(); + + expect(component.hasStarted()).toBe(true); + expect(component["workingData"]).toBeDefined(); + expect(component["workingData"]?.userId).toBeNull(); + expect(component["workingData"]?.userKey).toBeNull(); + }); + + it("should run diagnostics for all steps", async () => { + await component.runDiagnostics(); + + mockSteps.forEach((step) => { + expect(step.runDiagnostics).toHaveBeenCalledWith( + component["workingData"], + expect.anything(), + ); + }); + }); + + it("should mark steps as completed when diagnostics succeed", async () => { + await component.runDiagnostics(); + + const steps = component.steps(); + steps.forEach((step) => { + expect(step.status).toBe(StepStatus.Completed); + }); + }); + + it("should mark steps as failed when diagnostics return false", async () => { + mockSteps[2].runDiagnostics.mockResolvedValue(false); + + await component.runDiagnostics(); + + const steps = component.steps(); + expect(steps[2].status).toBe(StepStatus.Failed); + }); + + it("should mark steps as failed when diagnostics throw error", async () => { + mockSteps[3].runDiagnostics.mockRejectedValue(new Error("Test error")); + + await component.runDiagnostics(); + + const steps = component.steps(); + expect(steps[3].status).toBe(StepStatus.Failed); + expect(steps[3].message).toBe("Test error"); + }); + + it("should continue diagnostics even if a step fails", async () => { + mockSteps[1].runDiagnostics.mockRejectedValue(new Error("Step 1 failed")); + mockSteps[3].runDiagnostics.mockResolvedValue(false); + + await component.runDiagnostics(); + + // All steps should have been called despite failures + mockSteps.forEach((step) => { + expect(step.runDiagnostics).toHaveBeenCalled(); + }); + }); + + it("should set hasIssues to true when a step can recover", async () => { + mockSteps[2].runDiagnostics.mockResolvedValue(false); + mockSteps[2].canRecover.mockReturnValue(true); + + await component.runDiagnostics(); + + expect(component.hasIssues()).toBe(true); + }); + + it("should set hasIssues to false when no step can recover", async () => { + mockSteps.forEach((step) => { + step.runDiagnostics.mockResolvedValue(true); + step.canRecover.mockReturnValue(false); + }); + + await component.runDiagnostics(); + + expect(component.hasIssues()).toBe(false); + }); + + it("should set diagnosticsCompleted and status to completed when complete", async () => { + await component.runDiagnostics(); + + expect(component.diagnosticsCompleted()).toBe(true); + expect(component.status()).toBe(StepStatus.Completed); + }); + }); + + describe("runRecovery", () => { + let mockSteps: MockProxy[]; + let mockWorkingData: RecoveryWorkingData; + + beforeEach(() => { + mockWorkingData = { + userId: mockUserId, + userKey: null as any, + isPrivateKeyCorrupt: false, + encryptedPrivateKey: null, + ciphers: [], + folders: [], + }; + + mockSteps = Array(5) + .fill(null) + .map(() => { + const mockStep = mock(); + mockStep.title = "mockStep"; + mockStep.canRecover.mockReturnValue(false); + mockStep.runRecovery.mockResolvedValue(); + mockStep.runDiagnostics.mockResolvedValue(true); + return mockStep; + }); + + component["recoverySteps"] = mockSteps; + component["workingData"] = mockWorkingData; + }); + + it("should not run if already running", async () => { + component["status"].set(StepStatus.InProgress); + await component.runRecovery(); + + expect(mockSteps[0].runRecovery).not.toHaveBeenCalled(); + }); + + it("should not run if workingData is null", async () => { + component["workingData"] = null; + await component.runRecovery(); + + expect(mockSteps[0].runRecovery).not.toHaveBeenCalled(); + }); + + it("should only run recovery for steps that can recover", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + mockSteps[3].canRecover.mockReturnValue(true); + + await component.runRecovery(); + + expect(mockSteps[0].runRecovery).not.toHaveBeenCalled(); + expect(mockSteps[1].runRecovery).toHaveBeenCalled(); + expect(mockSteps[2].runRecovery).not.toHaveBeenCalled(); + expect(mockSteps[3].runRecovery).toHaveBeenCalled(); + expect(mockSteps[4].runRecovery).not.toHaveBeenCalled(); + }); + + it("should set recoveryCompleted and status when successful", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + + await component.runRecovery(); + + expect(component.recoveryCompleted()).toBe(true); + expect(component.status()).toBe(StepStatus.Completed); + }); + + it("should set status to failed if recovery is cancelled", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + mockSteps[1].runRecovery.mockRejectedValue(new Error("User cancelled")); + + await component.runRecovery(); + + expect(component.status()).toBe(StepStatus.Failed); + expect(component.recoveryCompleted()).toBe(false); + }); + + it("should re-run diagnostics after recovery completes", async () => { + mockSteps[1].canRecover.mockReturnValue(true); + + await component.runRecovery(); + + // Diagnostics should be called twice: once for initial diagnostic scan + mockSteps.forEach((step) => { + expect(step.runDiagnostics).toHaveBeenCalledWith(mockWorkingData, expect.anything()); + }); + }); + + it("should update hasIssues after re-running diagnostics", async () => { + // Setup initial state with an issue + mockSteps[1].canRecover.mockReturnValue(true); + mockSteps[1].runDiagnostics.mockResolvedValue(false); + + // After recovery completes, the issue should be fixed + mockSteps[1].runRecovery.mockImplementation(() => { + // Simulate recovery fixing the issue + mockSteps[1].canRecover.mockReturnValue(false); + mockSteps[1].runDiagnostics.mockResolvedValue(true); + return Promise.resolve(); + }); + + await component.runRecovery(); + + // Verify hasIssues is updated after re-running diagnostics + expect(component.hasIssues()).toBe(false); + }); + }); + + describe("saveDiagnosticLogs", () => { + it("should call fileDownloadService with log content", () => { + component.saveDiagnosticLogs(); + + expect(mockFileDownloadService.download).toHaveBeenCalledWith({ + fileName: expect.stringContaining("data-recovery-logs-"), + blobData: expect.any(String), + blobOptions: { type: "text/plain" }, + }); + }); + + it("should include timestamp in filename", () => { + component.saveDiagnosticLogs(); + + const downloadCall = mockFileDownloadService.download.mock.calls[0][0]; + expect(downloadCall.fileName).toMatch(/data-recovery-logs-\d{4}-\d{2}-\d{2}T.*\.txt/); + }); + }); +}); diff --git a/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts b/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts new file mode 100644 index 00000000000..31179dfb062 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/data-recovery.component.ts @@ -0,0 +1,208 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { ButtonModule, DialogService } from "@bitwarden/components"; +import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; + +import { SharedModule } from "../../shared"; + +import { LogRecorder } from "./log-recorder"; +import { + SyncStep, + UserInfoStep, + RecoveryStep, + PrivateKeyStep, + RecoveryWorkingData, + FolderStep, + CipherStep, +} from "./steps"; + +export const StepStatus = Object.freeze({ + NotStarted: 0, + InProgress: 1, + Completed: 2, + Failed: 3, +} as const); +export type StepStatus = (typeof StepStatus)[keyof typeof StepStatus]; + +interface StepState { + title: string; + status: StepStatus; + message?: string; +} + +@Component({ + selector: "app-data-recovery", + templateUrl: "data-recovery.component.html", + standalone: true, + imports: [JslibModule, ButtonModule, CommonModule, SharedModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DataRecoveryComponent { + protected readonly StepStatus = StepStatus; + + private i18nService = inject(I18nService); + private apiService = inject(ApiService); + private accountService = inject(AccountService); + private keyService = inject(KeyService); + private folderApiService = inject(FolderApiServiceAbstraction); + private cipherEncryptService = inject(CipherEncryptionService); + private dialogService = inject(DialogService); + private privateKeyRegenerationService = inject(UserAsymmetricKeysRegenerationService); + private cryptoFunctionService = inject(CryptoFunctionService); + private logService = inject(LogService); + private fileDownloadService = inject(FileDownloadService); + + private logger: LogRecorder = new LogRecorder(this.logService); + private recoverySteps: RecoveryStep[] = [ + new UserInfoStep(this.accountService, this.keyService), + new SyncStep(this.apiService), + new PrivateKeyStep( + this.privateKeyRegenerationService, + this.dialogService, + this.cryptoFunctionService, + ), + new FolderStep(this.folderApiService, this.dialogService), + new CipherStep(this.apiService, this.cipherEncryptService, this.dialogService), + ]; + private workingData: RecoveryWorkingData | null = null; + + readonly status = signal(StepStatus.NotStarted); + readonly hasStarted = signal(false); + readonly diagnosticsCompleted = signal(false); + readonly recoveryCompleted = signal(false); + readonly steps = signal( + this.recoverySteps.map((step) => ({ + title: this.i18nService.t(step.title), + status: StepStatus.NotStarted, + })), + ); + readonly hasIssues = signal(false); + + runDiagnostics = async () => { + if (this.status() === StepStatus.InProgress) { + return; + } + + this.hasStarted.set(true); + this.status.set(StepStatus.InProgress); + this.diagnosticsCompleted.set(false); + + this.logger.record("Starting diagnostics..."); + this.workingData = { + userId: null, + userKey: null, + isPrivateKeyCorrupt: false, + encryptedPrivateKey: null, + ciphers: [], + folders: [], + }; + + await this.runDiagnosticsInternal(); + + this.status.set(StepStatus.Completed); + this.diagnosticsCompleted.set(true); + }; + + private async runDiagnosticsInternal() { + if (!this.workingData) { + this.logger.record("No working data available"); + return; + } + + const currentSteps = this.steps(); + let hasAnyFailures = false; + + for (let i = 0; i < this.recoverySteps.length; i++) { + const step = this.recoverySteps[i]; + currentSteps[i].status = StepStatus.InProgress; + this.steps.set([...currentSteps]); + + this.logger.record(`Running diagnostics for step: ${step.title}`); + try { + const success = await step.runDiagnostics(this.workingData, this.logger); + currentSteps[i].status = success ? StepStatus.Completed : StepStatus.Failed; + if (!success) { + hasAnyFailures = true; + } + this.steps.set([...currentSteps]); + this.logger.record(`Diagnostics completed for step: ${step.title}`); + } catch (error) { + currentSteps[i].status = StepStatus.Failed; + currentSteps[i].message = (error as Error).message; + this.steps.set([...currentSteps]); + this.logger.record( + `Diagnostics failed for step: ${step.title} with error: ${(error as Error).message}`, + ); + hasAnyFailures = true; + } + } + + if (hasAnyFailures) { + this.logger.record("Diagnostics completed with errors"); + } else { + this.logger.record("Diagnostics completed successfully"); + } + + // Check if any recovery can be performed + const canRecoverAnyStep = this.recoverySteps.some((step) => step.canRecover(this.workingData!)); + this.hasIssues.set(canRecoverAnyStep); + } + + runRecovery = async () => { + if (this.status() === StepStatus.InProgress || !this.workingData) { + return; + } + + this.status.set(StepStatus.InProgress); + this.recoveryCompleted.set(false); + + this.logger.record("Starting recovery process..."); + + try { + for (let i = 0; i < this.recoverySteps.length; i++) { + const step = this.recoverySteps[i]; + if (step.canRecover(this.workingData)) { + this.logger.record(`Running recovery for step: ${step.title}`); + await step.runRecovery(this.workingData, this.logger); + } + } + + this.logger.record("Recovery process completed"); + this.recoveryCompleted.set(true); + + // Re-run diagnostics after recovery + this.logger.record("Re-running diagnostics to verify recovery..."); + await this.runDiagnosticsInternal(); + + this.status.set(StepStatus.Completed); + } catch (error) { + this.logger.record(`Recovery process cancelled or failed: ${(error as Error).message}`); + this.status.set(StepStatus.Failed); + } + }; + + saveDiagnosticLogs = () => { + const logs = this.logger.getLogs(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `data-recovery-logs-${timestamp}.txt`; + + const logContent = logs.join("\n"); + this.fileDownloadService.download({ + fileName: filename, + blobData: logContent, + blobOptions: { type: "text/plain" }, + }); + + this.logger.record("Diagnostic logs saved"); + }; +} diff --git a/apps/web/src/app/key-management/data-recovery/log-recorder.ts b/apps/web/src/app/key-management/data-recovery/log-recorder.ts new file mode 100644 index 00000000000..1bca90de48d --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/log-recorder.ts @@ -0,0 +1,19 @@ +import { LogService } from "@bitwarden/logging"; + +/** + * Record logs during the data recovery process. This only keeps them in memory and does not persist them anywhere. + */ +export class LogRecorder { + private logs: string[] = []; + + constructor(private logService: LogService) {} + + record(message: string) { + this.logs.push(message); + this.logService.info(`[DataRecovery] ${message}`); + } + + getLogs(): string[] { + return [...this.logs]; + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts new file mode 100644 index 00000000000..34e8cbdc9f3 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/cipher-step.ts @@ -0,0 +1,81 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service"; +import { DialogService } from "@bitwarden/components"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class CipherStep implements RecoveryStep { + title = "recoveryStepCipherTitle"; + + private undecryptableCipherIds: string[] = []; + + constructor( + private apiService: ApiService, + private cipherService: CipherEncryptionService, + private dialogService: DialogService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + if (!workingData.userId) { + logger.record("Missing user ID"); + return false; + } + + this.undecryptableCipherIds = []; + for (const cipher of workingData.ciphers) { + try { + await this.cipherService.decrypt(cipher, workingData.userId); + } catch { + logger.record(`Cipher ID ${cipher.id} was undecryptable`); + this.undecryptableCipherIds.push(cipher.id); + } + } + logger.record(`Found ${this.undecryptableCipherIds.length} undecryptable ciphers`); + + return this.undecryptableCipherIds.length == 0; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return this.undecryptableCipherIds.length > 0; + } + + async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // Recovery means deleting the broken ciphers. + if (this.undecryptableCipherIds.length === 0) { + logger.record("No undecryptable ciphers to recover"); + return; + } + + logger.record(`Showing confirmation dialog for ${this.undecryptableCipherIds.length} ciphers`); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "recoveryDeleteCiphersTitle" }, + content: { key: "recoveryDeleteCiphersDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: { key: "cancel" }, + type: "danger", + }); + + if (!confirmed) { + logger.record("User cancelled cipher deletion"); + throw new Error("Cipher recovery cancelled by user"); + } + + logger.record(`Deleting ${this.undecryptableCipherIds.length} ciphers`); + + for (const cipherId of this.undecryptableCipherIds) { + try { + await this.apiService.deleteCipher(cipherId); + logger.record(`Deleted cipher ${cipherId}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.record(`Failed to delete cipher ${cipherId}: ${errorMessage}`); + throw error; + } + } + + logger.record(`Successfully deleted ${this.undecryptableCipherIds.length} ciphers`); + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts new file mode 100644 index 00000000000..bc0ae31efba --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/folder-step.ts @@ -0,0 +1,97 @@ +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; +import { DialogService } from "@bitwarden/components"; +import { PureCrypto } from "@bitwarden/sdk-internal"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class FolderStep implements RecoveryStep { + title = "recoveryStepFoldersTitle"; + + private undecryptableFolderIds: string[] = []; + + constructor( + private folderService: FolderApiServiceAbstraction, + private dialogService: DialogService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + if (!workingData.userKey) { + logger.record("Missing user key"); + return false; + } + + this.undecryptableFolderIds = []; + for (const folder of workingData.folders) { + if (!folder.name?.encryptedString) { + logger.record(`Folder ID ${folder.id} has no name`); + this.undecryptableFolderIds.push(folder.id); + continue; + } + try { + await SdkLoadService.Ready; + PureCrypto.symmetric_decrypt_string( + folder.name.encryptedString, + workingData.userKey.toEncoded(), + ); + } catch { + logger.record(`Folder name for folder ID ${folder.id} was undecryptable`); + this.undecryptableFolderIds.push(folder.id); + } + } + logger.record(`Found ${this.undecryptableFolderIds.length} undecryptable folders`); + + return this.undecryptableFolderIds.length == 0; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return this.undecryptableFolderIds.length > 0; + } + + async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // Recovery means deleting the broken folders. + if (this.undecryptableFolderIds.length === 0) { + logger.record("No undecryptable folders to recover"); + return; + } + + if (!workingData.userId) { + logger.record("Missing user ID"); + throw new Error("Missing user ID"); + } + + logger.record(`Showing confirmation dialog for ${this.undecryptableFolderIds.length} folders`); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "recoveryDeleteFoldersTitle" }, + content: { key: "recoveryDeleteFoldersDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: { key: "cancel" }, + type: "danger", + }); + + if (!confirmed) { + logger.record("User cancelled folder deletion"); + throw new Error("Folder recovery cancelled by user"); + } + + logger.record(`Deleting ${this.undecryptableFolderIds.length} folders`); + + for (const folderId of this.undecryptableFolderIds) { + try { + await this.folderService.delete(folderId, workingData.userId); + logger.record(`Deleted folder ${folderId}`); + } catch (error) { + logger.record(`Failed to delete folder ${folderId}: ${error}`); + } + } + + logger.record(`Successfully deleted ${this.undecryptableFolderIds.length} folders`); + } + + getUndecryptableFolderIds(): string[] { + return this.undecryptableFolderIds; + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/index.ts b/apps/web/src/app/key-management/data-recovery/steps/index.ts new file mode 100644 index 00000000000..caf3cdb34ef --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/index.ts @@ -0,0 +1,6 @@ +export * from "./sync-step"; +export * from "./user-info-step"; +export * from "./recovery-step"; +export * from "./private-key-step"; +export * from "./folder-step"; +export * from "./cipher-step"; diff --git a/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts b/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts new file mode 100644 index 00000000000..82c20c466b8 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/private-key-step.ts @@ -0,0 +1,93 @@ +import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; +import { DialogService } from "@bitwarden/components"; +import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management"; +import { PureCrypto } from "@bitwarden/sdk-internal"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class PrivateKeyStep implements RecoveryStep { + title = "recoveryStepPrivateKeyTitle"; + + constructor( + private privateKeyRegenerationService: UserAsymmetricKeysRegenerationService, + private dialogService: DialogService, + private cryptoFunctionService: CryptoFunctionService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + if (!workingData.userId || !workingData.userKey) { + logger.record("Missing user ID or user key"); + return false; + } + + // Make sure the private key decrypts properly and is not somehow encrypted by a different user key / broken during key rotation. + const encryptedPrivateKey = workingData.encryptedPrivateKey; + if (!encryptedPrivateKey) { + logger.record("No encrypted private key found"); + return false; + } + logger.record("Private key length: " + encryptedPrivateKey.length); + let privateKey: Uint8Array; + try { + await SdkLoadService.Ready; + privateKey = PureCrypto.unwrap_decapsulation_key( + encryptedPrivateKey, + workingData.userKey.toEncoded(), + ); + } catch { + logger.record("Private key was un-decryptable"); + workingData.isPrivateKeyCorrupt = true; + return false; + } + + // Make sure the contained private key can be parsed and the public key can be derived. If not, then the private key may be corrupt / generated with an incompatible ASN.1 representation / with incompatible padding. + try { + const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); + logger.record("Public key length: " + publicKey.length); + } catch { + logger.record("Public key could not be derived; private key is corrupt"); + workingData.isPrivateKeyCorrupt = true; + return false; + } + + return true; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + // Only support recovery on V1 users. + return ( + workingData.isPrivateKeyCorrupt && + workingData.userKey !== null && + workingData.userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64 + ); + } + + async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // The recovery step is to replace the key pair. Currently, this only works if the user is not using emergency access or is part of an organization. + // This is because this will break emergency access enrollments / organization memberships / provider memberships. + logger.record("Showing confirmation dialog for private key replacement"); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "recoveryReplacePrivateKeyTitle" }, + content: { key: "recoveryReplacePrivateKeyDesc" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: { key: "cancel" }, + type: "danger", + }); + + if (!confirmed) { + logger.record("User cancelled private key replacement"); + throw new Error("Private key recovery cancelled by user"); + } + + logger.record("Replacing private key"); + await this.privateKeyRegenerationService.regenerateUserPublicKeyEncryptionKeyPair( + workingData.userId!, + ); + logger.record("Private key replaced successfully"); + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts b/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts new file mode 100644 index 00000000000..265d7c68284 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/recovery-step.ts @@ -0,0 +1,43 @@ +import { WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { UserKey } from "@bitwarden/common/types/key"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; +import { UserId } from "@bitwarden/user-core"; + +import { LogRecorder } from "../log-recorder"; + +/** + * A recovery step performs diagnostics and recovery actions on a specific domain, such as ciphers. + */ +export abstract class RecoveryStep { + /** Title of the recovery step, as an i18n key. */ + abstract title: string; + + /** + * Runs diagnostics on the provided working data. + * Returns true if no issues were found, false otherwise. + */ + abstract runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise; + + /** + * Returns whether recovery can be performed + */ + abstract canRecover(workingData: RecoveryWorkingData): boolean; + + /** + * Performs recovery on the provided working data. + */ + abstract runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise; +} + +/** + * Data used during the recovery process, passed between steps. + */ +export type RecoveryWorkingData = { + userId: UserId | null; + userKey: UserKey | null; + encryptedPrivateKey: WrappedPrivateKey | null; + isPrivateKeyCorrupt: boolean; + ciphers: Cipher[]; + folders: Folder[]; +}; diff --git a/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts b/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts new file mode 100644 index 00000000000..f0adb1e0b46 --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/sync-step.ts @@ -0,0 +1,43 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; +import { FolderData } from "@bitwarden/common/vault/models/data/folder.data"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { Folder } from "@bitwarden/common/vault/models/domain/folder"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class SyncStep implements RecoveryStep { + title = "recoveryStepSyncTitle"; + + constructor(private apiService: ApiService) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + // The intent of this step is to fetch the latest data from the server. Diagnostics does not + // ever run on local data but only remote data that is recent. + const response = await this.apiService.getSync(); + + workingData.ciphers = response.ciphers.map((c) => new Cipher(new CipherData(c))); + logger.record(`Fetched ${workingData.ciphers.length} ciphers from server`); + + workingData.folders = response.folders.map((f) => new Folder(new FolderData(f))); + logger.record(`Fetched ${workingData.folders.length} folders from server`); + + workingData.encryptedPrivateKey = + response.profile?.accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey ?? null; + logger.record( + `Fetched encrypted private key of length ${workingData.encryptedPrivateKey?.length ?? 0} from server`, + ); + + return true; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return false; + } + + runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + return Promise.resolve(); + } +} diff --git a/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts b/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts new file mode 100644 index 00000000000..9565b1da73b --- /dev/null +++ b/apps/web/src/app/key-management/data-recovery/steps/user-info-step.ts @@ -0,0 +1,49 @@ +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; +import { KeyService } from "@bitwarden/key-management"; + +import { LogRecorder } from "../log-recorder"; + +import { RecoveryStep, RecoveryWorkingData } from "./recovery-step"; + +export class UserInfoStep implements RecoveryStep { + title = "recoveryStepUserInfoTitle"; + + constructor( + private accountService: AccountService, + private keyService: KeyService, + ) {} + + async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + if (!activeAccount) { + logger.record("No active account found"); + return false; + } + const userId = activeAccount.id; + workingData.userId = userId; + logger.record(`User ID: ${userId}`); + + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + if (!userKey) { + logger.record("No user key found"); + return false; + } + workingData.userKey = userKey; + logger.record( + `User encryption type: ${userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64 ? "V1" : userKey.inner().type === EncryptionType.CoseEncrypt0 ? "Cose" : "Unknown"}`, + ); + + return true; + } + + canRecover(workingData: RecoveryWorkingData): boolean { + return false; + } + + runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise { + return Promise.resolve(); + } +} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index b40b9143991..ac9bdc4b946 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -78,6 +78,7 @@ import { freeTrialTextResolver } from "./billing/trial-initiation/complete-trial import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component"; import { RouteDataProperties } from "./core"; import { ReportsModule } from "./dirt/reports"; +import { DataRecoveryComponent } from "./key-management/data-recovery/data-recovery.component"; import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component"; import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component"; import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; @@ -696,6 +697,12 @@ const routes: Routes = [ path: "security", loadChildren: () => SecurityRoutingModule, }, + { + path: "data-recovery", + component: DataRecoveryComponent, + canActivate: [canAccessFeature(FeatureFlag.DataRecoveryTool)], + data: { titleId: "dataRecovery" } satisfies RouteDataProperties, + }, { path: "domain-rules", component: DomainRulesComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a755e4de556..c827f09d173 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -12253,6 +12253,54 @@ "userVerificationFailed": { "message": "User verification failed." }, + "recoveryDeleteCiphersTitle": { + "message": "Delete unrecoverable vault items" + }, + "recoveryDeleteCiphersDesc": { + "message": "Some of your vault items could not be recovered. Do you want to delete these unrecoverable items from your vault?" + }, + "recoveryDeleteFoldersTitle": { + "message": "Delete unrecoverable folders" + }, + "recoveryDeleteFoldersDesc": { + "message": "Some of your folders could not be recovered. Do you want to delete these unrecoverable folders from your vault?" + }, + "recoveryReplacePrivateKeyTitle": { + "message": "Replace encryption key" + }, + "recoveryReplacePrivateKeyDesc": { + "message": "Your public-key encryption key pair could not be recovered. Do you want to replace your encryption key with a new key pair? This will require you to set up existing emergency-access and organization memberships again." + }, + "recoveryStepSyncTitle": { + "message": "Synchronizing data" + }, + "recoveryStepPrivateKeyTitle": { + "message": "Verifying encryption key integrity" + }, + "recoveryStepUserInfoTitle": { + "message": "Verifying user information" + }, + "recoveryStepCipherTitle": { + "message": "Verifying vault item integrity" + }, + "recoveryStepFoldersTitle": { + "message": "Verifying folder integrity" + }, + "dataRecoveryTitle": { + "message": "Data Recovery and Diagnostics" + }, + "dataRecoveryDescription": { + "message": "Use the data recovery tool to diagnose and repair issues with your account. After running diagnostics you have the option to save diagnostic logs for support and the option to repair any detected issues." + }, + "runDiagnostics": { + "message": "Run Diagnostics" + }, + "repairIssues": { + "message": "Repair Issues" + }, + "saveDiagnosticLogs": { + "message": "Save Diagnostic Logs" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 1727d3da712..fb8edd8aa7d 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -43,6 +43,7 @@ export enum FeatureFlag { LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2", UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", + DataRecoveryTool = "pm-28813-data-recovery-tool", ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component", /* Tools */ @@ -149,6 +150,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.LinuxBiometricsV2]: FALSE, [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, [FeatureFlag.NoLogoutOnKdfChange]: FALSE, + [FeatureFlag.DataRecoveryTool]: FALSE, [FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE, /* Platform */ diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts index 4703d836db7..58620f49ed1 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/abstractions/user-asymmetric-key-regeneration.service.ts @@ -7,4 +7,11 @@ export abstract class UserAsymmetricKeysRegenerationService { * @param userId The user id. */ abstract regenerateIfNeeded(userId: UserId): Promise; + + /** + * Performs the regeneration of the user's public/private key pair without checking any preconditions. + * This should only be used for V1 encryption accounts + * @param userId The user id. + */ + abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise; } diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts index e57ab74de6b..92e5240a187 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.spec.ts @@ -370,3 +370,52 @@ describe("regenerateIfNeeded", () => { ); }); }); + +describe("regenerateUserPublicKeyEncryptionKeyPair", () => { + let sut: DefaultUserAsymmetricKeysRegenerationService; + const userId = "userId" as UserId; + + let keyService: MockProxy; + let cipherService: MockProxy; + let userAsymmetricKeysRegenerationApiService: MockProxy; + let logService: MockProxy; + let sdkService: MockSdkService; + let apiService: MockProxy; + let configService: MockProxy; + + beforeEach(() => { + keyService = mock(); + cipherService = mock(); + userAsymmetricKeysRegenerationApiService = mock(); + logService = mock(); + sdkService = new MockSdkService(); + apiService = mock(); + configService = mock(); + + sut = new DefaultUserAsymmetricKeysRegenerationService( + keyService, + cipherService, + userAsymmetricKeysRegenerationApiService, + logService, + sdkService, + apiService, + configService, + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should throw error when user key is not V1 encryption type", async () => { + const mockUserKey = { + keyB64: "mockKeyB64", + inner: () => ({ type: 7 }), + } as unknown as UserKey; + keyService.userKey$.mockReturnValue(of(mockUserKey)); + + await expect(sut.regenerateUserPublicKeyEncryptionKeyPair(userId)).rejects.toThrow( + "User key is not V1 encryption type", + ); + }); +}); diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts index 335f45b0ce2..48fe3a1686f 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts @@ -37,7 +37,7 @@ export class DefaultUserAsymmetricKeysRegenerationService if (privateKeyRegenerationFlag) { const shouldRegenerate = await this.shouldRegenerate(userId); if (shouldRegenerate) { - await this.regenerateUserAsymmetricKeys(userId); + await this.regenerateUserPublicKeyEncryptionKeyPair(userId); } } } catch (error) { @@ -125,11 +125,14 @@ export class DefaultUserAsymmetricKeysRegenerationService return false; } - private async regenerateUserAsymmetricKeys(userId: UserId): Promise { + async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise { const userKey = await firstValueFrom(this.keyService.userKey$(userId)); if (userKey == null) { throw new Error("User key not found"); } + if (userKey.inner().type !== EncryptionType.AesCbc256_HmacSha256_B64) { + throw new Error("User key is not V1 encryption type"); + } const makeKeyPairResponse = await firstValueFrom( this.sdkService.client$.pipe( map((sdk) => { From 3e9db6b472a04d94da214336add1fa4a3814a48e Mon Sep 17 00:00:00 2001 From: bmbitwarden Date: Tue, 9 Dec 2025 23:49:58 -0500 Subject: [PATCH 17/60] PM-27628 Rename Remove individual vault export policy (#17335) * PM-27628 conditions for send and export links in left navbar * PM-27628 resolved claude comment for pr * PM-27628 resolved claude comment for pr * PM-27628 reverted earlier display conditionals and changed label * PM-27628 changed out keys as well * PM-27628 revert description key change --- apps/web/src/locales/en/messages.json | 4 ++-- .../disable-personal-vault-export.component.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index c827f09d173..85159c0230c 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6815,8 +6815,8 @@ "vaultTimeoutRangeError": { "message": "Vault timeout is not within allowed range." }, - "disablePersonalVaultExport": { - "message": "Remove individual vault export" + "disableExport": { + "message": "Remove export" }, "disablePersonalVaultExportDescription": { "message": "Do not allow members to export data from their individual vault." diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts index 0f0fc5f358d..5a9b36912c9 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts @@ -8,7 +8,7 @@ import { import { SharedModule } from "@bitwarden/web-vault/app/shared"; export class DisablePersonalVaultExportPolicy extends BasePolicyEditDefinition { - name = "disablePersonalVaultExport"; + name = "disableExport"; description = "disablePersonalVaultExportDescription"; type = PolicyType.DisablePersonalVaultExport; component = DisablePersonalVaultExportPolicyComponent; From 663ef60ae5113115c092578193cf52c504f42572 Mon Sep 17 00:00:00 2001 From: Isaac Ivins Date: Wed, 10 Dec 2025 04:02:30 -0500 Subject: [PATCH 18/60] Feature/pm 27795 migrate send filters desktop migration (#17802) Created a new navigation component that renders Send type filters as sidebar navigation items. --- .../app/layout/desktop-layout.component.html | 2 +- .../layout/desktop-layout.component.spec.ts | 25 ++- .../app/layout/desktop-layout.component.ts | 11 +- .../send-v2/send-filters-nav.component.html | 25 +++ .../send-filters-nav.component.spec.ts | 204 ++++++++++++++++++ .../send-v2/send-filters-nav.component.ts | 54 +++++ .../tools/send-v2/send-v2.component.spec.ts | 34 ++- .../app/tools/send-v2/send-v2.component.ts | 44 +++- 8 files changed, 382 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/app/tools/send-v2/send-filters-nav.component.html create mode 100644 apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts create mode 100644 apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts diff --git a/apps/desktop/src/app/layout/desktop-layout.component.html b/apps/desktop/src/app/layout/desktop-layout.component.html index 7f8bd265102..1717b29acd1 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.html +++ b/apps/desktop/src/app/layout/desktop-layout.component.html @@ -3,7 +3,7 @@ - + diff --git a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts index cc2f7e58dfb..74cddd02495 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.spec.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.spec.ts @@ -1,3 +1,4 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { RouterModule } from "@angular/router"; import { mock } from "jest-mock-extended"; @@ -5,8 +6,18 @@ import { mock } from "jest-mock-extended"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { NavigationModule } from "@bitwarden/components"; +import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component"; + import { DesktopLayoutComponent } from "./desktop-layout.component"; +// Mock the child component to isolate DesktopLayoutComponent testing +@Component({ + selector: "app-send-filters-nav", + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockSendFiltersNavComponent {} + Object.defineProperty(window, "matchMedia", { writable: true, value: jest.fn().mockImplementation((query) => ({ @@ -34,7 +45,12 @@ describe("DesktopLayoutComponent", () => { useValue: mock(), }, ], - }).compileComponents(); + }) + .overrideComponent(DesktopLayoutComponent, { + remove: { imports: [SendFiltersNavComponent] }, + add: { imports: [MockSendFiltersNavComponent] }, + }) + .compileComponents(); fixture = TestBed.createComponent(DesktopLayoutComponent); component = fixture.componentInstance; @@ -58,4 +74,11 @@ describe("DesktopLayoutComponent", () => { expect(ngContent).toBeTruthy(); }); + + it("renders send filters navigation component", () => { + const compiled = fixture.nativeElement; + const sendFiltersNav = compiled.querySelector("app-send-filters-nav"); + + expect(sendFiltersNav).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/app/layout/desktop-layout.component.ts b/apps/desktop/src/app/layout/desktop-layout.component.ts index 006055f475f..0ee7065fba8 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.ts +++ b/apps/desktop/src/app/layout/desktop-layout.component.ts @@ -5,13 +5,22 @@ import { PasswordManagerLogo } from "@bitwarden/assets/svg"; import { LayoutComponent, NavigationModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { SendFiltersNavComponent } from "../tools/send-v2/send-filters-nav.component"; + import { DesktopSideNavComponent } from "./desktop-side-nav.component"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-layout", - imports: [RouterModule, I18nPipe, LayoutComponent, NavigationModule, DesktopSideNavComponent], + imports: [ + RouterModule, + I18nPipe, + LayoutComponent, + NavigationModule, + DesktopSideNavComponent, + SendFiltersNavComponent, + ], templateUrl: "./desktop-layout.component.html", }) export class DesktopLayoutComponent { diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.html b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.html new file mode 100644 index 00000000000..64c52b50a49 --- /dev/null +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.html @@ -0,0 +1,25 @@ + + + + + diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts new file mode 100644 index 00000000000..95ba5c53e36 --- /dev/null +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.spec.ts @@ -0,0 +1,204 @@ +import { ChangeDetectionStrategy, Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Router, provideRouter } from "@angular/router"; +import { RouterTestingHarness } from "@angular/router/testing"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { NavigationModule } from "@bitwarden/components"; +import { SendListFiltersService } from "@bitwarden/send-ui"; + +import { SendFiltersNavComponent } from "./send-filters-nav.component"; + +@Component({ template: "", changeDetection: ChangeDetectionStrategy.OnPush }) +class DummyComponent {} + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: true, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +describe("SendFiltersNavComponent", () => { + let component: SendFiltersNavComponent; + let fixture: ComponentFixture; + let harness: RouterTestingHarness; + let filterFormValueSubject: BehaviorSubject<{ sendType: SendType | null }>; + let mockSendListFiltersService: Partial; + + beforeEach(async () => { + filterFormValueSubject = new BehaviorSubject<{ sendType: SendType | null }>({ + sendType: null, + }); + + mockSendListFiltersService = { + filterForm: { + value: { sendType: null }, + valueChanges: filterFormValueSubject.asObservable(), + patchValue: jest.fn((value) => { + mockSendListFiltersService.filterForm.value = { + ...mockSendListFiltersService.filterForm.value, + ...value, + }; + filterFormValueSubject.next(mockSendListFiltersService.filterForm.value); + }), + } as any, + filters$: filterFormValueSubject.asObservable(), + }; + + await TestBed.configureTestingModule({ + imports: [SendFiltersNavComponent, NavigationModule], + providers: [ + provideRouter([ + { path: "vault", component: DummyComponent }, + { path: "new-sends", component: DummyComponent }, + ]), + { + provide: SendListFiltersService, + useValue: mockSendListFiltersService, + }, + { + provide: I18nService, + useValue: { + t: jest.fn((key) => key), + }, + }, + ], + }).compileComponents(); + + // Create harness and navigate to initial route + harness = await RouterTestingHarness.create("/vault"); + + // Create the component fixture separately (not a routed component) + fixture = TestBed.createComponent(SendFiltersNavComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("creates component", () => { + expect(component).toBeTruthy(); + }); + + it("renders bit-nav-group with Send icon and text", () => { + const compiled = fixture.nativeElement; + const navGroup = compiled.querySelector("bit-nav-group"); + + expect(navGroup).toBeTruthy(); + expect(navGroup.getAttribute("icon")).toBe("bwi-send"); + }); + + it("component exposes SendType enum for template", () => { + expect(component["SendType"]).toBe(SendType); + }); + + describe("isSendRouteActive", () => { + it("returns true when on /new-sends route", async () => { + await harness.navigateByUrl("/new-sends"); + fixture.detectChanges(); + + expect(component["isSendRouteActive"]()).toBe(true); + }); + + it("returns false when not on /new-sends route", () => { + expect(component["isSendRouteActive"]()).toBe(false); + }); + }); + + describe("activeSendType", () => { + it("returns the active send type when on send route and filter type is set", async () => { + await harness.navigateByUrl("/new-sends"); + mockSendListFiltersService.filterForm.value = { sendType: SendType.Text }; + filterFormValueSubject.next({ sendType: SendType.Text }); + fixture.detectChanges(); + + expect(component["activeSendType"]()).toBe(SendType.Text); + }); + + it("returns undefined when not on send route", () => { + mockSendListFiltersService.filterForm.value = { sendType: SendType.Text }; + filterFormValueSubject.next({ sendType: SendType.Text }); + fixture.detectChanges(); + + expect(component["activeSendType"]()).toBeUndefined(); + }); + + it("returns null when on send route but no type is selected", async () => { + await harness.navigateByUrl("/new-sends"); + mockSendListFiltersService.filterForm.value = { sendType: null }; + filterFormValueSubject.next({ sendType: null }); + fixture.detectChanges(); + + expect(component["activeSendType"]()).toBeNull(); + }); + }); + + describe("selectTypeAndNavigate", () => { + it("clears the sendType filter when called with no parameter", async () => { + await component["selectTypeAndNavigate"](); + + expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({ + sendType: null, + }); + }); + + it("updates filter form with Text type", async () => { + await component["selectTypeAndNavigate"](SendType.Text); + + expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({ + sendType: SendType.Text, + }); + }); + + it("updates filter form with File type", async () => { + await component["selectTypeAndNavigate"](SendType.File); + + expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({ + sendType: SendType.File, + }); + }); + + it("navigates to /new-sends when not on send route", async () => { + expect(harness.routeNativeElement?.textContent).toBeDefined(); + + await component["selectTypeAndNavigate"](SendType.Text); + + const currentUrl = TestBed.inject(Router).url; + expect(currentUrl).toBe("/new-sends"); + expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({ + sendType: SendType.Text, + }); + }); + + it("does not navigate when already on send route (component is reactive)", async () => { + await harness.navigateByUrl("/new-sends"); + const router = TestBed.inject(Router); + const navigateSpy = jest.spyOn(router, "navigate"); + + await component["selectTypeAndNavigate"](SendType.Text); + + expect(navigateSpy).not.toHaveBeenCalled(); + expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({ + sendType: SendType.Text, + }); + }); + + it("navigates when clearing filter from different route", async () => { + await component["selectTypeAndNavigate"](); // No parameter = clear filter + + const currentUrl = TestBed.inject(Router).url; + expect(currentUrl).toBe("/new-sends"); + expect(mockSendListFiltersService.filterForm.patchValue).toHaveBeenCalledWith({ + sendType: null, + }); + }); + }); +}); diff --git a/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts new file mode 100644 index 00000000000..28004f475e5 --- /dev/null +++ b/apps/desktop/src/app/tools/send-v2/send-filters-nav.component.ts @@ -0,0 +1,54 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, computed, inject } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { NavigationEnd, Router } from "@angular/router"; +import { filter, map, startWith } from "rxjs"; + +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { NavigationModule } from "@bitwarden/components"; +import { SendListFiltersService } from "@bitwarden/send-ui"; +import { I18nPipe } from "@bitwarden/ui-common"; + +/** + * Navigation component that renders Send filter options in the sidebar. + * Fully reactive using signals - no manual subscriptions or method-based computed values. + * - Parent "Send" nav-group clears filter (shows all sends) + * - Child "Text"/"File" items set filter to specific type + * - Active states computed reactively from filter signal + route signal + */ +@Component({ + selector: "app-send-filters-nav", + templateUrl: "./send-filters-nav.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, NavigationModule, I18nPipe], +}) +export class SendFiltersNavComponent { + protected readonly SendType = SendType; + private readonly filtersService = inject(SendListFiltersService); + private readonly router = inject(Router); + private readonly currentFilter = toSignal(this.filtersService.filters$); + + // Track whether current route is the send route + private readonly isSendRouteActive = toSignal( + this.router.events.pipe( + filter((event) => event instanceof NavigationEnd), + map((event) => (event as NavigationEnd).urlAfterRedirects.includes("/new-sends")), + startWith(this.router.url.includes("/new-sends")), + ), + { initialValue: this.router.url.includes("/new-sends") }, + ); + + // Computed: Active send type (null when on send route with no filter, undefined when not on send route) + protected readonly activeSendType = computed(() => { + return this.isSendRouteActive() ? this.currentFilter()?.sendType : undefined; + }); + + // Update send filter and navigate to /new-sends (only if not already there - send-v2 component reacts to filter changes) + protected async selectTypeAndNavigate(type?: SendType): Promise { + this.filtersService.filterForm.patchValue({ sendType: type !== undefined ? type : null }); + + if (!this.router.url.includes("/new-sends")) { + await this.router.navigate(["/new-sends"]); + } + } +} diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts index 5798df0989d..8657f3e375e 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.spec.ts @@ -1,4 +1,8 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { ChangeDetectorRef } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder } from "@angular/forms"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; @@ -15,6 +19,7 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { SendListFiltersService } from "@bitwarden/send-ui"; import * as utils from "../../../utils"; import { SearchBarService } from "../../layout/search/search-bar.service"; @@ -35,6 +40,8 @@ describe("SendV2Component", () => { let broadcasterService: MockProxy; let accountService: MockProxy; let policyService: MockProxy; + let sendListFiltersService: SendListFiltersService; + let changeDetectorRef: MockProxy; beforeEach(async () => { sendService = mock(); @@ -42,6 +49,13 @@ describe("SendV2Component", () => { broadcasterService = mock(); accountService = mock(); policyService = mock(); + changeDetectorRef = mock(); + + // Create real SendListFiltersService with mocked dependencies + const formBuilder = new FormBuilder(); + const i18nService = mock(); + i18nService.t.mockImplementation((key: string) => key); + sendListFiltersService = new SendListFiltersService(i18nService, formBuilder); // Mock sendViews$ observable sendService.sendViews$ = of([]); @@ -51,6 +65,10 @@ describe("SendV2Component", () => { accountService.activeAccount$ = of({ id: "test-user-id" } as any); policyService.policyAppliesToUser$ = jest.fn().mockReturnValue(of(false)); + // Mock SearchService methods needed by base component + const mockSearchService = mock(); + mockSearchService.isSearchable.mockResolvedValue(false); + await TestBed.configureTestingModule({ imports: [SendV2Component], providers: [ @@ -59,7 +77,7 @@ describe("SendV2Component", () => { { provide: PlatformUtilsService, useValue: mock() }, { provide: EnvironmentService, useValue: mock() }, { provide: BroadcasterService, useValue: broadcasterService }, - { provide: SearchService, useValue: mock() }, + { provide: SearchService, useValue: mockSearchService }, { provide: PolicyService, useValue: policyService }, { provide: SearchBarService, useValue: searchBarService }, { provide: LogService, useValue: mock() }, @@ -67,6 +85,8 @@ describe("SendV2Component", () => { { provide: DialogService, useValue: mock() }, { provide: ToastService, useValue: mock() }, { provide: AccountService, useValue: accountService }, + { provide: SendListFiltersService, useValue: sendListFiltersService }, + { provide: ChangeDetectorRef, useValue: changeDetectorRef }, ], }).compileComponents(); @@ -331,7 +351,6 @@ describe("SendV2Component", () => { describe("load", () => { it("sets loading states correctly", async () => { jest.spyOn(component, "search").mockResolvedValue(); - jest.spyOn(component, "selectAll"); expect(component.loaded).toBeFalsy(); @@ -341,14 +360,17 @@ describe("SendV2Component", () => { expect(component.loaded).toBe(true); }); - it("calls selectAll when onSuccessfulLoad is not set", async () => { + it("sets up sendViews$ subscription", async () => { + const mockSends = [new SendView(), new SendView()]; + sendService.sendViews$ = of(mockSends); jest.spyOn(component, "search").mockResolvedValue(); - jest.spyOn(component, "selectAll"); - component.onSuccessfulLoad = null; await component.load(); - expect(component.selectAll).toHaveBeenCalled(); + // Give observable time to emit + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(component.sends).toEqual(mockSends); }); it("calls onSuccessfulLoad when it is set", async () => { diff --git a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts index 4afe02d9f98..eb0856b76af 100644 --- a/apps/desktop/src/app/tools/send-v2/send-v2.component.ts +++ b/apps/desktop/src/app/tools/send-v2/send-v2.component.ts @@ -2,8 +2,9 @@ // @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component, OnInit, OnDestroy, ViewChild, NgZone, ChangeDetectorRef } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; -import { mergeMap } from "rxjs"; +import { mergeMap, Subscription } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component"; @@ -14,11 +15,13 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi 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 { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { SendListFiltersService } from "@bitwarden/send-ui"; import { invokeMenu, RendererMenuItem } from "../../../utils"; import { SearchBarService } from "../../layout/search/search-bar.service"; @@ -55,6 +58,9 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest // Tracks the current UI state: viewing list (None), adding new Send (Add), or editing existing Send (Edit) action: Action = Action.None; + // Subscription for sendViews$ cleanup + private sendViewsSubscription: Subscription; + constructor( sendService: SendService, i18nService: I18nService, @@ -71,6 +77,7 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest toastService: ToastService, accountService: AccountService, private cdr: ChangeDetectorRef, + private sendListFiltersService: SendListFiltersService, ) { super( sendService, @@ -88,12 +95,17 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest ); // Listen to search bar changes and update the Send list filter - // eslint-disable-next-line rxjs-angular/prefer-takeuntil - this.searchBarService.searchText$.subscribe((searchText) => { + this.searchBarService.searchText$.pipe(takeUntilDestroyed()).subscribe((searchText) => { this.searchText = searchText; this.searchTextChanged(); - setTimeout(() => this.cdr.detectChanges(), 250); }); + + // Listen to filter changes from sidebar navigation + this.sendListFiltersService.filterForm.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((filters) => { + this.applySendTypeFilter(filters); + }); } // Initialize the component: enable search bar, subscribe to sync events, and load Send items @@ -103,6 +115,10 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest await super.ngOnInit(); + // Read current filter synchronously to avoid race condition on navigation + const currentFilter = this.sendListFiltersService.filterForm.value; + this.applySendTypeFilter(currentFilter); + // Listen for sync completion events to refresh the Send list this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -118,8 +134,18 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest await this.load(); } + // Apply send type filter to display: centralized logic for initial load and filter changes + private applySendTypeFilter(filters: Partial<{ sendType: SendType | null }>): void { + if (filters.sendType === null || filters.sendType === undefined) { + this.selectAll(); + } else { + this.selectType(filters.sendType); + } + } + // Clean up subscriptions and disable search bar when component is destroyed ngOnDestroy() { + this.sendViewsSubscription?.unsubscribe(); this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); this.searchBarService.setEnabled(false); } @@ -130,7 +156,12 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest // Note: The filter parameter is ignored in this implementation for desktop-specific behavior. async load(filter: (send: SendView) => boolean = null) { this.loading = true; - this.sendService.sendViews$ + + // Recreate subscription on each load (required for sync refresh) + // Manual cleanup in ngOnDestroy is intentional - load() is called multiple times + this.sendViewsSubscription?.unsubscribe(); + + this.sendViewsSubscription = this.sendService.sendViews$ .pipe( mergeMap(async (sends) => { this.sends = sends; @@ -143,9 +174,6 @@ export class SendV2Component extends BaseSendComponent implements OnInit, OnDest .subscribe(); if (this.onSuccessfulLoad != null) { await this.onSuccessfulLoad(); - } else { - // Default action - this.selectAll(); } this.loading = false; this.loaded = true; From 6e383ecbc6cfe68f73959d38c49227425b064226 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:23:04 +0100 Subject: [PATCH 19/60] [deps]: Update peter-evans/repository-dispatch action to v4.0.1 (#17891) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/test-browser-interactions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index dfc0f28b9c6..c8f4c959c52 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -75,7 +75,7 @@ jobs: - name: Trigger test-all workflow in browser-interactions-testing if: steps.changed-files.outputs.monitored == 'true' - uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ steps.app-token.outputs.token }} repository: "bitwarden/browser-interactions-testing" From 0301e9d1d7ad1c42127451372c66a99ff7bdff49 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:57:58 -0600 Subject: [PATCH 20/60] [deps]: Update Rust crate tokio-util to v0.7.17 (#17575) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 4 ++-- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index cf946f7f204..1b98e677ac7 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -3329,9 +3329,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.13" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 59df7ba57fb..b492fc62e2a 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -63,7 +63,7 @@ ssh-key = { version = "=0.6.7", default-features = false } sysinfo = "=0.37.2" thiserror = "=2.0.17" tokio = "=1.45.0" -tokio-util = "=0.7.13" +tokio-util = "=0.7.17" tracing = "=0.1.41" tracing-subscriber = { version = "=0.3.20", features = [ "fmt", From 151c2d97f07e47cbce92593c83ae03c6193bd2a9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:13:24 +0000 Subject: [PATCH 21/60] [deps]: Update Rust crate tokio to v1.48.0 (#15700) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 77 +++----------------------- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 70 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 1b98e677ac7..692ebfa29e9 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" - [[package]] name = "aead" version = "0.5.2" @@ -351,21 +336,6 @@ dependencies = [ "windows-core", ] -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -1415,12 +1385,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "glob" version = "0.3.3" @@ -1857,15 +1821,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" -dependencies = [ - "adler2", -] - [[package]] name = "mio" version = "1.0.3" @@ -2177,15 +2132,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -2751,12 +2697,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - [[package]] name = "rustc_version" version = "0.4.1" @@ -3078,12 +3018,12 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "socket2" -version = "0.5.9" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3299,11 +3239,10 @@ dependencies = [ [[package]] name = "tokio" -version = "1.45.0" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", "libc", "mio", @@ -3313,14 +3252,14 @@ dependencies = [ "socket2", "tokio-macros", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index b492fc62e2a..2d5b1e31175 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -62,7 +62,7 @@ ssh-encoding = "=0.2.0" ssh-key = { version = "=0.6.7", default-features = false } sysinfo = "=0.37.2" thiserror = "=2.0.17" -tokio = "=1.45.0" +tokio = "=1.48.0" tokio-util = "=0.7.17" tracing = "=0.1.41" tracing-subscriber = { version = "=0.3.20", features = [ From 892f5548d21f63e64fedac03c94d540c83b9ef6d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:14:18 +0100 Subject: [PATCH 22/60] [deps] Platform: Update Rust crate bytes to v1.11.0 (#17618) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 4 ++-- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 692ebfa29e9..a2b653a929e 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -485,9 +485,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "camino" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 2d5b1e31175..58ce3758ae1 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -27,7 +27,7 @@ ashpd = "=0.11.0" base64 = "=0.22.1" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" } byteorder = "=1.5.0" -bytes = "=1.10.1" +bytes = "=1.11.0" cbc = "=0.1.2" chacha20poly1305 = "=0.10.1" core-foundation = "=0.10.1" From 44384d51c9d2b2017603be94a54329b2df6cd8b3 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Wed, 10 Dec 2025 09:28:36 -0500 Subject: [PATCH 23/60] fix padding when nested. remove ng style and class (#17874) * fix padding when nested. remove ng style and class * add expanded group to story to cover bug fix * use class binding for async classes * remove unnecessary x padding to improve truncation * simplify padding logic * fix padding end in closed state * add back some padding in tree view * add back padding to avoid weird spacing scenarios --- .../src/navigation/nav-group.stories.ts | 5 ++++ .../src/navigation/nav-item.component.html | 27 ++++++++----------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/libs/components/src/navigation/nav-group.stories.ts b/libs/components/src/navigation/nav-group.stories.ts index d5c381ac3e3..e3033e4b40a 100644 --- a/libs/components/src/navigation/nav-group.stories.ts +++ b/libs/components/src/navigation/nav-group.stories.ts @@ -84,6 +84,11 @@ export const Default: StoryObj = { + + + + + `, }), diff --git a/libs/components/src/navigation/nav-item.component.html b/libs/components/src/navigation/nav-item.component.html index 1de8a9bd167..8a59d474d94 100644 --- a/libs/components/src/navigation/nav-item.component.html +++ b/libs/components/src/navigation/nav-item.component.html @@ -2,16 +2,12 @@ @let open = sideNavService.open$ | async; @if (open || icon()) {
@if (open) { @@ -26,13 +22,12 @@
@if (icon()) { From 852248d5fa38b43824011d99072a97179492d884 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 09:43:51 -0500 Subject: [PATCH 24/60] [deps] Platform: Update napi to v3 (major) (#16053) * [deps] Platform: Update napi to v3 * fix: upgrade required dependencies * fix: deprecated syntax in package.json * fix: TS code after napi changes * fix: lint * fix: floating promise * fix: libsqlite musl compilation * feat: remove support for musl * fix: sorting lint * fix: logging not working * fix: pre-emptive fix for passkey autofill * fix: rust lint * fix: package-lock * fix: linux type error * fix: windows type error --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Andreas Coroiu Co-authored-by: Andreas Coroiu --- .github/workflows/build-desktop.yml | 14 +- apps/desktop/desktop_native/Cargo.lock | 71 +- apps/desktop/desktop_native/Cargo.toml | 6 +- apps/desktop/desktop_native/build.js | 4 +- .../chromium_importer/src/metadata.rs | 23 +- apps/desktop/desktop_native/napi/index.d.ts | 431 ++--- apps/desktop/desktop_native/napi/index.js | 12 +- apps/desktop/desktop_native/napi/package.json | 26 +- apps/desktop/desktop_native/napi/src/lib.rs | 136 +- .../autofill/main/main-ssh-agent.service.ts | 2 +- .../desktop/src/main/native-messaging.main.ts | 4 +- .../main/autofill/native-autofill.main.ts | 18 +- package-lock.json | 1479 ++++++++++++++++- 13 files changed, 1833 insertions(+), 393 deletions(-) diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index c99d2183d71..6978edd8b3c 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -209,7 +209,7 @@ jobs: - name: Set up environment run: | sudo apt-get update - sudo apt-get -y install pkg-config libxss-dev rpm musl-dev musl-tools flatpak flatpak-builder + sudo apt-get -y install pkg-config libxss-dev rpm flatpak flatpak-builder - name: Set up Snap run: sudo snap install snapcraft --classic @@ -262,12 +262,10 @@ jobs: env: PKG_CONFIG_ALLOW_CROSS: true PKG_CONFIG_ALL_STATIC: true - TARGET: musl # Note: It is important that we use the release build because some compute heavy - # operations such as key derivation for oo7 on linux are too slow in debug mode + # operations such as key derivation for oo7 on linux are too slow in debug mode run: | - rustup target add x86_64-unknown-linux-musl - node build.js --target=x86_64-unknown-linux-musl --release + node build.js --release - name: Build application run: npm run dist:lin @@ -367,7 +365,7 @@ jobs: - name: Set up environment run: | sudo apt-get update - sudo apt-get -y install pkg-config libxss-dev rpm musl-dev musl-tools flatpak flatpak-builder squashfs-tools ruby ruby-dev rubygems build-essential + sudo apt-get -y install pkg-config libxss-dev rpm flatpak flatpak-builder squashfs-tools ruby ruby-dev rubygems build-essential sudo gem install --no-document fpm - name: Set up Snap @@ -427,12 +425,10 @@ jobs: env: PKG_CONFIG_ALLOW_CROSS: true PKG_CONFIG_ALL_STATIC: true - TARGET: musl # Note: It is important that we use the release build because some compute heavy # operations such as key derivation for oo7 on linux are too slow in debug mode run: | - rustup target add aarch64-unknown-linux-musl - node build.js --target=aarch64-unknown-linux-musl --release + node build.js --release - name: Check index.d.ts generated if: github.event_name == 'pull_request' && steps.cache.outputs.cache-hit != 'true' diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index a2b653a929e..7aeeefb2d0d 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -685,9 +685,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "convert_case" -version = "0.6.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" dependencies = [ "unicode-segmentation", ] @@ -746,16 +746,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ctor" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" -dependencies = [ - "quote", - "syn", -] - [[package]] name = "ctor" version = "0.5.0" @@ -1860,32 +1850,33 @@ dependencies = [ [[package]] name = "napi" -version = "2.16.17" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +checksum = "f1b74e3dce5230795bb4d2821b941706dee733c7308752507254b0497f39cad7" dependencies = [ "bitflags", - "ctor 0.2.9", - "napi-derive", + "ctor", + "napi-build", "napi-sys", - "once_cell", + "nohash-hasher", + "rustc-hash", "tokio", ] [[package]] name = "napi-build" -version = "2.2.0" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03acbfa4f156a32188bfa09b86dc11a431b5725253fc1fc6f6df5bed273382c4" +checksum = "dcae8ad5609d14afb3a3b91dee88c757016261b151e9dcecabf1b2a31a6cab14" [[package]] name = "napi-derive" -version = "2.16.13" +version = "3.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +checksum = "7552d5a579b834614bbd496db5109f1b9f1c758f08224b0dee1e408333adf0d0" dependencies = [ - "cfg-if", "convert_case", + "ctor", "napi-derive-backend", "proc-macro2", "quote", @@ -1894,24 +1885,22 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "1.0.75" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +checksum = "5f6a81ac7486b70f2532a289603340862c06eea5a1e650c1ffeda2ce1238516a" dependencies = [ "convert_case", - "once_cell", "proc-macro2", "quote", - "regex", "semver", "syn", ] [[package]] name = "napi-sys" -version = "2.4.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +checksum = "3e4e7135a8f97aa0f1509cce21a8a1f9dcec1b50d8dee006b48a5adb69a9d64d" dependencies = [ "libloading", ] @@ -1929,6 +1918,12 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -2498,7 +2493,7 @@ dependencies = [ name = "process_isolation" version = "0.0.0" dependencies = [ - "ctor 0.5.0", + "ctor", "desktop_core", "libc", "tracing", @@ -2613,18 +2608,6 @@ dependencies = [ "thiserror 2.0.17", ] -[[package]] -name = "regex" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - [[package]] name = "regex-automata" version = "0.4.9" @@ -2697,6 +2680,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 58ce3758ae1..2eff1af41b5 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -42,9 +42,9 @@ interprocess = "=2.2.1" libc = "=0.2.178" linux-keyutils = "=0.2.4" memsec = "=0.7.0" -napi = "=2.16.17" -napi-build = "=2.2.0" -napi-derive = "=2.16.13" +napi = "=3.3.0" +napi-build = "=2.2.3" +napi-derive = "=3.2.5" oo7 = "=0.4.3" pin-project = "=1.1.10" pkcs8 = "=0.10.2" diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index a7ed89a9c17..e267e28a08c 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -11,8 +11,8 @@ const rustTargetsMap = { "aarch64-pc-windows-msvc": { nodeArch: 'arm64', platform: 'win32' }, "x86_64-apple-darwin": { nodeArch: 'x64', platform: 'darwin' }, "aarch64-apple-darwin": { nodeArch: 'arm64', platform: 'darwin' }, - 'x86_64-unknown-linux-musl': { nodeArch: 'x64', platform: 'linux' }, - 'aarch64-unknown-linux-musl': { nodeArch: 'arm64', platform: 'linux' }, + 'x86_64-unknown-linux-gnu': { nodeArch: 'x64', platform: 'linux' }, + 'aarch64-unknown-linux-gnu': { nodeArch: 'arm64', platform: 'linux' }, } // Ensure the dist directory exists diff --git a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs index 51a181f7f49..9aa2cea6e5e 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs @@ -7,9 +7,9 @@ pub struct NativeImporterMetadata { /// Identifies the importer pub id: String, /// Describes the strategies used to obtain imported data - pub loaders: Vec<&'static str>, + pub loaders: Vec, /// Identifies the instructions for the importer - pub instructions: &'static str, + pub instructions: String, } /// Returns a map of supported importers based on the current platform. @@ -36,9 +36,9 @@ pub fn get_supported_importers( PLATFORM_SUPPORTED_BROWSERS.iter().map(|b| b.name).collect(); for (id, browser_name) in IMPORTERS { - let mut loaders: Vec<&'static str> = vec!["file"]; + let mut loaders: Vec = vec!["file".to_string()]; if supported.contains(browser_name) { - loaders.push("chromium"); + loaders.push("chromium".to_string()); } if installed_browsers.contains(&browser_name.to_string()) { @@ -47,7 +47,7 @@ pub fn get_supported_importers( NativeImporterMetadata { id: id.to_string(), loaders, - instructions: "chromium", + instructions: "chromium".to_string(), }, ); } @@ -79,12 +79,9 @@ mod tests { map.keys().cloned().collect() } - fn get_loaders( - map: &HashMap, - id: &str, - ) -> HashSet<&'static str> { + fn get_loaders(map: &HashMap, id: &str) -> HashSet { map.get(id) - .map(|m| m.loaders.iter().copied().collect::>()) + .map(|m| m.loaders.iter().cloned().collect::>()) .unwrap_or_default() } @@ -107,7 +104,7 @@ mod tests { for (key, meta) in map.iter() { assert_eq!(&meta.id, key); assert_eq!(meta.instructions, "chromium"); - assert!(meta.loaders.contains(&"file")); + assert!(meta.loaders.contains(&"file".to_owned())); } } @@ -147,7 +144,7 @@ mod tests { for (key, meta) in map.iter() { assert_eq!(&meta.id, key); assert_eq!(meta.instructions, "chromium"); - assert!(meta.loaders.contains(&"file")); + assert!(meta.loaders.contains(&"file".to_owned())); } } @@ -183,7 +180,7 @@ mod tests { for (key, meta) in map.iter() { assert_eq!(&meta.id, key); assert_eq!(meta.instructions, "chromium"); - assert!(meta.loaders.contains(&"file")); + assert!(meta.loaders.contains(&"file".to_owned())); } } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 0db29c9a05d..375c65edb8d 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -1,125 +1,7 @@ -/* tslint:disable */ -/* eslint-disable */ - /* auto-generated by NAPI-RS */ - -export declare namespace passwords { - /** The error message returned when a password is not found during retrieval or deletion. */ - export const PASSWORD_NOT_FOUND: string - /** - * Fetch the stored password from the keychain. - * Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. - */ - export function getPassword(service: string, account: string): Promise - /** - * Save the password to the keychain. Adds an entry if none exists otherwise updates the - * existing entry. - */ - export function setPassword(service: string, account: string, password: string): Promise - /** - * Delete the stored password from the keychain. - * Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. - */ - export function deletePassword(service: string, account: string): Promise - /** Checks if the os secure storage is available */ - export function isAvailable(): Promise -} -export declare namespace biometrics { - export function prompt(hwnd: Buffer, message: string): Promise - export function available(): Promise - export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise - /** - * Retrieves the biometric secret for the given service and account. - * Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist. - */ - export function getBiometricSecret(service: string, account: string, keyMaterial?: KeyMaterial | undefined | null): Promise - /** - * Derives key material from biometric data. Returns a string encoded with a - * base64 encoded key and the base64 encoded challenge used to create it - * separated by a `|` character. - * - * If the iv is provided, it will be used as the challenge. Otherwise a random challenge will - * be generated. - * - * `format!("|")` - */ - export function deriveKeyMaterial(iv?: string | undefined | null): Promise - export interface KeyMaterial { - osKeyPartB64: string - clientKeyPartB64?: string - } - export interface OsDerivedKey { - keyB64: string - ivB64: string - } -} -export declare namespace biometrics_v2 { - export function initBiometricSystem(): BiometricLockSystem - export function authenticate(biometricLockSystem: BiometricLockSystem, hwnd: Buffer, message: string): Promise - export function authenticateAvailable(biometricLockSystem: BiometricLockSystem): Promise - export function enrollPersistent(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise - export function provideKey(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise - export function unlock(biometricLockSystem: BiometricLockSystem, userId: string, hwnd: Buffer): Promise - export function unlockAvailable(biometricLockSystem: BiometricLockSystem, userId: string): Promise - export function hasPersistent(biometricLockSystem: BiometricLockSystem, userId: string): Promise - export function unenroll(biometricLockSystem: BiometricLockSystem, userId: string): Promise - export class BiometricLockSystem { } -} -export declare namespace clipboards { - export function read(): Promise - export function write(text: string, password: boolean): Promise -} -export declare namespace sshagent { - export interface PrivateKey { - privateKey: string - name: string - cipherId: string - } - export interface SshKey { - privateKey: string - publicKey: string - keyFingerprint: string - } - export interface SshUiRequest { - cipherId?: string - isList: boolean - processName: string - isForwarding: boolean - namespace?: string - } - export function serve(callback: (err: Error | null, arg: SshUiRequest) => any): Promise - export function stop(agentState: SshAgentState): void - export function isRunning(agentState: SshAgentState): boolean - export function setKeys(agentState: SshAgentState, newKeys: Array): void - export function lock(agentState: SshAgentState): void - export function clearKeys(agentState: SshAgentState): void - export class SshAgentState { } -} -export declare namespace processisolations { - export function disableCoredumps(): Promise - export function isCoreDumpingDisabled(): Promise - export function isolateProcess(): Promise -} -export declare namespace powermonitors { - export function onLock(callback: (err: Error | null, ) => any): Promise - export function isLockMonitorAvailable(): Promise -} -export declare namespace windows_registry { - export function createKey(key: string, subkey: string, value: string): Promise - export function deleteKey(key: string, subkey: string): Promise -} -export declare namespace ipc { - export interface IpcMessage { - clientId: number - kind: IpcMessageType - message?: string - } - export const enum IpcMessageType { - Connected = 0, - Disconnected = 1, - Message = 2 - } - export class IpcServer { +/* eslint-disable */ +export declare namespace autofill { + export class AutofillIpcServer { /** * Create and start the IPC server without blocking. * @@ -127,34 +9,43 @@ export declare namespace ipc { * connection and must be the same for both the server and client. @param callback * This function will be called whenever a message is received from a client. */ - static listen(name: string, callback: (error: null | Error, message: IpcMessage) => void): Promise + static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void, nativeStatusCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void): Promise /** Return the path to the IPC server. */ getPath(): string /** Stop the IPC server. */ stop(): void - /** - * Send a message over the IPC server to all the connected clients - * - * @return The number of clients that the message was sent to. Note that the number of - * messages actually received may be less, as some clients could disconnect before - * receiving the message. - */ - send(message: string): number + completeRegistration(clientId: number, sequenceNumber: number, response: PasskeyRegistrationResponse): number + completeAssertion(clientId: number, sequenceNumber: number, response: PasskeyAssertionResponse): number + completeError(clientId: number, sequenceNumber: number, error: string): number } -} -export declare namespace autostart { - export function setAutostart(autostart: boolean, params: Array): Promise -} -export declare namespace autofill { - export function runCommand(value: string): Promise - export const enum UserVerification { - Preferred = 'preferred', - Required = 'required', - Discouraged = 'discouraged' + export interface NativeStatus { + key: string + value: string } - export interface Position { - x: number - y: number + export interface PasskeyAssertionRequest { + rpId: string + clientDataHash: Array + userVerification: UserVerification + allowedCredentials: Array> + windowXy: Position + } + export interface PasskeyAssertionResponse { + rpId: string + userHandle: Array + signature: Array + clientDataHash: Array + authenticatorData: Array + credentialId: Array + } + export interface PasskeyAssertionWithoutUserInterfaceRequest { + rpId: string + credentialId: Array + userName: string + userHandle: Array + recordIdentifier?: string + clientDataHash: Array + userVerification: UserVerification + windowXy: Position } export interface PasskeyRegistrationRequest { rpId: string @@ -172,71 +63,77 @@ export declare namespace autofill { credentialId: Array attestationObject: Array } - export interface PasskeyAssertionRequest { - rpId: string - clientDataHash: Array - userVerification: UserVerification - allowedCredentials: Array> - windowXy: Position + export interface Position { + x: number + y: number } - export interface PasskeyAssertionWithoutUserInterfaceRequest { - rpId: string - credentialId: Array - userName: string - userHandle: Array - recordIdentifier?: string - clientDataHash: Array - userVerification: UserVerification - windowXy: Position - } - export interface NativeStatus { - key: string - value: string - } - export interface PasskeyAssertionResponse { - rpId: string - userHandle: Array - signature: Array - clientDataHash: Array - authenticatorData: Array - credentialId: Array - } - export class IpcServer { - /** - * Create and start the IPC server without blocking. - * - * @param name The endpoint name to listen on. This name uniquely identifies the IPC - * connection and must be the same for both the server and client. @param callback - * This function will be called whenever a message is received from a client. - */ - static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void, nativeStatusCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void): Promise - /** Return the path to the IPC server. */ - getPath(): string - /** Stop the IPC server. */ - stop(): void - completeRegistration(clientId: number, sequenceNumber: number, response: PasskeyRegistrationResponse): number - completeAssertion(clientId: number, sequenceNumber: number, response: PasskeyAssertionResponse): number - completeError(clientId: number, sequenceNumber: number, error: string): number + export function runCommand(value: string): Promise + export const enum UserVerification { + Preferred = 'preferred', + Required = 'required', + Discouraged = 'discouraged' } } -export declare namespace passkey_authenticator { - export function register(): void + +export declare namespace autostart { + export function setAutostart(autostart: boolean, params: Array): Promise } -export declare namespace logging { - export const enum LogLevel { - Trace = 0, - Debug = 1, - Info = 2, - Warn = 3, - Error = 4 + +export declare namespace autotype { + export function getForegroundWindowTitle(): string + export function typeInput(input: Array, keyboardShortcut: Array): void +} + +export declare namespace biometrics { + export function available(): Promise + /** + * Derives key material from biometric data. Returns a string encoded with a + * base64 encoded key and the base64 encoded challenge used to create it + * separated by a `|` character. + * + * If the iv is provided, it will be used as the challenge. Otherwise a random challenge will + * be generated. + * + * `format!("|")` + */ + export function deriveKeyMaterial(iv?: string | undefined | null): Promise + /** + * Retrieves the biometric secret for the given service and account. + * Throws Error with message [`passwords::PASSWORD_NOT_FOUND`] if the secret does not exist. + */ + export function getBiometricSecret(service: string, account: string, keyMaterial?: KeyMaterial | undefined | null): Promise + export interface KeyMaterial { + osKeyPartB64: string + clientKeyPartB64?: string } - export function initNapiLog(jsLogFn: (err: Error | null, arg0: LogLevel, arg1: string) => any): void + export interface OsDerivedKey { + keyB64: string + ivB64: string + } + export function prompt(hwnd: Buffer, message: string): Promise + export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise } + +export declare namespace biometrics_v2 { + export class BiometricLockSystem { + + } + export function authenticate(biometricLockSystem: BiometricLockSystem, hwnd: Buffer, message: string): Promise + export function authenticateAvailable(biometricLockSystem: BiometricLockSystem): Promise + export function enrollPersistent(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise + export function hasPersistent(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export function initBiometricSystem(): BiometricLockSystem + export function provideKey(biometricLockSystem: BiometricLockSystem, userId: string, key: Buffer): Promise + export function unenroll(biometricLockSystem: BiometricLockSystem, userId: string): Promise + export function unlock(biometricLockSystem: BiometricLockSystem, userId: string, hwnd: Buffer): Promise + export function unlockAvailable(biometricLockSystem: BiometricLockSystem, userId: string): Promise +} + export declare namespace chromium_importer { - export interface ProfileInfo { - id: string - name: string - } + export function getAvailableProfiles(browser: string): Array + /** Returns OS aware metadata describing supported Chromium based importers as a JSON string. */ + export function getMetadata(): Record + export function importLogins(browser: string, profileId: string): Promise> export interface Login { url: string username: string @@ -257,12 +154,130 @@ export declare namespace chromium_importer { loaders: Array instructions: string } - /** Returns OS aware metadata describing supported Chromium based importers as a JSON string. */ - export function getMetadata(): Record - export function getAvailableProfiles(browser: string): Array - export function importLogins(browser: string, profileId: string): Promise> + export interface ProfileInfo { + id: string + name: string + } } -export declare namespace autotype { - export function getForegroundWindowTitle(): string - export function typeInput(input: Array, keyboardShortcut: Array): void + +export declare namespace clipboards { + export function read(): Promise + export function write(text: string, password: boolean): Promise +} + +export declare namespace ipc { + export class NativeIpcServer { + /** + * Create and start the IPC server without blocking. + * + * @param name The endpoint name to listen on. This name uniquely identifies the IPC + * connection and must be the same for both the server and client. @param callback + * This function will be called whenever a message is received from a client. + */ + static listen(name: string, callback: (error: null | Error, message: IpcMessage) => void): Promise + /** Return the path to the IPC server. */ + getPath(): string + /** Stop the IPC server. */ + stop(): void + /** + * Send a message over the IPC server to all the connected clients + * + * @return The number of clients that the message was sent to. Note that the number of + * messages actually received may be less, as some clients could disconnect before + * receiving the message. + */ + send(message: string): number + } + export interface IpcMessage { + clientId: number + kind: IpcMessageType + message?: string + } + export const enum IpcMessageType { + Connected = 0, + Disconnected = 1, + Message = 2 + } +} + +export declare namespace logging { + export function initNapiLog(jsLogFn: ((err: Error | null, arg0: LogLevel, arg1: string) => any)): void + export const enum LogLevel { + Trace = 0, + Debug = 1, + Info = 2, + Warn = 3, + Error = 4 + } +} + +export declare namespace passkey_authenticator { + export function register(): void +} + +export declare namespace passwords { + /** + * Delete the stored password from the keychain. + * Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. + */ + export function deletePassword(service: string, account: string): Promise + /** + * Fetch the stored password from the keychain. + * Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. + */ + export function getPassword(service: string, account: string): Promise + /** Checks if the os secure storage is available */ + export function isAvailable(): Promise + /** The error message returned when a password is not found during retrieval or deletion. */ + export const PASSWORD_NOT_FOUND: string + /** + * Save the password to the keychain. Adds an entry if none exists otherwise updates the + * existing entry. + */ + export function setPassword(service: string, account: string, password: string): Promise +} + +export declare namespace powermonitors { + export function isLockMonitorAvailable(): Promise + export function onLock(callback: ((err: Error | null, ) => any)): Promise +} + +export declare namespace processisolations { + export function disableCoredumps(): Promise + export function isCoreDumpingDisabled(): Promise + export function isolateProcess(): Promise +} + +export declare namespace sshagent { + export class SshAgentState { + + } + export function clearKeys(agentState: SshAgentState): void + export function isRunning(agentState: SshAgentState): boolean + export function lock(agentState: SshAgentState): void + export interface PrivateKey { + privateKey: string + name: string + cipherId: string + } + export function serve(callback: ((err: Error | null, arg: SshUiRequest) => Promise)): Promise + export function setKeys(agentState: SshAgentState, newKeys: Array): void + export interface SshKey { + privateKey: string + publicKey: string + keyFingerprint: string + } + export interface SshUiRequest { + cipherId?: string + isList: boolean + processName: string + isForwarding: boolean + namespace?: string + } + export function stop(agentState: SshAgentState): void +} + +export declare namespace windows_registry { + export function createKey(key: string, subkey: string, value: string): Promise + export function deleteKey(key: string, subkey: string): Promise } diff --git a/apps/desktop/desktop_native/napi/index.js b/apps/desktop/desktop_native/napi/index.js index 64819be4405..0362d9ee2bb 100644 --- a/apps/desktop/desktop_native/napi/index.js +++ b/apps/desktop/desktop_native/napi/index.js @@ -82,20 +82,20 @@ switch (platform) { switch (arch) { case "x64": nativeBinding = loadFirstAvailable( - ["desktop_napi.linux-x64-musl.node", "desktop_napi.linux-x64-gnu.node"], - "@bitwarden/desktop-napi-linux-x64-musl", + ["desktop_napi.linux-x64-gnu.node"], + "@bitwarden/desktop-napi-linux-x64-gnu", ); break; case "arm64": nativeBinding = loadFirstAvailable( - ["desktop_napi.linux-arm64-musl.node", "desktop_napi.linux-arm64-gnu.node"], - "@bitwarden/desktop-napi-linux-arm64-musl", + ["desktop_napi.linux-arm64-gnu.node"], + "@bitwarden/desktop-napi-linux-arm64-gnu", ); break; case "arm": nativeBinding = loadFirstAvailable( - ["desktop_napi.linux-arm-musl.node", "desktop_napi.linux-arm-gnu.node"], - "@bitwarden/desktop-napi-linux-arm-musl", + ["desktop_napi.linux-arm-gnu.node"], + "@bitwarden/desktop-napi-linux-arm-gnu", ); localFileExisted = existsSync(join(__dirname, "desktop_napi.linux-arm-gnueabihf.node")); try { diff --git a/apps/desktop/desktop_native/napi/package.json b/apps/desktop/desktop_native/napi/package.json index d557ccfd259..5401207c252 100644 --- a/apps/desktop/desktop_native/napi/package.json +++ b/apps/desktop/desktop_native/napi/package.json @@ -3,27 +3,23 @@ "version": "0.1.0", "description": "", "scripts": { - "build": "napi build --platform --js false", + "build": "napi build --platform --no-js", "test": "cargo test" }, "author": "", "license": "GPL-3.0", "devDependencies": { - "@napi-rs/cli": "2.18.4" + "@napi-rs/cli": "3.2.0" }, "napi": { - "name": "desktop_napi", - "triples": { - "defaults": true, - "additional": [ - "x86_64-unknown-linux-musl", - "aarch64-unknown-linux-gnu", - "i686-pc-windows-msvc", - "armv7-unknown-linux-gnueabihf", - "aarch64-apple-darwin", - "aarch64-unknown-linux-musl", - "aarch64-pc-windows-msvc" - ] - } + "binaryName": "desktop_napi", + "targets": [ + "aarch64-apple-darwin", + "aarch64-pc-windows-msvc", + "aarch64-unknown-linux-gnu", + "armv7-unknown-linux-gnueabihf", + "i686-pc-windows-msvc", + "x86_64-unknown-linux-gnu" + ] } } diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 7f63001c221..25dfdd08336 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -290,7 +290,7 @@ pub mod sshagent { use napi::{ bindgen_prelude::Promise, - threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction}, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, }; use tokio::{self, sync::Mutex}; use tracing::error; @@ -326,13 +326,15 @@ pub mod sshagent { #[allow(clippy::unused_async)] // FIXME: Remove unused async! #[napi] pub async fn serve( - callback: ThreadsafeFunction, + callback: ThreadsafeFunction>, ) -> napi::Result { let (auth_request_tx, mut auth_request_rx) = tokio::sync::mpsc::channel::(32); let (auth_response_tx, auth_response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(32); let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx)); + // Wrap callback in Arc so it can be shared across spawned tasks + let callback = Arc::new(callback); tokio::spawn(async move { let _ = auth_response_rx; @@ -342,42 +344,50 @@ pub mod sshagent { tokio::spawn(async move { let auth_response_tx_arc = cloned_response_tx_arc; let callback = cloned_callback; - let promise_result: Result, napi::Error> = callback - .call_async(Ok(SshUIRequest { + // In NAPI v3, obtain the JS callback return as a Promise and await it + // in Rust + let (tx, rx) = std::sync::mpsc::channel::>(); + let status = callback.call_with_return_value( + Ok(SshUIRequest { cipher_id: request.cipher_id, is_list: request.is_list, process_name: request.process_name, is_forwarding: request.is_forwarding, namespace: request.namespace, - })) - .await; - match promise_result { - Ok(promise_result) => match promise_result.await { - Ok(result) => { - let _ = auth_response_tx_arc - .lock() - .await - .send((request.request_id, result)) - .expect("should be able to send auth response to agent"); - } - Err(e) => { - error!(error = %e, "Calling UI callback promise was rejected"); - let _ = auth_response_tx_arc - .lock() - .await - .send((request.request_id, false)) - .expect("should be able to send auth response to agent"); + }), + ThreadsafeFunctionCallMode::Blocking, + move |ret: Result, napi::Error>, _env| { + if let Ok(p) = ret { + let _ = tx.send(p); } + Ok(()) }, - Err(e) => { - error!(error = %e, "Calling UI callback could not create promise"); - let _ = auth_response_tx_arc - .lock() - .await - .send((request.request_id, false)) - .expect("should be able to send auth response to agent"); + ); + + let result = if status == napi::Status::Ok { + match rx.recv() { + Ok(promise) => match promise.await { + Ok(v) => v, + Err(e) => { + error!(error = %e, "UI callback promise rejected"); + false + } + }, + Err(e) => { + error!(error = %e, "Failed to receive UI callback promise"); + false + } } - } + } else { + error!(error = ?status, "Calling UI callback failed"); + false + }; + + let _ = auth_response_tx_arc + .lock() + .await + .send((request.request_id, result)) + .expect("should be able to send auth response to agent"); }); } }); @@ -465,14 +475,12 @@ pub mod processisolations { #[napi] pub mod powermonitors { use napi::{ - threadsafe_function::{ - ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode, - }, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio, }; #[napi] - pub async fn on_lock(callback: ThreadsafeFunction<(), CalleeHandled>) -> napi::Result<()> { + pub async fn on_lock(callback: ThreadsafeFunction<()>) -> napi::Result<()> { let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32); desktop_core::powermonitor::on_lock(tx) .await @@ -511,9 +519,7 @@ pub mod windows_registry { #[napi] pub mod ipc { use desktop_core::ipc::server::{Message, MessageType}; - use napi::threadsafe_function::{ - ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode, - }; + use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; #[napi(object)] pub struct IpcMessage { @@ -550,12 +556,12 @@ pub mod ipc { } #[napi] - pub struct IpcServer { + pub struct NativeIpcServer { server: desktop_core::ipc::server::Server, } #[napi] - impl IpcServer { + impl NativeIpcServer { /// Create and start the IPC server without blocking. /// /// @param name The endpoint name to listen on. This name uniquely identifies the IPC @@ -566,7 +572,7 @@ pub mod ipc { pub async fn listen( name: String, #[napi(ts_arg_type = "(error: null | Error, message: IpcMessage) => void")] - callback: ThreadsafeFunction, + callback: ThreadsafeFunction, ) -> napi::Result { let (send, mut recv) = tokio::sync::mpsc::channel::(32); tokio::spawn(async move { @@ -583,7 +589,7 @@ pub mod ipc { )) })?; - Ok(IpcServer { server }) + Ok(NativeIpcServer { server }) } /// Return the path to the IPC server. @@ -630,8 +636,9 @@ pub mod autostart { #[napi] pub mod autofill { use desktop_core::ipc::server::{Message, MessageType}; - use napi::threadsafe_function::{ - ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode, + use napi::{ + bindgen_prelude::FnArgs, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tracing::error; @@ -746,14 +753,14 @@ pub mod autofill { } #[napi] - pub struct IpcServer { + pub struct AutofillIpcServer { server: desktop_core::ipc::server::Server, } // FIXME: Remove unwraps! They panic and terminate the whole application. #[allow(clippy::unwrap_used)] #[napi] - impl IpcServer { + impl AutofillIpcServer { /// Create and start the IPC server without blocking. /// /// @param name The endpoint name to listen on. This name uniquely identifies the IPC @@ -769,30 +776,24 @@ pub mod autofill { ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void" )] registration_callback: ThreadsafeFunction< - (u32, u32, PasskeyRegistrationRequest), - ErrorStrategy::CalleeHandled, + FnArgs<(u32, u32, PasskeyRegistrationRequest)>, >, #[napi( ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void" )] assertion_callback: ThreadsafeFunction< - (u32, u32, PasskeyAssertionRequest), - ErrorStrategy::CalleeHandled, + FnArgs<(u32, u32, PasskeyAssertionRequest)>, >, #[napi( ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void" )] assertion_without_user_interface_callback: ThreadsafeFunction< - (u32, u32, PasskeyAssertionWithoutUserInterfaceRequest), - ErrorStrategy::CalleeHandled, + FnArgs<(u32, u32, PasskeyAssertionWithoutUserInterfaceRequest)>, >, #[napi( ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void" )] - native_status_callback: ThreadsafeFunction< - (u32, u32, NativeStatus), - ErrorStrategy::CalleeHandled, - >, + native_status_callback: ThreadsafeFunction<(u32, u32, NativeStatus)>, ) -> napi::Result { let (send, mut recv) = tokio::sync::mpsc::channel::(32); tokio::spawn(async move { @@ -817,7 +818,7 @@ pub mod autofill { Ok(msg) => { let value = msg .value - .map(|value| (client_id, msg.sequence_number, value)) + .map(|value| (client_id, msg.sequence_number, value).into()) .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); assertion_callback @@ -836,7 +837,7 @@ pub mod autofill { Ok(msg) => { let value = msg .value - .map(|value| (client_id, msg.sequence_number, value)) + .map(|value| (client_id, msg.sequence_number, value).into()) .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); assertion_without_user_interface_callback @@ -854,7 +855,7 @@ pub mod autofill { Ok(msg) => { let value = msg .value - .map(|value| (client_id, msg.sequence_number, value)) + .map(|value| (client_id, msg.sequence_number, value).into()) .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); registration_callback .call(value, ThreadsafeFunctionCallMode::NonBlocking); @@ -894,7 +895,7 @@ pub mod autofill { )) })?; - Ok(IpcServer { server }) + Ok(AutofillIpcServer { server }) } /// Return the path to the IPC server. @@ -987,8 +988,9 @@ pub mod logging { use std::{fmt::Write, sync::OnceLock}; - use napi::threadsafe_function::{ - ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode, + use napi::{ + bindgen_prelude::FnArgs, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, }; use tracing::Level; use tracing_subscriber::{ @@ -999,7 +1001,7 @@ pub mod logging { Layer, }; - struct JsLogger(OnceLock>); + struct JsLogger(OnceLock>>); static JS_LOGGER: JsLogger = JsLogger(OnceLock::new()); #[napi] @@ -1071,13 +1073,13 @@ pub mod logging { let msg = (event.metadata().level().into(), buffer); if let Some(logger) = JS_LOGGER.0.get() { - let _ = logger.call(Ok(msg), ThreadsafeFunctionCallMode::NonBlocking); + let _ = logger.call(Ok(msg.into()), ThreadsafeFunctionCallMode::NonBlocking); }; } } #[napi] - pub fn init_napi_log(js_log_fn: ThreadsafeFunction<(LogLevel, String), CalleeHandled>) { + pub fn init_napi_log(js_log_fn: ThreadsafeFunction>) { let _ = JS_LOGGER.0.set(js_log_fn); let filter = EnvFilter::builder() @@ -1140,8 +1142,8 @@ pub mod chromium_importer { #[napi(object)] pub struct NativeImporterMetadata { pub id: String, - pub loaders: Vec<&'static str>, - pub instructions: &'static str, + pub loaders: Vec, + pub instructions: String, } impl From<_LoginImportResult> for LoginImportResult { @@ -1218,7 +1220,7 @@ pub mod chromium_importer { #[napi] pub mod autotype { #[napi] - pub fn get_foreground_window_title() -> napi::Result { + pub fn get_foreground_window_title() -> napi::Result { autotype::get_foreground_window_title().map_err(|_| { napi::Error::from_reason( "Autotype Error: failed to get foreground window title".to_string(), diff --git a/apps/desktop/src/autofill/main/main-ssh-agent.service.ts b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts index 595ef778bcf..31196e4cf98 100644 --- a/apps/desktop/src/autofill/main/main-ssh-agent.service.ts +++ b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts @@ -37,7 +37,7 @@ export class MainSshAgentService { init() { // handle sign request passing to UI sshagent - .serve(async (err: Error, sshUiRequest: sshagent.SshUiRequest) => { + .serve(async (err: Error | null, sshUiRequest: sshagent.SshUiRequest): Promise => { // clear all old (> SIGN_TIMEOUT) requests this.requestResponses = this.requestResponses.filter( (response) => response.timestamp > new Date(Date.now() - this.SIGN_TIMEOUT), diff --git a/apps/desktop/src/main/native-messaging.main.ts b/apps/desktop/src/main/native-messaging.main.ts index 23d2e038635..a0c17a115e0 100644 --- a/apps/desktop/src/main/native-messaging.main.ts +++ b/apps/desktop/src/main/native-messaging.main.ts @@ -14,7 +14,7 @@ import { isDev } from "../utils"; import { WindowMain } from "./window.main"; export class NativeMessagingMain { - private ipcServer: ipc.IpcServer | null; + private ipcServer: ipc.NativeIpcServer | null; private connected: number[] = []; constructor( @@ -78,7 +78,7 @@ export class NativeMessagingMain { this.ipcServer.stop(); } - this.ipcServer = await ipc.IpcServer.listen("bw", (error, msg) => { + this.ipcServer = await ipc.NativeIpcServer.listen("bw", (error, msg) => { switch (msg.kind) { case ipc.IpcMessageType.Connected: { this.connected.push(msg.clientId); diff --git a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts index 7ecd7c2e9e5..c0d860d74db 100644 --- a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts +++ b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts @@ -21,7 +21,7 @@ export type RunCommandParams = { export type RunCommandResult = C["output"]; export class NativeAutofillMain { - private ipcServer: autofill.IpcServer | null; + private ipcServer?: autofill.AutofillIpcServer; private messageBuffer: BufferedMessage[] = []; private listenerReady = false; @@ -70,13 +70,13 @@ export class NativeAutofillMain { }, ); - this.ipcServer = await autofill.IpcServer.listen( + this.ipcServer = await autofill.AutofillIpcServer.listen( "af", // RegistrationCallback (error, clientId, sequenceNumber, request) => { if (error) { this.logService.error("autofill.IpcServer.registration", error); - this.ipcServer.completeError(clientId, sequenceNumber, String(error)); + this.ipcServer?.completeError(clientId, sequenceNumber, String(error)); return; } this.safeSend("autofill.passkeyRegistration", { @@ -89,7 +89,7 @@ export class NativeAutofillMain { (error, clientId, sequenceNumber, request) => { if (error) { this.logService.error("autofill.IpcServer.assertion", error); - this.ipcServer.completeError(clientId, sequenceNumber, String(error)); + this.ipcServer?.completeError(clientId, sequenceNumber, String(error)); return; } this.safeSend("autofill.passkeyAssertion", { @@ -102,7 +102,7 @@ export class NativeAutofillMain { (error, clientId, sequenceNumber, request) => { if (error) { this.logService.error("autofill.IpcServer.assertion", error); - this.ipcServer.completeError(clientId, sequenceNumber, String(error)); + this.ipcServer?.completeError(clientId, sequenceNumber, String(error)); return; } this.safeSend("autofill.passkeyAssertionWithoutUserInterface", { @@ -115,7 +115,7 @@ export class NativeAutofillMain { (error, clientId, sequenceNumber, status) => { if (error) { this.logService.error("autofill.IpcServer.nativeStatus", error); - this.ipcServer.completeError(clientId, sequenceNumber, String(error)); + this.ipcServer?.completeError(clientId, sequenceNumber, String(error)); return; } this.safeSend("autofill.nativeStatus", { @@ -137,19 +137,19 @@ export class NativeAutofillMain { ipcMain.on("autofill.completePasskeyRegistration", (event, data) => { this.logService.debug("autofill.completePasskeyRegistration", data); const { clientId, sequenceNumber, response } = data; - this.ipcServer.completeRegistration(clientId, sequenceNumber, response); + this.ipcServer?.completeRegistration(clientId, sequenceNumber, response); }); ipcMain.on("autofill.completePasskeyAssertion", (event, data) => { this.logService.debug("autofill.completePasskeyAssertion", data); const { clientId, sequenceNumber, response } = data; - this.ipcServer.completeAssertion(clientId, sequenceNumber, response); + this.ipcServer?.completeAssertion(clientId, sequenceNumber, response); }); ipcMain.on("autofill.completeError", (event, data) => { this.logService.debug("autofill.completeError", data); const { clientId, sequenceNumber, error } = data; - this.ipcServer.completeError(clientId, sequenceNumber, String(error)); + this.ipcServer?.completeError(clientId, sequenceNumber, String(error)); }); } diff --git a/package-lock.json b/package-lock.json index 5321edccd18..82b7e805a70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -287,7 +287,206 @@ "version": "0.1.0", "license": "GPL-3.0", "devDependencies": { - "@napi-rs/cli": "2.18.4" + "@napi-rs/cli": "3.2.0" + } + }, + "apps/desktop/node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "apps/desktop/node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "apps/desktop/node_modules/@napi-rs/cli": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-3.2.0.tgz", + "integrity": "sha512-heyXt/9OBPv/WrTFW2+PxIMzH6MCeqP9ZsvOg0LN6pLngBnszcxFsdhCAh5I6sddzQsvru53zj59GUzvmpWk8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^7.8.4", + "@napi-rs/cross-toolchain": "^1.0.3", + "@napi-rs/wasm-tools": "^1.0.1", + "@octokit/rest": "^22.0.0", + "clipanion": "^4.0.0-rc.4", + "colorette": "^2.0.20", + "debug": "^4.4.1", + "emnapi": "^1.5.0", + "es-toolkit": "^1.39.10", + "find-up": "^7.0.0", + "js-yaml": "^4.1.0", + "semver": "^7.7.2", + "typanion": "^3.14.0" + }, + "bin": { + "napi": "dist/cli.js", + "napi-raw": "cli.mjs" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/runtime": "^1.1.0", + "emnapi": "^1.1.0" + }, + "peerDependenciesMeta": { + "@emnapi/runtime": { + "optional": true + }, + "emnapi": { + "optional": true + } + } + }, + "apps/desktop/node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "apps/desktop/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "apps/desktop/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "apps/desktop/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "apps/desktop/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "apps/desktop/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "apps/desktop/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "apps/web": { @@ -6123,28 +6322,28 @@ } }, "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", "license": "MIT", "dependencies": { - "@emnapi/wasi-threads": "1.0.2", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "license": "MIT", "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "license": "MIT", "dependencies": { "tslib": "^2.4.0" @@ -8792,21 +8991,411 @@ "win32" ] }, - "node_modules/@napi-rs/cli": { - "version": "2.18.4", - "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", - "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "node_modules/@napi-rs/cross-toolchain": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/cross-toolchain/-/cross-toolchain-1.0.3.tgz", + "integrity": "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==", "dev": true, "license": "MIT", - "bin": { - "napi": "scripts/index.js" + "workspaces": [ + ".", + "arm64/*", + "x64/*" + ], + "dependencies": { + "@napi-rs/lzma": "^1.4.5", + "@napi-rs/tar": "^1.1.0", + "debug": "^4.4.1" }, + "peerDependencies": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" + }, + "peerDependenciesMeta": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-x86_64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-x86_64": { + "optional": true + } + } + }, + "node_modules/@napi-rs/lzma": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma/-/lzma-1.4.5.tgz", + "integrity": "sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg==", + "dev": true, + "license": "MIT", "engines": { "node": ">= 10" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/lzma-android-arm-eabi": "1.4.5", + "@napi-rs/lzma-android-arm64": "1.4.5", + "@napi-rs/lzma-darwin-arm64": "1.4.5", + "@napi-rs/lzma-darwin-x64": "1.4.5", + "@napi-rs/lzma-freebsd-x64": "1.4.5", + "@napi-rs/lzma-linux-arm-gnueabihf": "1.4.5", + "@napi-rs/lzma-linux-arm64-gnu": "1.4.5", + "@napi-rs/lzma-linux-arm64-musl": "1.4.5", + "@napi-rs/lzma-linux-ppc64-gnu": "1.4.5", + "@napi-rs/lzma-linux-riscv64-gnu": "1.4.5", + "@napi-rs/lzma-linux-s390x-gnu": "1.4.5", + "@napi-rs/lzma-linux-x64-gnu": "1.4.5", + "@napi-rs/lzma-linux-x64-musl": "1.4.5", + "@napi-rs/lzma-wasm32-wasi": "1.4.5", + "@napi-rs/lzma-win32-arm64-msvc": "1.4.5", + "@napi-rs/lzma-win32-ia32-msvc": "1.4.5", + "@napi-rs/lzma-win32-x64-msvc": "1.4.5" + } + }, + "node_modules/@napi-rs/lzma-android-arm-eabi": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm-eabi/-/lzma-android-arm-eabi-1.4.5.tgz", + "integrity": "sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-android-arm64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm64/-/lzma-android-arm64-1.4.5.tgz", + "integrity": "sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-darwin-arm64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-arm64/-/lzma-darwin-arm64-1.4.5.tgz", + "integrity": "sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-darwin-x64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-x64/-/lzma-darwin-x64-1.4.5.tgz", + "integrity": "sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-freebsd-x64": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-freebsd-x64/-/lzma-freebsd-x64-1.4.5.tgz", + "integrity": "sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm-gnueabihf": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm-gnueabihf/-/lzma-linux-arm-gnueabihf-1.4.5.tgz", + "integrity": "sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-gnu/-/lzma-linux-arm64-gnu-1.4.5.tgz", + "integrity": "sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-musl": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-musl/-/lzma-linux-arm64-musl-1.4.5.tgz", + "integrity": "sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-ppc64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-ppc64-gnu/-/lzma-linux-ppc64-gnu-1.4.5.tgz", + "integrity": "sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-riscv64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-riscv64-gnu/-/lzma-linux-riscv64-gnu-1.4.5.tgz", + "integrity": "sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-s390x-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-s390x-gnu/-/lzma-linux-s390x-gnu-1.4.5.tgz", + "integrity": "sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.4.5.tgz", + "integrity": "sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-musl": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-musl/-/lzma-linux-x64-musl-1.4.5.tgz", + "integrity": "sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-wasm32-wasi/-/lzma-wasm32-wasi-1.4.5.tgz", + "integrity": "sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", + "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/lzma-win32-arm64-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-arm64-msvc/-/lzma-win32-arm64-msvc-1.4.5.tgz", + "integrity": "sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-win32-ia32-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-ia32-msvc/-/lzma-win32-ia32-msvc-1.4.5.tgz", + "integrity": "sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/lzma-win32-x64-msvc": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-x64-msvc/-/lzma-win32-x64-msvc-1.4.5.tgz", + "integrity": "sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" } }, "node_modules/@napi-rs/nice": { @@ -9132,6 +9721,330 @@ "node": ">= 10" } }, + "node_modules/@napi-rs/tar": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar/-/tar-1.1.0.tgz", + "integrity": "sha512-7cmzIu+Vbupriudo7UudoMRH2OA3cTw67vva8MxeoAe5S7vPFI7z0vp0pMXiA25S8IUJefImQ90FeJjl8fjEaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/tar-android-arm-eabi": "1.1.0", + "@napi-rs/tar-android-arm64": "1.1.0", + "@napi-rs/tar-darwin-arm64": "1.1.0", + "@napi-rs/tar-darwin-x64": "1.1.0", + "@napi-rs/tar-freebsd-x64": "1.1.0", + "@napi-rs/tar-linux-arm-gnueabihf": "1.1.0", + "@napi-rs/tar-linux-arm64-gnu": "1.1.0", + "@napi-rs/tar-linux-arm64-musl": "1.1.0", + "@napi-rs/tar-linux-ppc64-gnu": "1.1.0", + "@napi-rs/tar-linux-s390x-gnu": "1.1.0", + "@napi-rs/tar-linux-x64-gnu": "1.1.0", + "@napi-rs/tar-linux-x64-musl": "1.1.0", + "@napi-rs/tar-wasm32-wasi": "1.1.0", + "@napi-rs/tar-win32-arm64-msvc": "1.1.0", + "@napi-rs/tar-win32-ia32-msvc": "1.1.0", + "@napi-rs/tar-win32-x64-msvc": "1.1.0" + } + }, + "node_modules/@napi-rs/tar-android-arm-eabi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm-eabi/-/tar-android-arm-eabi-1.1.0.tgz", + "integrity": "sha512-h2Ryndraj/YiKgMV/r5by1cDusluYIRT0CaE0/PekQ4u+Wpy2iUVqvzVU98ZPnhXaNeYxEvVJHNGafpOfaD0TA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-android-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm64/-/tar-android-arm64-1.1.0.tgz", + "integrity": "sha512-DJFyQHr1ZxNZorm/gzc1qBNLF/FcKzcH0V0Vwan5P+o0aE2keQIGEjJ09FudkF9v6uOuJjHCVDdK6S6uHtShAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-arm64/-/tar-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-Zz2sXRzjIX4e532zD6xm2SjXEym6MkvfCvL2RMpG2+UwNVDVscHNcz3d47Pf3sysP2e2af7fBB3TIoK2f6trPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-x64/-/tar-darwin-x64-1.1.0.tgz", + "integrity": "sha512-EI+CptIMNweT0ms9S3mkP/q+J6FNZ1Q6pvpJOEcWglRfyfQpLqjlC0O+dptruTPE8VamKYuqdjxfqD8hifZDOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-freebsd-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-freebsd-x64/-/tar-freebsd-x64-1.1.0.tgz", + "integrity": "sha512-J0PIqX+pl6lBIAckL/c87gpodLbjZB1OtIK+RDscKC9NLdpVv6VGOxzUV/fYev/hctcE8EfkLbgFOfpmVQPg2g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm-gnueabihf": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm-gnueabihf/-/tar-linux-arm-gnueabihf-1.1.0.tgz", + "integrity": "sha512-SLgIQo3f3EjkZ82ZwvrEgFvMdDAhsxCYjyoSuWfHCz0U16qx3SuGCp8+FYOPYCECHN3ZlGjXnoAIt9ERd0dEUg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-gnu/-/tar-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-d014cdle52EGaH6GpYTQOP9Py7glMO1zz/+ynJPjjzYFSxvdYx0byrjumZk2UQdIyGZiJO2MEFpCkEEKFSgPYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-musl/-/tar-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-L/y1/26q9L/uBqiW/JdOb/Dc94egFvNALUZV2WCGKQXc6UByPBMgdiEyW2dtoYxYYYYc+AKD+jr+wQPcvX2vrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-ppc64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-ppc64-gnu/-/tar-linux-ppc64-gnu-1.1.0.tgz", + "integrity": "sha512-EPE1K/80RQvPbLRJDJs1QmCIcH+7WRi0F73+oTe1582y9RtfGRuzAkzeBuAGRXAQEjRQw/RjtNqr6UTJ+8UuWQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-s390x-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-s390x-gnu/-/tar-linux-s390x-gnu-1.1.0.tgz", + "integrity": "sha512-B2jhWiB1ffw1nQBqLUP1h4+J1ovAxBOoe5N2IqDMOc63fsPZKNqF1PvO/dIem8z7LL4U4bsfmhy3gBfu547oNQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-gnu/-/tar-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-tbZDHnb9617lTnsDMGo/eAMZxnsQFnaRe+MszRqHguKfMwkisc9CCJnks/r1o84u5fECI+J/HOrKXgczq/3Oww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-musl/-/tar-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-dV6cODlzbO8u6Anmv2N/ilQHq/AWz0xyltuXoLU3yUyXbZcnWYZuB2rL8OBGPmqNcD+x9NdScBNXh7vWN0naSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-wasm32-wasi/-/tar-wasm32-wasi-1.1.0.tgz", + "integrity": "sha512-jIa9nb2HzOrfH0F8QQ9g3WE4aMH5vSI5/1NYVNm9ysCmNjCCtMXCAhlI3WKCdm/DwHf0zLqdrrtDFXODcNaqMw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", + "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/tar-win32-arm64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-arm64-msvc/-/tar-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-vfpG71OB0ijtjemp3WTdmBKJm9R70KM8vsSExMsIQtV0lVzP07oM1CW6JbNRPXNLhRoue9ofYLiUDk8bE0Hckg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-ia32-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-ia32-msvc/-/tar-win32-ia32-msvc-1.1.0.tgz", + "integrity": "sha512-hGPyPW60YSpOSgzfy68DLBHgi6HxkAM+L59ZZZPMQ0TOXjQg+p2EW87+TjZfJOkSpbYiEkULwa/f4a2hcVjsqQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-x64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-x64-msvc/-/tar-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-L6Ed1DxXK9YSCMyvpR8MiNAyKNkQLjsHsHK9E0qnHa8NzLFqzDKhvs5LfnWxM2kJ+F7m/e5n9zPm24kHb3LsVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", @@ -9143,6 +10056,276 @@ "@tybys/wasm-util": "^0.9.0" } }, + "node_modules/@napi-rs/wasm-tools": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools/-/wasm-tools-1.0.1.tgz", + "integrity": "sha512-enkZYyuCdo+9jneCPE/0fjIta4wWnvVN9hBo2HuiMpRF0q3lzv1J6b/cl7i0mxZUKhBrV3aCKDBQnCOhwKbPmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/wasm-tools-android-arm-eabi": "1.0.1", + "@napi-rs/wasm-tools-android-arm64": "1.0.1", + "@napi-rs/wasm-tools-darwin-arm64": "1.0.1", + "@napi-rs/wasm-tools-darwin-x64": "1.0.1", + "@napi-rs/wasm-tools-freebsd-x64": "1.0.1", + "@napi-rs/wasm-tools-linux-arm64-gnu": "1.0.1", + "@napi-rs/wasm-tools-linux-arm64-musl": "1.0.1", + "@napi-rs/wasm-tools-linux-x64-gnu": "1.0.1", + "@napi-rs/wasm-tools-linux-x64-musl": "1.0.1", + "@napi-rs/wasm-tools-wasm32-wasi": "1.0.1", + "@napi-rs/wasm-tools-win32-arm64-msvc": "1.0.1", + "@napi-rs/wasm-tools-win32-ia32-msvc": "1.0.1", + "@napi-rs/wasm-tools-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm-eabi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm-eabi/-/wasm-tools-android-arm-eabi-1.0.1.tgz", + "integrity": "sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm64/-/wasm-tools-android-arm64-1.0.1.tgz", + "integrity": "sha512-WDR7S+aRLV6LtBJAg5fmjKkTZIdrEnnQxgdsb7Cf8pYiMWBHLU+LC49OUVppQ2YSPY0+GeYm9yuZWW3kLjJ7Bg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-arm64/-/wasm-tools-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-qWTI+EEkiN0oIn/N2gQo7+TVYil+AJ20jjuzD2vATS6uIjVz+Updeqmszi7zq7rdFTLp6Ea3/z4kDKIfZwmR9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-x64/-/wasm-tools-darwin-x64-1.0.1.tgz", + "integrity": "sha512-bA6hubqtHROR5UI3tToAF/c6TDmaAgF0SWgo4rADHtQ4wdn0JeogvOk50gs2TYVhKPE2ZD2+qqt7oBKB+sxW3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-freebsd-x64/-/wasm-tools-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-90+KLBkD9hZEjPQW1MDfwSt5J1L46EUKacpCZWyRuL6iIEO5CgWU0V/JnEgFsDOGyyYtiTvHc5bUdUTWd4I9Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-gnu/-/wasm-tools-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-rG0QlS65x9K/u3HrKafDf8cFKj5wV2JHGfl8abWgKew0GVPyp6vfsDweOwHbWAjcHtp2LHi6JHoW80/MTHm52Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-musl/-/wasm-tools-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-jAasbIvjZXCgX0TCuEFQr+4D6Lla/3AAVx2LmDuMjgG4xoIXzjKWl7c4chuaD+TI+prWT0X6LJcdzFT+ROKGHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-gnu/-/wasm-tools-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-Plgk5rPqqK2nocBGajkMVbGm010Z7dnUgq0wtnYRZbzWWxwWcXfZMPa8EYxrK4eE8SzpI7VlZP1tdVsdjgGwMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-musl/-/wasm-tools-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-GW7AzGuWxtQkyHknHWYFdR0CHmW6is8rG2Rf4V6GNmMpmwtXt/ItWYWtBe4zqJWycMNazpfZKSw/BpT7/MVCXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-wasm32-wasi/-/wasm-tools-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-/nQVSTrqSsn7YdAc2R7Ips/tnw5SPUcl3D7QrXCNGPqjbatIspnaexvaOYNyKMU6xPu+pc0BTnKVmqhlJJCPLA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", + "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-arm64-msvc/-/wasm-tools-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-PFi7oJIBu5w7Qzh3dwFea3sHRO3pojMsaEnUIy22QvsW+UJfNQwJCryVrpoUt8m4QyZXI+saEq/0r4GwdoHYFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-ia32-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-ia32-msvc/-/wasm-tools-win32-ia32-msvc-1.0.1.tgz", + "integrity": "sha512-gXkuYzxQsgkj05Zaq+KQTkHIN83dFAwMcTKa2aQcpYPRImFm2AQzEyLtpXmyCWzJ0F9ZYAOmbSyrNew8/us6bw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-x64-msvc/-/wasm-tools-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-rEAf05nol3e3eei2sRButmgXP+6ATgm0/38MKhz9Isne82T4rPIMYsCIFj0kOisaGeVwoi2fnm7O9oWp5YVnYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@ng-select/ng-select": { "version": "20.7.0", "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-20.7.0.tgz", @@ -11411,6 +12594,172 @@ "yargs-parser": "21.1.1" } }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", + "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", + "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.2", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -17108,6 +18457,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/bent": { "version": "7.3.12", "resolved": "https://registry.npmjs.org/bent/-/bent-7.3.12.tgz", @@ -18323,6 +19679,22 @@ "node": ">= 12" } }, + "node_modules/clipanion": { + "version": "4.0.0-rc.4", + "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz", + "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ], + "dependencies": { + "typanion": "^3.8.0" + }, + "peerDependencies": { + "typanion": "*" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -20856,6 +22228,21 @@ "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, + "node_modules/emnapi": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/emnapi/-/emnapi-1.7.1.tgz", + "integrity": "sha512-wlLK2xFq+T+rCBlY6+lPlFVDEyE93b7hSn9dMrfWBIcPf4ArwUvymvvMnN9M5WWuiryYQe9M+UJrkqw4trdyRA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "node-addon-api": ">= 6.1.0" + }, + "peerDependenciesMeta": { + "node-addon-api": { + "optional": true + } + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -21179,6 +22566,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", + "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/es6-error": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", @@ -22182,6 +23580,23 @@ "node": ">=10.13.0" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -39707,6 +41122,16 @@ "node": "*" } }, + "node_modules/typanion": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", + "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ] + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -40112,6 +41537,19 @@ "node": ">=4" } }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -40217,6 +41655,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", From 0e277a411d3c37fcb4141cb652e6dddfd44ba37d Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:31:28 -0500 Subject: [PATCH 25/60] [PM-1632] Redirect on SSO required response from `connect/token` (#17637) * feat: add Identity Sso Required Response type as possible response from token endpoint. * feat: consume sso organization identifier to redirect user * feat: add get requiresSso to AuthResult for more ergonomic code. * feat: sso-redirect on sso-required for CLI and Desktop * chore: fixing type errors * test: fix and add tests for new sso method * docs: fix misspelling * fix: get email from AuthResult instead of the FormGroup * fix:claude: when email is not available for SSO login show error toast. * fix:claude: add null safety check --- .../extension-login-component.service.spec.ts | 30 +++++++ .../extension-login-component.service.ts | 2 + apps/cli/src/auth/commands/login.command.ts | 80 ++++++++++++++++--- .../desktop-login-component.service.spec.ts | 52 ++++++++++++ .../login/desktop-login-component.service.ts | 12 ++- apps/desktop/src/platform/preload.ts | 9 ++- .../sso-localhost-callback.service.ts | 33 +++++--- .../login/web-login-component.service.ts | 5 +- apps/web/src/locales/en/messages.json | 3 + .../login/default-login-component.service.ts | 36 ++++++--- .../angular/login/login-component.service.ts | 8 ++ .../auth/src/angular/login/login.component.ts | 18 +++++ .../common/login-strategies/login.strategy.ts | 21 ++++- .../password-login.strategy.ts | 11 ++- .../services/sso-redirect/sso-url.service.ts | 4 +- libs/common/src/abstractions/api.service.ts | 6 +- .../src/auth/models/domain/auth-result.ts | 8 ++ .../identity-sso-required.response.ts | 10 +++ libs/common/src/services/api.service.ts | 8 +- 19 files changed, 308 insertions(+), 48 deletions(-) create mode 100644 libs/common/src/auth/models/response/identity-sso-required.response.ts diff --git a/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts b/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts index bd85ff9293e..dc1a7b4bb6b 100644 --- a/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts +++ b/apps/browser/src/auth/popup/login/extension-login-component.service.spec.ts @@ -102,6 +102,36 @@ describe("ExtensionLoginComponentService", () => { }); }); + describe("redirectToSsoLoginWithOrganizationSsoIdentifier", () => { + it("launches SSO browser window with correct Url", async () => { + const email = "test@bitwarden.com"; + const state = "testState"; + const expectedState = "testState:clientId=browser"; + const codeVerifier = "testCodeVerifier"; + const codeChallenge = "testCodeChallenge"; + const orgSsoIdentifier = "org-sso-identifier"; + + passwordGenerationService.generatePassword.mockResolvedValueOnce(state); + passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier); + jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge); + + await service.redirectToSsoLoginWithOrganizationSsoIdentifier(email, orgSsoIdentifier); + + expect(ssoUrlService.buildSsoUrl).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(String), + email, + orgSsoIdentifier, + ); + expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(expectedState); + expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier); + expect(platformUtilsService.launchUri).toHaveBeenCalled(); + }); + }); + describe("showBackButton", () => { it("sets showBackButton in extensionAnonLayoutWrapperDataService", () => { service.showBackButton(true); diff --git a/apps/browser/src/auth/popup/login/extension-login-component.service.ts b/apps/browser/src/auth/popup/login/extension-login-component.service.ts index 621c7d74876..cfaf6e04d10 100644 --- a/apps/browser/src/auth/popup/login/extension-login-component.service.ts +++ b/apps/browser/src/auth/popup/login/extension-login-component.service.ts @@ -47,6 +47,7 @@ export class ExtensionLoginComponentService email: string, state: string, codeChallenge: string, + orgSsoIdentifier?: string, ): Promise { const env = await firstValueFrom(this.environmentService.environment$); const webVaultUrl = env.getWebVaultUrl(); @@ -60,6 +61,7 @@ export class ExtensionLoginComponentService state, codeChallenge, email, + orgSsoIdentifier, ); this.platformUtilsService.launchUri(webAppSsoUrl); diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 661e052fb72..d8859318b52 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -113,20 +113,14 @@ export class LoginCommand { } else if (options.sso != null && this.canInteract) { // If the optional Org SSO Identifier isn't provided, the option value is `true`. const orgSsoIdentifier = options.sso === true ? null : options.sso; - const passwordOptions: any = { - type: "password", - length: 64, - uppercase: true, - lowercase: true, - numbers: true, - special: false, - }; - const state = await this.passwordGenerationService.generatePassword(passwordOptions); - ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); - const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, "sha256"); - const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + const ssoPromptData = await this.makeSsoPromptData(); + ssoCodeVerifier = ssoPromptData.ssoCodeVerifier; try { - const ssoParams = await this.openSsoPrompt(codeChallenge, state, orgSsoIdentifier); + const ssoParams = await this.openSsoPrompt( + ssoPromptData.codeChallenge, + ssoPromptData.state, + orgSsoIdentifier, + ); ssoCode = ssoParams.ssoCode; orgIdentifier = ssoParams.orgIdentifier; } catch { @@ -231,9 +225,43 @@ export class LoginCommand { new PasswordLoginCredentials(email, password, twoFactor), ); } + + // Begin Acting on initial AuthResult + if (response.requiresEncryptionKeyMigration) { return Response.error(this.i18nService.t("legacyEncryptionUnsupported")); } + + // Opting for not checking feature flag since the server will not respond with + // SsoOrganizationIdentifier if the feature flag is not enabled. + if (response.requiresSso && this.canInteract) { + const ssoPromptData = await this.makeSsoPromptData(); + ssoCodeVerifier = ssoPromptData.ssoCodeVerifier; + try { + const ssoParams = await this.openSsoPrompt( + ssoPromptData.codeChallenge, + ssoPromptData.state, + response.ssoOrganizationIdentifier, + ); + ssoCode = ssoParams.ssoCode; + orgIdentifier = ssoParams.orgIdentifier; + if (ssoCode != null && ssoCodeVerifier != null) { + response = await this.loginStrategyService.logIn( + new SsoLoginCredentials( + ssoCode, + ssoCodeVerifier, + this.ssoRedirectUri, + orgIdentifier, + undefined, // email to look up 2FA token not required as CLI can't remember 2FA token + twoFactor, + ), + ); + } + } catch { + return Response.badRequest("Something went wrong. Try again."); + } + } + if (response.requiresTwoFactor) { const twoFactorProviders = await this.twoFactorService.getSupportedProviders(null); if (twoFactorProviders.length === 0) { @@ -279,6 +307,10 @@ export class LoginCommand { if (twoFactorToken == null && selectedProvider.type === TwoFactorProviderType.Email) { const emailReq = new TwoFactorEmailRequest(); emailReq.email = await this.loginStrategyService.getEmail(); + // if the user was logging in with SSO, we need to include the SSO session token + if (response.ssoEmail2FaSessionToken != null) { + emailReq.ssoEmail2FaSessionToken = response.ssoEmail2FaSessionToken; + } emailReq.masterPasswordHash = await this.loginStrategyService.getMasterPasswordHash(); await this.twoFactorApiService.postTwoFactorEmail(emailReq); } @@ -324,6 +356,7 @@ export class LoginCommand { response = await this.loginStrategyService.logInNewDeviceVerification(newDeviceToken); } + // We check response two factor again here since MFA could fail based on the logic on ln 226 if (response.requiresTwoFactor) { return Response.error("Login failed."); } @@ -692,6 +725,27 @@ export class LoginCommand { }; } + /// Generate SSO prompt data: code verifier, code challenge, and state + private async makeSsoPromptData(): Promise<{ + ssoCodeVerifier: string; + codeChallenge: string; + state: string; + }> { + const passwordOptions: any = { + type: "password", + length: 64, + uppercase: true, + lowercase: true, + numbers: true, + special: false, + }; + const state = await this.passwordGenerationService.generatePassword(passwordOptions); + const ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions); + const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, "sha256"); + const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash); + return { ssoCodeVerifier, codeChallenge, state }; + } + private async openSsoPrompt( codeChallenge: string, state: string, diff --git a/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts b/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts index c88627250c9..414bbaca56f 100644 --- a/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts +++ b/apps/desktop/src/auth/login/desktop-login-component.service.spec.ts @@ -136,6 +136,7 @@ describe("DesktopLoginComponentService", () => { codeChallenge, state, email, + undefined, ); } else { expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); @@ -145,4 +146,55 @@ describe("DesktopLoginComponentService", () => { }); }); }); + + describe("redirectToSsoLoginWithOrganizationSsoIdentifier", () => { + // Array of all permutations of isAppImage and isDev + const permutations = [ + [true, false], // Case 1: isAppImage true + [false, true], // Case 2: isDev true + [true, true], // Case 3: all true + [false, false], // Case 4: all false + ]; + + permutations.forEach(([isAppImage, isDev]) => { + it("calls redirectToSso with orgSsoIdentifier", async () => { + (global as any).ipc.platform.isAppImage = isAppImage; + (global as any).ipc.platform.isDev = isDev; + + const email = "test@bitwarden.com"; + const state = "testState"; + const codeVerifier = "testCodeVerifier"; + const codeChallenge = "testCodeChallenge"; + const orgSsoIdentifier = "orgSsoId"; + + passwordGenerationService.generatePassword.mockResolvedValueOnce(state); + passwordGenerationService.generatePassword.mockResolvedValueOnce(codeVerifier); + jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue(codeChallenge); + + await service.redirectToSsoLoginWithOrganizationSsoIdentifier(email, orgSsoIdentifier); + + if (isAppImage || isDev) { + expect(ipc.platform.localhostCallbackService.openSsoPrompt).toHaveBeenCalledWith( + codeChallenge, + state, + email, + orgSsoIdentifier, + ); + } else { + expect(ssoUrlService.buildSsoUrl).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(String), + expect.any(String), + email, + orgSsoIdentifier, + ); + expect(ssoLoginService.setSsoState).toHaveBeenCalledWith(state); + expect(ssoLoginService.setCodeVerifier).toHaveBeenCalledWith(codeVerifier); + expect(platformUtilsService.launchUri).toHaveBeenCalled(); + } + }); + }); + }); }); diff --git a/apps/desktop/src/auth/login/desktop-login-component.service.ts b/apps/desktop/src/auth/login/desktop-login-component.service.ts index d7e7ba0178b..6ef39eaa018 100644 --- a/apps/desktop/src/auth/login/desktop-login-component.service.ts +++ b/apps/desktop/src/auth/login/desktop-login-component.service.ts @@ -48,11 +48,12 @@ export class DesktopLoginComponentService email: string, state: string, codeChallenge: string, + orgSsoIdentifier?: string, ): Promise { // For platforms that cannot support a protocol-based (e.g. bitwarden://) callback, we use a localhost callback // Otherwise, we launch the SSO component in a browser window and wait for the callback if (ipc.platform.isAppImage || ipc.platform.isDev) { - await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge); + await this.initiateSsoThroughLocalhostCallback(email, state, codeChallenge, orgSsoIdentifier); } else { const env = await firstValueFrom(this.environmentService.environment$); const webVaultUrl = env.getWebVaultUrl(); @@ -66,6 +67,7 @@ export class DesktopLoginComponentService state, codeChallenge, email, + orgSsoIdentifier, ); this.platformUtilsService.launchUri(ssoWebAppUrl); @@ -76,9 +78,15 @@ export class DesktopLoginComponentService email: string, state: string, challenge: string, + orgSsoIdentifier?: string, ): Promise { try { - await ipc.platform.localhostCallbackService.openSsoPrompt(challenge, state, email); + await ipc.platform.localhostCallbackService.openSsoPrompt( + challenge, + state, + email, + orgSsoIdentifier, + ); // FIXME: Remove when updating file. Eslint update // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (err) { diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 5af2fa571ec..a45ac753b3f 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -108,8 +108,13 @@ const ephemeralStore = { }; const localhostCallbackService = { - openSsoPrompt: (codeChallenge: string, state: string, email: string): Promise => { - return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email }); + openSsoPrompt: ( + codeChallenge: string, + state: string, + email: string, + orgSsoIdentifier?: string, + ): Promise => { + return ipcRenderer.invoke("openSsoPrompt", { codeChallenge, state, email, orgSsoIdentifier }); }, }; diff --git a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts index 75a84919b07..fdd9bc29237 100644 --- a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts +++ b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts @@ -25,20 +25,25 @@ export class SSOLocalhostCallbackService { private messagingService: MessageSender, private ssoUrlService: SsoUrlService, ) { - ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state, email }) => { - // Close any existing server before starting new one - if (this.currentServer) { - await this.closeCurrentServer(); - } + ipcMain.handle( + "openSsoPrompt", + async (event, { codeChallenge, state, email, orgSsoIdentifier }) => { + // Close any existing server before starting new one + if (this.currentServer) { + await this.closeCurrentServer(); + } - return this.openSsoPrompt(codeChallenge, state, email).then(({ ssoCode, recvState }) => { - this.messagingService.send("ssoCallback", { - code: ssoCode, - state: recvState, - redirectUri: this.ssoRedirectUri, - }); - }); - }); + return this.openSsoPrompt(codeChallenge, state, email, orgSsoIdentifier).then( + ({ ssoCode, recvState }) => { + this.messagingService.send("ssoCallback", { + code: ssoCode, + state: recvState, + redirectUri: this.ssoRedirectUri, + }); + }, + ); + }, + ); } private async closeCurrentServer(): Promise { @@ -58,6 +63,7 @@ export class SSOLocalhostCallbackService { codeChallenge: string, state: string, email: string, + orgSsoIdentifier?: string, ): Promise<{ ssoCode: string; recvState: string }> { const env = await firstValueFrom(this.environmentService.environment$); @@ -121,6 +127,7 @@ export class SSOLocalhostCallbackService { state, codeChallenge, email, + orgSsoIdentifier, ); // Set up error handler before attempting to listen diff --git a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts index 5bea0908b0a..8c1bc4bd080 100644 --- a/apps/web/src/app/auth/core/services/login/web-login-component.service.ts +++ b/apps/web/src/app/auth/core/services/login/web-login-component.service.ts @@ -61,8 +61,11 @@ export class WebLoginComponentService email: string, state: string, codeChallenge: string, + orgSsoIdentifier?: string, ): Promise { - await this.router.navigate(["/sso"]); + await this.router.navigate(["/sso"], { + queryParams: { identifier: orgSsoIdentifier }, + }); return; } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 85159c0230c..be2f72e34b0 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -9490,6 +9490,9 @@ "ssoLoginIsRequired": { "message": "SSO login is required" }, + "emailRequiredForSsoLogin": { + "message": "Email is required for SSO" + }, "selectedRegionFlag": { "message": "Selected region flag" }, diff --git a/libs/auth/src/angular/login/default-login-component.service.ts b/libs/auth/src/angular/login/default-login-component.service.ts index 7f98040d9c2..2d50a0ffeb7 100644 --- a/libs/auth/src/angular/login/default-login-component.service.ts +++ b/libs/auth/src/angular/login/default-login-component.service.ts @@ -33,19 +33,27 @@ export class DefaultLoginComponentService implements LoginComponentService { */ async redirectToSsoLogin(email: string): Promise { // Set the state that we'll need to verify the SSO login when we get the code back - const [state, codeChallenge] = await this.setSsoPreLoginState(); - - // Set the email address in state. This is used in 2 places: - // 1. On the web client, on the SSO component we need the email address to look up - // the org SSO identifier. The email address is passed via query param for the other clients. - // 2. On all clients, after authentication on the originating client the SSO component - // will need to look up 2FA Remember token by email. - await this.ssoLoginService.setSsoEmail(email); + const [state, codeChallenge] = await this.setSsoPreLoginState(email); // Finally, we redirect to the SSO login page. This will be handled by each client implementation of this service. await this.redirectToSso(email, state, codeChallenge); } + /** + * Redirects the user to the SSO login page, either via route or in a new browser window. + * @param email The email address of the user attempting to log in + */ + async redirectToSsoLoginWithOrganizationSsoIdentifier( + email: string, + orgSsoIdentifier: string, + ): Promise { + // Set the state that we'll need to verify the SSO login when we get the code back + const [state, codeChallenge] = await this.setSsoPreLoginState(email); + + // Finally, we redirect to the SSO login page. This will be handled by each client implementation of this service. + await this.redirectToSso(email, state, codeChallenge, orgSsoIdentifier); + } + /** * No-op implementation of redirectToSso */ @@ -53,6 +61,7 @@ export class DefaultLoginComponentService implements LoginComponentService { email: string, state: string, codeChallenge: string, + orgSsoIdentifier?: string, ): Promise { return; } @@ -65,9 +74,9 @@ export class DefaultLoginComponentService implements LoginComponentService { } /** - * Sets the state required for verifying SSO login after completion + * Set the state that we'll need to verify the SSO login when we get the authorization code back */ - private async setSsoPreLoginState(): Promise<[string, string]> { + private async setSsoPreLoginState(email: string): Promise<[string, string]> { // Generate SSO params const passwordOptions: any = { type: "password", @@ -93,6 +102,13 @@ export class DefaultLoginComponentService implements LoginComponentService { await this.ssoLoginService.setSsoState(state); await this.ssoLoginService.setCodeVerifier(codeVerifier); + // Set the email address in state. This is used in 2 places: + // 1. On the web client, on the SSO component we need the email address to look up + // the org SSO identifier. The email address is passed via query param for the other clients. + // 2. On all clients, after authentication on the originating client the SSO component + // will need to look up 2FA Remember token by email. + await this.ssoLoginService.setSsoEmail(email); + return [state, codeChallenge]; } } diff --git a/libs/auth/src/angular/login/login-component.service.ts b/libs/auth/src/angular/login/login-component.service.ts index 5ca83c97c5f..b7c2b16ce24 100644 --- a/libs/auth/src/angular/login/login-component.service.ts +++ b/libs/auth/src/angular/login/login-component.service.ts @@ -35,6 +35,14 @@ export abstract class LoginComponentService { */ redirectToSsoLogin: (email: string) => Promise; + /** + * Redirects the user to the SSO login page with organization SSO identifier, either via route or in a new browser window. + */ + redirectToSsoLoginWithOrganizationSsoIdentifier: ( + email: string, + orgSsoIdentifier: string | null | undefined, + ) => Promise; + /** * Shows the back button. */ diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index 91ca2b614d1..8e688f3f830 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -381,6 +381,24 @@ export class LoginComponent implements OnInit, OnDestroy { return; } + // redirect to SSO if ssoOrganizationIdentifier is present in token response + if (authResult.requiresSso) { + const email = this.formGroup?.value?.email; + if (!email) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("emailRequiredForSsoLogin"), + }); + return; + } + await this.loginComponentService.redirectToSsoLoginWithOrganizationSsoIdentifier( + email, + authResult.ssoOrganizationIdentifier, + ); + return; + } + // User logged in successfully so execute side effects await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 08d5ae6246f..ae375c8b2f5 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -13,6 +13,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request"; import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response"; +import { IdentitySsoRequiredResponse } from "@bitwarden/common/auth/models/response/identity-sso-required.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { TwoFactorService } from "@bitwarden/common/auth/two-factor"; @@ -49,7 +50,8 @@ import { CacheData } from "../services/login-strategies/login-strategy.state"; type IdentityResponse = | IdentityTokenResponse | IdentityTwoFactorResponse - | IdentityDeviceVerificationResponse; + | IdentityDeviceVerificationResponse + | IdentitySsoRequiredResponse; export abstract class LoginStrategyData { tokenRequest: @@ -128,6 +130,8 @@ export abstract class LoginStrategy { return [await this.processTokenResponse(response), response]; } else if (response instanceof IdentityDeviceVerificationResponse) { return [await this.processDeviceVerificationResponse(response), response]; + } else if (response instanceof IdentitySsoRequiredResponse) { + return [await this.processSsoRequiredResponse(response), response]; } throw new Error("Invalid response object."); @@ -398,4 +402,19 @@ export abstract class LoginStrategy { result.requiresDeviceVerification = true; return result; } + + /** + * Handles the response from the server when a SSO Authentication is required. + * It hydrates the AuthResult with the SSO organization identifier. + * + * @param {IdentitySsoRequiredResponse} response - The response from the server indicating that SSO is required. + * @returns {Promise} - A promise that resolves to an AuthResult object + */ + protected async processSsoRequiredResponse( + response: IdentitySsoRequiredResponse, + ): Promise { + const result = new AuthResult(); + result.ssoOrganizationIdentifier = response.ssoOrganizationIdentifier; + return result; + } } diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index ad49567b2ff..842a48e28cd 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -10,6 +10,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response"; +import { IdentitySsoRequiredResponse } from "@bitwarden/common/auth/models/response/identity-sso-required.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { HashPurpose } from "@bitwarden/common/platform/enums"; @@ -165,14 +166,20 @@ export class PasswordLoginStrategy extends LoginStrategy { identityResponse: | IdentityTokenResponse | IdentityTwoFactorResponse - | IdentityDeviceVerificationResponse, + | IdentityDeviceVerificationResponse + | IdentitySsoRequiredResponse, credentials: PasswordLoginCredentials, authResult: AuthResult, ): Promise { // TODO: PM-21084 - investigate if we should be sending down masterPasswordPolicy on the // IdentityDeviceVerificationResponse like we do for the IdentityTwoFactorResponse // If the response is a device verification response, we don't need to evaluate the password - if (identityResponse instanceof IdentityDeviceVerificationResponse) { + // If SSO is required, we also do not evaluate the password here, since the user needs to first + // authenticate with their SSO IdP Provider + if ( + identityResponse instanceof IdentityDeviceVerificationResponse || + identityResponse instanceof IdentitySsoRequiredResponse + ) { return; } diff --git a/libs/auth/src/common/services/sso-redirect/sso-url.service.ts b/libs/auth/src/common/services/sso-redirect/sso-url.service.ts index b2d6231db7c..22404c5b1f7 100644 --- a/libs/auth/src/common/services/sso-redirect/sso-url.service.ts +++ b/libs/auth/src/common/services/sso-redirect/sso-url.service.ts @@ -8,9 +8,9 @@ export class SsoUrlService { * @param webAppUrl The URL of the web app * @param clientType The client type that is initiating SSO, which will drive how the response is handled * @param redirectUri The redirect URI or callback that will receive the SSO code after authentication - * @param state A state value that will be peristed through the SSO flow + * @param state A state value that will be persisted through the SSO flow * @param codeChallenge A challenge value that will be used to verify the SSO code after authentication - * @param email The optional email adddress of the user initiating SSO, which will be used to look up the org SSO identifier + * @param email The optional email address of the user initiating SSO, which will be used to look up the org SSO identifier * @param orgSsoIdentifier The optional SSO identifier of the org that is initiating SSO * @returns The URL for redirecting users to the web app SSO component */ diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index f7ca1964b76..72a17f0fa87 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -50,6 +50,7 @@ import { UpdateProfileRequest } from "../auth/models/request/update-profile.requ import { ApiKeyResponse } from "../auth/models/response/api-key.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response"; +import { IdentitySsoRequiredResponse } from "../auth/models/response/identity-sso-required.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; @@ -140,7 +141,10 @@ export abstract class ApiService { | UserApiTokenRequest | WebAuthnLoginTokenRequest, ): Promise< - IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse + | IdentityTokenResponse + | IdentityTwoFactorResponse + | IdentityDeviceVerificationResponse + | IdentitySsoRequiredResponse >; abstract refreshIdentityToken(userId?: UserId): Promise; diff --git a/libs/common/src/auth/models/domain/auth-result.ts b/libs/common/src/auth/models/domain/auth-result.ts index ae3e9bdeda6..178866901d3 100644 --- a/libs/common/src/auth/models/domain/auth-result.ts +++ b/libs/common/src/auth/models/domain/auth-result.ts @@ -1,5 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { Utils } from "@bitwarden/common/platform/misc/utils"; + import { UserId } from "../../../types/guid"; import { TwoFactorProviderType } from "../../enums/two-factor-provider-type"; @@ -18,10 +20,16 @@ export class AuthResult { email: string; requiresEncryptionKeyMigration: boolean; requiresDeviceVerification: boolean; + ssoOrganizationIdentifier?: string | null; // The master-password used in the authentication process masterPassword: string | null; get requiresTwoFactor() { return this.twoFactorProviders != null; } + + // This is not as extensible as an object-based approach. In the future we may need to adjust to an object based approach. + get requiresSso() { + return !Utils.isNullOrWhitespace(this.ssoOrganizationIdentifier); + } } diff --git a/libs/common/src/auth/models/response/identity-sso-required.response.ts b/libs/common/src/auth/models/response/identity-sso-required.response.ts new file mode 100644 index 00000000000..b1b6df6fd08 --- /dev/null +++ b/libs/common/src/auth/models/response/identity-sso-required.response.ts @@ -0,0 +1,10 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; + +export class IdentitySsoRequiredResponse extends BaseResponse { + ssoOrganizationIdentifier: string | null; + + constructor(response: any) { + super(response); + this.ssoOrganizationIdentifier = this.getResponseProperty("SsoOrganizationIdentifier"); + } +} diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 5f4d3de11b5..c60f6c5e907 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -63,6 +63,7 @@ import { UpdateProfileRequest } from "../auth/models/request/update-profile.requ import { ApiKeyResponse } from "../auth/models/response/api-key.response"; import { AuthRequestResponse } from "../auth/models/response/auth-request.response"; import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response"; +import { IdentitySsoRequiredResponse } from "../auth/models/response/identity-sso-required.response"; import { IdentityTokenResponse } from "../auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response"; import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response"; @@ -165,7 +166,10 @@ export class ApiService implements ApiServiceAbstraction { | SsoTokenRequest | WebAuthnLoginTokenRequest, ): Promise< - IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse + | IdentityTokenResponse + | IdentityTwoFactorResponse + | IdentityDeviceVerificationResponse + | IdentitySsoRequiredResponse > { const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", @@ -212,6 +216,8 @@ export class ApiService implements ApiServiceAbstraction { responseJson?.ErrorModel?.Message === ApiService.NEW_DEVICE_VERIFICATION_REQUIRED_MESSAGE ) { return new IdentityDeviceVerificationResponse(responseJson); + } else if (response.status === 400 && responseJson?.SsoOrganizationIdentifier) { + return new IdentitySsoRequiredResponse(responseJson); } } From 38ca88be1fd9fc29738af99f67c9f8bca0076395 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:08:08 -0600 Subject: [PATCH 26/60] [deps] Platform: Update parse5 to v7.3.0 (#14924) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 20 ++++++++++++++++---- package.json | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 82b7e805a70..b6e8d19af59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34605,12 +34605,12 @@ } }, "node_modules/parse5": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", - "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "license": "MIT", "dependencies": { - "entities": "^4.5.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -34684,6 +34684,18 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", diff --git a/package.json b/package.json index c7b04c434e7..2358422258f 100644 --- a/package.json +++ b/package.json @@ -217,7 +217,7 @@ "eslint": "$eslint" }, "tailwindcss": "$tailwindcss", - "parse5": "7.2.1", + "parse5": "7.3.0", "react": "18.3.1", "react-dom": "18.3.1", "@types/react": "18.3.27" From 0439f60a3bf20f4b61c54bd2d95fef7162840d04 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:22:35 -0600 Subject: [PATCH 27/60] [deps] Platform: Update Rust crate oo7 to v0.5.0 (#16416) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 25 +++++++++++++++++++++---- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 7aeeefb2d0d..4a5985de8f9 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -138,6 +138,23 @@ dependencies = [ "zbus", ] +[[package]] +name = "ashpd" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0986d5b4f0802160191ad75f8d33ada000558757db3defb70299ca95d9fcbd" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.1", + "serde", + "serde_repr", + "tokio", + "url", + "zbus", +] + [[package]] name = "askama" version = "0.12.1" @@ -816,7 +833,7 @@ dependencies = [ "aes", "anyhow", "arboard", - "ashpd", + "ashpd 0.11.0", "base64", "bitwarden-russh", "bytes", @@ -2135,12 +2152,12 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "oo7" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb23d3ec3527d65a83be1c1795cb883c52cfa57147d42acc797127df56fc489" +checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" dependencies = [ "aes", - "ashpd", + "ashpd 0.12.0", "cbc", "cipher", "digest", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 2eff1af41b5..2926aac7f06 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -45,7 +45,7 @@ memsec = "=0.7.0" napi = "=3.3.0" napi-build = "=2.2.3" napi-derive = "=3.2.5" -oo7 = "=0.4.3" +oo7 = "=0.5.0" pin-project = "=1.1.10" pkcs8 = "=0.10.2" rand = "=0.9.1" From 8b5cb48519cbcb282dabd540bd5268a16acb1431 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:37:09 -0600 Subject: [PATCH 28/60] [deps] Platform: Update sass to v1.95.1 (#17887) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index b6e8d19af59..a86f728f21d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -165,7 +165,7 @@ "process": "0.11.10", "remark-gfm": "4.0.1", "rimraf": "6.1.2", - "sass": "1.94.2", + "sass": "1.95.1", "sass-loader": "16.0.6", "storybook": "9.1.16", "style-loader": "4.0.0", @@ -37529,9 +37529,9 @@ } }, "node_modules/sass": { - "version": "1.94.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.94.2.tgz", - "integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==", + "version": "1.95.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.95.1.tgz", + "integrity": "sha512-uPoDh5NIEZV4Dp5GBodkmNY9tSQfXY02pmCcUo+FR1P+x953HGkpw+vV28D4IqYB6f8webZtwoSaZaiPtpTeMg==", "license": "MIT", "dependencies": { "chokidar": "^4.0.0", diff --git a/package.json b/package.json index 2358422258f..9cb65a6e1db 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "process": "0.11.10", "remark-gfm": "4.0.1", "rimraf": "6.1.2", - "sass": "1.94.2", + "sass": "1.95.1", "sass-loader": "16.0.6", "storybook": "9.1.16", "style-loader": "4.0.0", From 3c6b6eaa56fdf565eb56e8189c18d6bd96d632e9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:51:02 -0600 Subject: [PATCH 29/60] [deps] Platform: Update Rust crate anyhow to v1.0.100 (#17546) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 4 ++-- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 4a5985de8f9..6536ccaa7af 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -99,9 +99,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.94" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arboard" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 2926aac7f06..37584dca235 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -21,7 +21,7 @@ publish = false [workspace.dependencies] aes = "=0.8.4" aes-gcm = "=0.10.3" -anyhow = "=1.0.94" +anyhow = "=1.0.100" arboard = { version = "=3.6.1", default-features = false } ashpd = "=0.11.0" base64 = "=0.22.1" From 7f892cf26afd3183a7aab94929148999152f4552 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:57:36 -0600 Subject: [PATCH 30/60] [deps] Autofill: Update prettier to v3.7.3 (#17853) * [deps] Autofill: Update prettier to v3.6.2 * fix: [PM-23425] Fix prettier issues related to dependency updte Signed-off-by: Ben Brooks * [deps] Autofill: Update prettier to v3.6.2 * [deps] Autofill: Update prettier to v3.7.3 * [PM-29379] Fix prettier issues found with the updated Prettier 3.7.3 Signed-off-by: Ben Brooks --------- Signed-off-by: Ben Brooks Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ben Brooks --- .../extension-device-management-component.service.ts | 4 +--- .../services/browser-fido2-user-interface.service.ts | 4 +--- .../content/overlay-notifications-content.service.ts | 4 +--- .../inline-menu-field-qualification.service.ts | 4 +--- .../services/desktop-fido2-user-interface.service.ts | 4 +--- .../organization-user-reset-password.service.ts | 11 ++++------- .../webauthn-login/webauthn-login-admin.service.ts | 4 +--- .../services/emergency-access.service.ts | 11 ++++------- .../default-device-management-component.service.ts | 4 +--- ...default-login-approval-dialog-component.service.ts | 4 +--- .../encrypted-migrations-scheduler.service.ts | 4 +--- ...fault-new-device-verification-component.service.ts | 4 +--- ...ault-two-factor-auth-webauthn-component.service.ts | 4 +--- .../user-decryption-options.service.ts | 4 +--- ...ult-organization-management-preferences.service.ts | 4 +--- ...assword-reset-enrollment.service.implementation.ts | 4 +--- .../organization-sponsorship-api.service.ts | 4 +--- libs/common/src/platform/ipc/ipc-message.ts | 6 ++++-- .../services/fido2/fido2-authenticator.service.ts | 6 +++--- .../platform/services/fido2/fido2-client.service.ts | 6 +++--- libs/common/src/tools/state/secret-classifier.ts | 8 +++++--- libs/common/src/tools/state/secret-state.ts | 10 +++++++--- libs/common/src/tools/state/user-state-subject.ts | 10 +++++----- libs/components/src/dialog/dialog.service.ts | 7 ++++--- ...lt-user-asymmetric-key-regeneration-api.service.ts | 4 +--- ...efault-user-asymmetric-key-regeneration.service.ts | 4 +--- libs/state-internal/src/default-derived-state.ts | 8 +++++--- libs/state-internal/src/inline-derived-state.ts | 8 +++++--- libs/state-test-utils/src/fake-state.ts | 8 +++++--- .../core/src/engine/rpc/create-forwarding-address.ts | 3 +-- .../generator/core/src/engine/rpc/get-account-id.ts | 3 +-- .../core/src/policies/default-policy-evaluator.ts | 7 ++++--- .../policies/dynamic-password-policy-constraints.ts | 4 +--- .../src/strategies/catchall-generator-strategy.ts | 7 ++++--- .../src/strategies/eff-username-generator-strategy.ts | 7 ++++--- .../core/src/strategies/options-classifier.ts | 3 +-- .../src/strategies/passphrase-generator-strategy.ts | 7 ++++--- .../src/strategies/password-generator-strategy.ts | 7 ++++--- .../src/strategies/subaddress-generator-strategy.ts | 7 ++++--- .../navigation/src/generator-navigation-evaluator.ts | 7 ++++--- package-lock.json | 8 ++++---- package.json | 2 +- 42 files changed, 108 insertions(+), 131 deletions(-) diff --git a/apps/browser/src/auth/services/extension-device-management-component.service.ts b/apps/browser/src/auth/services/extension-device-management-component.service.ts index 2585ba3198c..eb7ea4be37b 100644 --- a/apps/browser/src/auth/services/extension-device-management-component.service.ts +++ b/apps/browser/src/auth/services/extension-device-management-component.service.ts @@ -3,9 +3,7 @@ import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/ /** * Browser extension implementation of the device management component service */ -export class ExtensionDeviceManagementComponentService - implements DeviceManagementComponentServiceAbstraction -{ +export class ExtensionDeviceManagementComponentService implements DeviceManagementComponentServiceAbstraction { /** * Don't show header information in browser extension client */ diff --git a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts index 5818bbf8d82..e0ab45e9f84 100644 --- a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts @@ -120,9 +120,7 @@ export type BrowserFido2ParentWindowReference = chrome.tabs.Tab; * Browser implementation of the {@link Fido2UserInterfaceService}. * The user interface is implemented as a popout and the service uses the browser's messaging API to communicate with it. */ -export class BrowserFido2UserInterfaceService - implements Fido2UserInterfaceServiceAbstraction -{ +export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { constructor(private authService: AuthService) {} async newSession( diff --git a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts index 8dc08169468..ab3b8144426 100644 --- a/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts +++ b/apps/browser/src/autofill/overlay/notifications/content/overlay-notifications-content.service.ts @@ -15,9 +15,7 @@ import { OverlayNotificationsExtensionMessageHandlers, } from "../abstractions/overlay-notifications-content.service"; -export class OverlayNotificationsContentService - implements OverlayNotificationsContentServiceInterface -{ +export class OverlayNotificationsContentService implements OverlayNotificationsContentServiceInterface { private notificationBarRootElement: HTMLElement | null = null; private notificationBarElement: HTMLElement | null = null; private notificationBarIframeElement: HTMLIFrameElement | null = null; diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts index f6afaae202f..65f9eee1ecb 100644 --- a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -16,9 +16,7 @@ import { } from "./autofill-constants"; import AutofillService from "./autofill.service"; -export class InlineMenuFieldQualificationService - implements InlineMenuFieldQualificationServiceInterface -{ +export class InlineMenuFieldQualificationService implements InlineMenuFieldQualificationServiceInterface { private searchFieldNamesSet = new Set(AutoFillConstants.SearchFieldNames); private excludedAutofillFieldTypesSet = new Set(AutoFillConstants.ExcludedAutofillLoginTypes); private usernameFieldTypes = new Set(["text", "email", "number", "tel"]); diff --git a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts index 19946ab590c..cf29370840d 100644 --- a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts +++ b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts @@ -43,9 +43,7 @@ export type NativeWindowObject = { windowXy?: { x: number; y: number }; }; -export class DesktopFido2UserInterfaceService - implements Fido2UserInterfaceServiceAbstraction -{ +export class DesktopFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { constructor( private authService: AuthService, private cipherService: CipherService, diff --git a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts index df5e7e8a25c..88797f86650 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service.ts @@ -35,13 +35,10 @@ import { OrganizationUserResetPasswordEntry } from "./organization-user-reset-pa @Injectable({ providedIn: "root", }) -export class OrganizationUserResetPasswordService - implements - UserKeyRotationKeyRecoveryProvider< - OrganizationUserResetPasswordWithIdRequest, - OrganizationUserResetPasswordEntry - > -{ +export class OrganizationUserResetPasswordService implements UserKeyRotationKeyRecoveryProvider< + OrganizationUserResetPasswordWithIdRequest, + OrganizationUserResetPasswordEntry +> { constructor( private keyService: KeyService, private encryptService: EncryptService, diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts index 7765d01f75c..3fc57e1a22c 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts @@ -39,9 +39,7 @@ import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service /** * Service for managing WebAuthnLogin credentials. */ -export class WebauthnLoginAdminService - implements UserKeyRotationDataProvider -{ +export class WebauthnLoginAdminService implements UserKeyRotationDataProvider { static readonly MaxCredentialCount = 5; private navigatorCredentials: CredentialsContainer; diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts index b91bc932e83..80b1b27116b 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts @@ -45,13 +45,10 @@ import { EmergencyAccessGranteeDetailsResponse } from "../response/emergency-acc import { EmergencyAccessApiService } from "./emergency-access-api.service"; @Injectable() -export class EmergencyAccessService - implements - UserKeyRotationKeyRecoveryProvider< - EmergencyAccessWithIdRequest, - GranteeEmergencyAccessWithPublicKey - > -{ +export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvider< + EmergencyAccessWithIdRequest, + GranteeEmergencyAccessWithPublicKey +> { constructor( private emergencyAccessApiService: EmergencyAccessApiService, private apiService: ApiService, diff --git a/libs/angular/src/auth/device-management/default-device-management-component.service.ts b/libs/angular/src/auth/device-management/default-device-management-component.service.ts index 5089ba259a5..169ee6ce7b6 100644 --- a/libs/angular/src/auth/device-management/default-device-management-component.service.ts +++ b/libs/angular/src/auth/device-management/default-device-management-component.service.ts @@ -3,9 +3,7 @@ import { DeviceManagementComponentServiceAbstraction } from "./device-management /** * Default implementation of the device management component service */ -export class DefaultDeviceManagementComponentService - implements DeviceManagementComponentServiceAbstraction -{ +export class DefaultDeviceManagementComponentService implements DeviceManagementComponentServiceAbstraction { /** * Show header information in web client */ diff --git a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts index 5fefd3c3abb..4a9a37fd0de 100644 --- a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts +++ b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts @@ -3,9 +3,7 @@ import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval /** * Default implementation of the LoginApprovalDialogComponentServiceAbstraction. */ -export class DefaultLoginApprovalDialogComponentService - implements LoginApprovalDialogComponentServiceAbstraction -{ +export class DefaultLoginApprovalDialogComponentService implements LoginApprovalDialogComponentServiceAbstraction { /** * No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method. * @returns diff --git a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts index 1c50919d1cb..cf79c65e998 100644 --- a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts +++ b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts @@ -45,9 +45,7 @@ const VAULT_ROUTE = "/vault"; * if it is required by showing a UI prompt. It is only one means of triggering migrations, in case the user stays unlocked for a while, * or regularly logs in without a master-password, when the migrations do require a master-password to run. */ -export class DefaultEncryptedMigrationsSchedulerService - implements EncryptedMigrationsSchedulerService -{ +export class DefaultEncryptedMigrationsSchedulerService implements EncryptedMigrationsSchedulerService { isMigrating = false; url$: Observable; diff --git a/libs/auth/src/angular/new-device-verification/default-new-device-verification-component.service.ts b/libs/auth/src/angular/new-device-verification/default-new-device-verification-component.service.ts index 88ea652bc4b..4e7731a412b 100644 --- a/libs/auth/src/angular/new-device-verification/default-new-device-verification-component.service.ts +++ b/libs/auth/src/angular/new-device-verification/default-new-device-verification-component.service.ts @@ -1,8 +1,6 @@ import { NewDeviceVerificationComponentService } from "./new-device-verification-component.service"; -export class DefaultNewDeviceVerificationComponentService - implements NewDeviceVerificationComponentService -{ +export class DefaultNewDeviceVerificationComponentService implements NewDeviceVerificationComponentService { showBackButton() { return true; } diff --git a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/default-two-factor-auth-webauthn-component.service.ts b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/default-two-factor-auth-webauthn-component.service.ts index 3d3578c656e..545c57f2946 100644 --- a/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/default-two-factor-auth-webauthn-component.service.ts +++ b/libs/auth/src/angular/two-factor-auth/child-components/two-factor-auth-webauthn/default-two-factor-auth-webauthn-component.service.ts @@ -1,8 +1,6 @@ import { TwoFactorAuthWebAuthnComponentService } from "./two-factor-auth-webauthn-component.service"; -export class DefaultTwoFactorAuthWebAuthnComponentService - implements TwoFactorAuthWebAuthnComponentService -{ +export class DefaultTwoFactorAuthWebAuthnComponentService implements TwoFactorAuthWebAuthnComponentService { /** * Default implementation is to not open in a new tab. */ diff --git a/libs/auth/src/common/services/user-decryption-options/user-decryption-options.service.ts b/libs/auth/src/common/services/user-decryption-options/user-decryption-options.service.ts index a0075d1987b..09d73f0224d 100644 --- a/libs/auth/src/common/services/user-decryption-options/user-decryption-options.service.ts +++ b/libs/auth/src/common/services/user-decryption-options/user-decryption-options.service.ts @@ -19,9 +19,7 @@ export const USER_DECRYPTION_OPTIONS = new UserKeyDefinition { diff --git a/libs/common/src/admin-console/services/organization-management-preferences/default-organization-management-preferences.service.ts b/libs/common/src/admin-console/services/organization-management-preferences/default-organization-management-preferences.service.ts index e257b691638..55af4b0bf29 100644 --- a/libs/common/src/admin-console/services/organization-management-preferences/default-organization-management-preferences.service.ts +++ b/libs/common/src/admin-console/services/organization-management-preferences/default-organization-management-preferences.service.ts @@ -27,9 +27,7 @@ function buildKeyDefinition(key: string): UserKeyDefinition { export const AUTO_CONFIRM_FINGERPRINTS = buildKeyDefinition("autoConfirmFingerPrints"); -export class DefaultOrganizationManagementPreferencesService - implements OrganizationManagementPreferencesService -{ +export class DefaultOrganizationManagementPreferencesService implements OrganizationManagementPreferencesService { constructor(private stateProvider: StateProvider) {} autoConfirmFingerPrints = this.buildOrganizationManagementPreference( diff --git a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts index 55644009f16..a1464ed9c9b 100644 --- a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts +++ b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.ts @@ -22,9 +22,7 @@ import { UserKey } from "../../types/key"; import { AccountService } from "../abstractions/account.service"; import { PasswordResetEnrollmentServiceAbstraction } from "../abstractions/password-reset-enrollment.service.abstraction"; -export class PasswordResetEnrollmentServiceImplementation - implements PasswordResetEnrollmentServiceAbstraction -{ +export class PasswordResetEnrollmentServiceImplementation implements PasswordResetEnrollmentServiceAbstraction { constructor( protected organizationApiService: OrganizationApiServiceAbstraction, protected accountService: AccountService, diff --git a/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts b/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts index de0ff302737..dea3979ac2d 100644 --- a/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts +++ b/libs/common/src/billing/services/organization/organization-sponsorship-api.service.ts @@ -4,9 +4,7 @@ import { PlatformUtilsService } from "../../../platform/abstractions/platform-ut import { OrganizationSponsorshipApiServiceAbstraction } from "../../abstractions/organizations/organization-sponsorship-api.service.abstraction"; import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response"; -export class OrganizationSponsorshipApiService - implements OrganizationSponsorshipApiServiceAbstraction -{ +export class OrganizationSponsorshipApiService implements OrganizationSponsorshipApiServiceAbstraction { constructor( private apiService: ApiService, private platformUtilsService: PlatformUtilsService, diff --git a/libs/common/src/platform/ipc/ipc-message.ts b/libs/common/src/platform/ipc/ipc-message.ts index c3ac6360597..ede68e170c5 100644 --- a/libs/common/src/platform/ipc/ipc-message.ts +++ b/libs/common/src/platform/ipc/ipc-message.ts @@ -5,8 +5,10 @@ export interface IpcMessage { message: SerializedOutgoingMessage; } -export interface SerializedOutgoingMessage - extends Omit { +export interface SerializedOutgoingMessage extends Omit< + OutgoingMessage, + typeof Symbol.dispose | "free" | "payload" +> { payload: number[]; } diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index e560a77cc2e..d1081e9f7b2 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -46,9 +46,9 @@ const KeyUsages: KeyUsage[] = ["sign"]; * * It is highly recommended that the W3C specification is used a reference when reading this code. */ -export class Fido2AuthenticatorService - implements Fido2AuthenticatorServiceAbstraction -{ +export class Fido2AuthenticatorService< + ParentWindowReference, +> implements Fido2AuthenticatorServiceAbstraction { constructor( private cipherService: CipherService, private userInterface: Fido2UserInterfaceService, diff --git a/libs/common/src/platform/services/fido2/fido2-client.service.ts b/libs/common/src/platform/services/fido2/fido2-client.service.ts index 08c0a265100..503ffef8241 100644 --- a/libs/common/src/platform/services/fido2/fido2-client.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-client.service.ts @@ -47,9 +47,9 @@ import { guidToRawFormat } from "./guid-utils"; * * It is highly recommended that the W3C specification is used a reference when reading this code. */ -export class Fido2ClientService - implements Fido2ClientServiceAbstraction -{ +export class Fido2ClientService< + ParentWindowReference, +> implements Fido2ClientServiceAbstraction { private timeoutAbortController: AbortController; private readonly TIMEOUTS = { NO_VERIFICATION: { diff --git a/libs/common/src/tools/state/secret-classifier.ts b/libs/common/src/tools/state/secret-classifier.ts index e961ffcd20e..ddb4bc2c63a 100644 --- a/libs/common/src/tools/state/secret-classifier.ts +++ b/libs/common/src/tools/state/secret-classifier.ts @@ -14,9 +14,11 @@ import { Classifier } from "./classifier"; * Data that cannot be serialized by JSON.stringify() should * be excluded. */ -export class SecretClassifier - implements Classifier<Plaintext, Disclosed, Secret> -{ +export class SecretClassifier<Plaintext extends object, Disclosed, Secret> implements Classifier< + Plaintext, + Disclosed, + Secret +> { private constructor( disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[], excluded: readonly (keyof Plaintext)[], diff --git a/libs/common/src/tools/state/secret-state.ts b/libs/common/src/tools/state/secret-state.ts index 91f45a51211..0aa808d2cb7 100644 --- a/libs/common/src/tools/state/secret-state.ts +++ b/libs/common/src/tools/state/secret-state.ts @@ -25,9 +25,13 @@ const ONE_MINUTE = 1000 * 60; * * DO NOT USE THIS for synchronized data. */ -export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret> - implements SingleUserState<Outer> -{ +export class SecretState< + Outer, + Id, + Plaintext extends object, + Disclosed, + Secret, +> implements SingleUserState<Outer> { // The constructor is private to avoid creating a circular dependency when // wiring the derived and secret states together. private constructor( diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index e6b66d8f699..f9cdf87ea04 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -79,11 +79,11 @@ const DEFAULT_FRAME_SIZE = 32; * @template Dependencies use-specific dependencies provided by the user. */ export class UserStateSubject< - State extends object, - Secret = State, - Disclosed = Record<string, never>, - Dependencies = null, - > + State extends object, + Secret = State, + Disclosed = Record<string, never>, + Dependencies = null, +> extends Observable<State> implements SubjectLike<State> { diff --git a/libs/components/src/dialog/dialog.service.ts b/libs/components/src/dialog/dialog.service.ts index 1fc452418e1..804b650beab 100644 --- a/libs/components/src/dialog/dialog.service.ts +++ b/libs/components/src/dialog/dialog.service.ts @@ -43,9 +43,10 @@ class CustomBlockScrollStrategy implements ScrollStrategy { detach() {} } -export abstract class DialogRef<R = unknown, C = unknown> - implements Pick<CdkDialogRef<R, C>, "close" | "closed" | "disableClose" | "componentInstance"> -{ +export abstract class DialogRef<R = unknown, C = unknown> implements Pick< + CdkDialogRef<R, C>, + "close" | "closed" | "disableClose" | "componentInstance" +> { abstract readonly isDrawer?: boolean; // --- From CdkDialogRef --- diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration-api.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration-api.service.ts index a79bcfd0ef3..37410b9fffb 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration-api.service.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration-api.service.ts @@ -4,9 +4,7 @@ import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-st import { UserAsymmetricKeysRegenerationApiService } from "../abstractions/user-asymmetric-key-regeneration-api.service"; import { KeyRegenerationRequest } from "../models/requests/key-regeneration.request"; -export class DefaultUserAsymmetricKeysRegenerationApiService - implements UserAsymmetricKeysRegenerationApiService -{ +export class DefaultUserAsymmetricKeysRegenerationApiService implements UserAsymmetricKeysRegenerationApiService { constructor(private apiService: ApiService) {} async regenerateUserAsymmetricKeys( diff --git a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts index 48fe3a1686f..36bf9c8a421 100644 --- a/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts +++ b/libs/key-management/src/user-asymmetric-key-regeneration/services/default-user-asymmetric-key-regeneration.service.ts @@ -15,9 +15,7 @@ import { KeyService } from "../../abstractions/key.service"; import { UserAsymmetricKeysRegenerationApiService } from "../abstractions/user-asymmetric-key-regeneration-api.service"; import { UserAsymmetricKeysRegenerationService } from "../abstractions/user-asymmetric-key-regeneration.service"; -export class DefaultUserAsymmetricKeysRegenerationService - implements UserAsymmetricKeysRegenerationService -{ +export class DefaultUserAsymmetricKeysRegenerationService implements UserAsymmetricKeysRegenerationService { constructor( private keyService: KeyService, private cipherService: CipherService, diff --git a/libs/state-internal/src/default-derived-state.ts b/libs/state-internal/src/default-derived-state.ts index ce84be93f92..60355dc033b 100644 --- a/libs/state-internal/src/default-derived-state.ts +++ b/libs/state-internal/src/default-derived-state.ts @@ -5,9 +5,11 @@ import { DeriveDefinition, DerivedState, DerivedStateDependencies } from "@bitwa /** * Default derived state */ -export class DefaultDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies> - implements DerivedState<TTo> -{ +export class DefaultDerivedState< + TFrom, + TTo, + TDeps extends DerivedStateDependencies, +> implements DerivedState<TTo> { private readonly storageKey: string; private forcedValueSubject = new Subject<TTo>(); diff --git a/libs/state-internal/src/inline-derived-state.ts b/libs/state-internal/src/inline-derived-state.ts index 2c03443d42c..cdbc329e050 100644 --- a/libs/state-internal/src/inline-derived-state.ts +++ b/libs/state-internal/src/inline-derived-state.ts @@ -17,9 +17,11 @@ export class InlineDerivedStateProvider implements DerivedStateProvider { } } -export class InlineDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies> - implements DerivedState<TTo> -{ +export class InlineDerivedState< + TFrom, + TTo, + TDeps extends DerivedStateDependencies, +> implements DerivedState<TTo> { constructor( parentState$: Observable<TFrom>, deriveDefinition: DeriveDefinition<TFrom, TTo, TDeps>, diff --git a/libs/state-test-utils/src/fake-state.ts b/libs/state-test-utils/src/fake-state.ts index 25aabcd9933..21cb5e7aa73 100644 --- a/libs/state-test-utils/src/fake-state.ts +++ b/libs/state-test-utils/src/fake-state.ts @@ -236,9 +236,11 @@ export class FakeActiveUserState<T> implements ActiveUserState<T> { } } -export class FakeDerivedState<TFrom, TTo, TDeps extends DerivedStateDependencies> - implements DerivedState<TTo> -{ +export class FakeDerivedState< + TFrom, + TTo, + TDeps extends DerivedStateDependencies, +> implements DerivedState<TTo> { // eslint-disable-next-line rxjs/no-exposed-subjects -- exposed for testing setup stateSubject = new ReplaySubject<TTo>(1); diff --git a/libs/tools/generator/core/src/engine/rpc/create-forwarding-address.ts b/libs/tools/generator/core/src/engine/rpc/create-forwarding-address.ts index 4d4a5ba2ded..3321d48b345 100644 --- a/libs/tools/generator/core/src/engine/rpc/create-forwarding-address.ts +++ b/libs/tools/generator/core/src/engine/rpc/create-forwarding-address.ts @@ -7,8 +7,7 @@ import { ForwarderContext } from "../forwarder-context"; export class CreateForwardingAddressRpc< Settings extends ApiSettings, Req extends IntegrationRequest = IntegrationRequest, -> implements JsonRpc<Req, string> -{ +> implements JsonRpc<Req, string> { constructor( readonly requestor: ForwarderConfiguration<Settings>, readonly context: ForwarderContext<Settings>, diff --git a/libs/tools/generator/core/src/engine/rpc/get-account-id.ts b/libs/tools/generator/core/src/engine/rpc/get-account-id.ts index 751220fc216..19550967835 100644 --- a/libs/tools/generator/core/src/engine/rpc/get-account-id.ts +++ b/libs/tools/generator/core/src/engine/rpc/get-account-id.ts @@ -9,8 +9,7 @@ import { ForwarderContext } from "../forwarder-context"; export class GetAccountIdRpc< Settings extends ApiSettings, Req extends IntegrationRequest = IntegrationRequest, -> implements JsonRpc<Req, string> -{ +> implements JsonRpc<Req, string> { constructor( readonly requestor: ForwarderConfiguration<Settings>, readonly context: ForwarderContext<Settings>, diff --git a/libs/tools/generator/core/src/policies/default-policy-evaluator.ts b/libs/tools/generator/core/src/policies/default-policy-evaluator.ts index 384b3bc1aeb..2d2ce48aec8 100644 --- a/libs/tools/generator/core/src/policies/default-policy-evaluator.ts +++ b/libs/tools/generator/core/src/policies/default-policy-evaluator.ts @@ -2,9 +2,10 @@ import { PolicyEvaluator } from "../abstractions"; import { NoPolicy } from "../types"; /** A policy evaluator that does not apply any policy */ -export class DefaultPolicyEvaluator<PolicyTarget> - implements PolicyEvaluator<NoPolicy, PolicyTarget> -{ +export class DefaultPolicyEvaluator<PolicyTarget> implements PolicyEvaluator< + NoPolicy, + PolicyTarget +> { /** {@link PolicyEvaluator.policy} */ get policy() { return {}; diff --git a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts index b1e582637e4..32a6099d6cc 100644 --- a/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts +++ b/libs/tools/generator/core/src/policies/dynamic-password-policy-constraints.ts @@ -13,9 +13,7 @@ import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne, Zero } from " import { PasswordPolicyConstraints } from "./password-policy-constraints"; /** Creates state constraints by blending policy and password settings. */ -export class DynamicPasswordPolicyConstraints - implements DynamicStateConstraints<PasswordGeneratorSettings> -{ +export class DynamicPasswordPolicyConstraints implements DynamicStateConstraints<PasswordGeneratorSettings> { /** Instantiates the object. * @param policy the password policy to enforce. This cannot be * `null` or `undefined`. diff --git a/libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts b/libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts index 36365fc338f..a093aa164f1 100644 --- a/libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts @@ -13,9 +13,10 @@ import { observe$PerUserId, sharedStateByUserId } from "../util"; import { CATCHALL_SETTINGS } from "./storage"; /** Strategy for creating usernames using a catchall email address */ -export class CatchallGeneratorStrategy - implements GeneratorStrategy<CatchallGenerationOptions, NoPolicy> -{ +export class CatchallGeneratorStrategy implements GeneratorStrategy< + CatchallGenerationOptions, + NoPolicy +> { /** Instantiates the generation strategy * @param usernameService generates a catchall address for a domain */ diff --git a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts index ebeacef81e8..8c75d2d2a34 100644 --- a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts @@ -16,9 +16,10 @@ const UsernameDigits = Object.freeze({ }); /** Strategy for creating usernames from the EFF wordlist */ -export class EffUsernameGeneratorStrategy - implements GeneratorStrategy<EffUsernameGenerationOptions, NoPolicy> -{ +export class EffUsernameGeneratorStrategy implements GeneratorStrategy< + EffUsernameGenerationOptions, + NoPolicy +> { /** Instantiates the generation strategy * @param usernameService generates a username from EFF word list */ diff --git a/libs/tools/generator/core/src/strategies/options-classifier.ts b/libs/tools/generator/core/src/strategies/options-classifier.ts index 672b5e5cc65..9ed98d71c96 100644 --- a/libs/tools/generator/core/src/strategies/options-classifier.ts +++ b/libs/tools/generator/core/src/strategies/options-classifier.ts @@ -10,8 +10,7 @@ import { Classifier } from "@bitwarden/common/tools/state/classifier"; export class OptionsClassifier< Settings, Options extends IntegrationRequest & Settings = IntegrationRequest & Settings, -> implements Classifier<Options, Record<string, never>, Settings> -{ +> implements Classifier<Options, Record<string, never>, Settings> { /** Partitions `secret` into its disclosed properties and secret properties. * @param value The object to partition * @returns an object that classifies secrets. diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts index 374df84a5bd..be2984e09e1 100644 --- a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts @@ -14,9 +14,10 @@ import { observe$PerUserId, optionsToEffWordListRequest, sharedStateByUserId } f import { PASSPHRASE_SETTINGS } from "./storage"; /** Generates passphrases composed of random words */ -export class PassphraseGeneratorStrategy - implements GeneratorStrategy<PassphraseGenerationOptions, PassphraseGeneratorPolicy> -{ +export class PassphraseGeneratorStrategy implements GeneratorStrategy< + PassphraseGenerationOptions, + PassphraseGeneratorPolicy +> { /** instantiates the password generator strategy. * @param legacy generates the passphrase * @param stateProvider provides durable state diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts index 1a5070901c2..ab8a04cf79b 100644 --- a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts @@ -12,9 +12,10 @@ import { observe$PerUserId, optionsToRandomAsciiRequest, sharedStateByUserId } f import { PASSWORD_SETTINGS } from "./storage"; /** Generates passwords composed of random characters */ -export class PasswordGeneratorStrategy - implements GeneratorStrategy<PasswordGenerationOptions, PasswordGeneratorPolicy> -{ +export class PasswordGeneratorStrategy implements GeneratorStrategy< + PasswordGenerationOptions, + PasswordGeneratorPolicy +> { /** instantiates the password generator strategy. * @param legacy generates the password */ diff --git a/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts b/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts index 86df7f1c667..f0c7c482060 100644 --- a/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts @@ -17,9 +17,10 @@ import { SUBADDRESS_SETTINGS } from "./storage"; * For example, if the email address is `jd+xyz@domain.io`, * the subaddress is `xyz`. */ -export class SubaddressGeneratorStrategy - implements GeneratorStrategy<SubaddressGenerationOptions, NoPolicy> -{ +export class SubaddressGeneratorStrategy implements GeneratorStrategy< + SubaddressGenerationOptions, + NoPolicy +> { /** Instantiates the generation strategy * @param usernameService generates an email subaddress from an email address */ diff --git a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts index 5446c1f26ad..e5b1ab87817 100644 --- a/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts +++ b/libs/tools/generator/extensions/navigation/src/generator-navigation-evaluator.ts @@ -8,9 +8,10 @@ import { GeneratorNavigationPolicy } from "./generator-navigation-policy"; /** Enforces policy for generator navigation options. */ -export class GeneratorNavigationEvaluator - implements PolicyEvaluator<GeneratorNavigationPolicy, GeneratorNavigation> -{ +export class GeneratorNavigationEvaluator implements PolicyEvaluator< + GeneratorNavigationPolicy, + GeneratorNavigation +> { /** Instantiates the evaluator. * @param policy The policy applied by the evaluator. When this conflicts with * the defaults, the policy takes precedence. diff --git a/package-lock.json b/package-lock.json index a86f728f21d..9e4b320a6f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -160,7 +160,7 @@ "path-browserify": "1.0.1", "postcss": "8.5.6", "postcss-loader": "8.2.0", - "prettier": "3.6.2", + "prettier": "3.7.3", "prettier-plugin-tailwindcss": "0.7.1", "process": "0.11.10", "remark-gfm": "4.0.1", @@ -35983,9 +35983,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz", + "integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 9cb65a6e1db..380d76727e0 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "path-browserify": "1.0.1", "postcss": "8.5.6", "postcss-loader": "8.2.0", - "prettier": "3.6.2", + "prettier": "3.7.3", "prettier-plugin-tailwindcss": "0.7.1", "process": "0.11.10", "remark-gfm": "4.0.1", From cdeacf2a77ea5ce924d0fac8be6d82a179be1ef5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:14:12 +0000 Subject: [PATCH 31/60] [deps]: Update Rust crate ashpd to v0.12.0 (#16420) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 23 +++-------------------- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 6536ccaa7af..53eee09d9b8 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -121,23 +121,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "ashpd" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" -dependencies = [ - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.1", - "serde", - "serde_repr", - "tokio", - "url", - "zbus", -] - [[package]] name = "ashpd" version = "0.12.0" @@ -833,7 +816,7 @@ dependencies = [ "aes", "anyhow", "arboard", - "ashpd 0.11.0", + "ashpd", "base64", "bitwarden-russh", "bytes", @@ -1672,7 +1655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a793df0d7afeac54f95b471d3af7f0d4fb975699f972341a4b76988d49cdf0c" dependencies = [ "cfg-if", - "windows-targets 0.53.3", + "windows-targets 0.48.5", ] [[package]] @@ -2157,7 +2140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" dependencies = [ "aes", - "ashpd 0.12.0", + "ashpd", "cbc", "cipher", "digest", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 37584dca235..74a1b6bb1da 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -23,7 +23,7 @@ aes = "=0.8.4" aes-gcm = "=0.10.3" anyhow = "=1.0.100" arboard = { version = "=3.6.1", default-features = false } -ashpd = "=0.11.0" +ashpd = "=0.12.0" base64 = "=0.22.1" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" } byteorder = "=1.5.0" From 6828b9374ac7b826e0b9320f7a517d657e4c38d0 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann <mail@quexten.com> Date: Wed, 10 Dec 2025 19:04:38 +0100 Subject: [PATCH 32/60] Fix cipher key decryption in TS code (#17907) --- libs/common/src/vault/models/domain/cipher.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 599b1c765e4..850952364ca 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -155,7 +155,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> { if (this.login != null) { model.login = await this.login.decrypt( bypassValidation, - userKeyOrOrgKey, + cipherDecryptionKey, `Cipher Id: ${this.id}`, ); } @@ -167,17 +167,20 @@ export class Cipher extends Domain implements Decryptable<CipherView> { break; case CipherType.Card: if (this.card != null) { - model.card = await this.card.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`); + model.card = await this.card.decrypt(cipherDecryptionKey, `Cipher Id: ${this.id}`); } break; case CipherType.Identity: if (this.identity != null) { - model.identity = await this.identity.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`); + model.identity = await this.identity.decrypt( + cipherDecryptionKey, + `Cipher Id: ${this.id}`, + ); } break; case CipherType.SshKey: if (this.sshKey != null) { - model.sshKey = await this.sshKey.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`); + model.sshKey = await this.sshKey.decrypt(cipherDecryptionKey, `Cipher Id: ${this.id}`); } break; default: @@ -188,7 +191,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> { const attachments: AttachmentView[] = []; for (const attachment of this.attachments) { const decryptedAttachment = await attachment.decrypt( - userKeyOrOrgKey, + cipherDecryptionKey, `Cipher Id: ${this.id}`, ); attachments.push(decryptedAttachment); @@ -199,7 +202,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> { if (this.fields != null && this.fields.length > 0) { const fields: FieldView[] = []; for (const field of this.fields) { - const decryptedField = await field.decrypt(userKeyOrOrgKey); + const decryptedField = await field.decrypt(cipherDecryptionKey); fields.push(decryptedField); } model.fields = fields; @@ -208,7 +211,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> { if (this.passwordHistory != null && this.passwordHistory.length > 0) { const passwordHistory: PasswordHistoryView[] = []; for (const ph of this.passwordHistory) { - const decryptedPh = await ph.decrypt(userKeyOrOrgKey); + const decryptedPh = await ph.decrypt(cipherDecryptionKey); passwordHistory.push(decryptedPh); } model.passwordHistory = passwordHistory; From 48941a36504e9cefd79a95fb33ad279846d0e057 Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:03:28 -0500 Subject: [PATCH 33/60] Fix Publish Web workflow (#17897) --- .github/workflows/publish-web.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index 62d9342cf61..fb1de5a1bc5 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -187,6 +187,8 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + owner: ${{ github.repository_owner }} + repositories: self-host - name: Trigger Bitwarden lite build uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 From d205580701fe1f8970ee353232fcd6156b53ab1d Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:45:06 -0600 Subject: [PATCH 34/60] Only show free for 1 year for SM standalone (#17914) --- .../organization-subscription-cloud.component.html | 9 ++------- .../organization-subscription-cloud.component.ts | 8 +++++--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 0666cca2c4b..8b9b98dc390 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -38,12 +38,7 @@ {{ i.amount | currency: "$" }} </td> <td bitCell class="tw-text-right"> - <ng-container - *ngIf=" - sub?.customerDiscount?.appliesTo?.includes(i.productId); - else calculateElse - " - > + <ng-container *ngIf="isSecretsManagerTrial(); else calculateElse"> {{ "freeForOneYear" | i18n }} </ng-container> <ng-template #calculateElse> @@ -52,7 +47,7 @@ {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }} </span> <span - *ngIf="customerDiscount?.percentOff && !isSecretsManagerTrial()" + *ngIf="customerDiscount?.percentOff" class="tw-line-through !tw-text-muted" >{{ calculateTotalAppliedDiscount(i.quantity * i.amount) | currency: "$" diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index e0c1a12a80f..de5d71cce5e 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -403,11 +403,13 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } isSecretsManagerTrial(): boolean { - return ( + const isSmStandalone = this.sub?.customerDiscount?.id === "sm-standalone"; + const appliesToProduct = this.sub?.subscription?.items?.some((item) => this.sub?.customerDiscount?.appliesTo?.includes(item.productId), - ) ?? false - ); + ) ?? false; + + return isSmStandalone && appliesToProduct; } closeChangePlan() { From 93640e65e3cf2236d17102ead53e17372fac9830 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:56:09 -0500 Subject: [PATCH 35/60] [deps] Autofill: Update @lit-labs/signals to v0.1.3 (#17539) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9e4b320a6f7..d3908e71266 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,7 +86,7 @@ "@electron/notarize": "3.0.1", "@electron/rebuild": "4.0.1", "@eslint/compat": "2.0.0", - "@lit-labs/signals": "0.1.2", + "@lit-labs/signals": "0.1.3", "@ngtools/webpack": "20.3.12", "@storybook/addon-a11y": "9.1.16", "@storybook/addon-designs": "9.0.0-next.3", @@ -8586,9 +8586,9 @@ "license": "BSD-3-Clause" }, "node_modules/@lit-labs/signals": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@lit-labs/signals/-/signals-0.1.2.tgz", - "integrity": "sha512-hkOL0ua4ILeHlaJ8IqFKS+Y+dpYznWaDhdikzwt3zJ1/LPz3Etft4OPIMoltzbBJS5pyXPRseD/uWRlET3ImEA==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@lit-labs/signals/-/signals-0.1.3.tgz", + "integrity": "sha512-P0yWgH5blwVyEwBg+WFspLzeu1i0ypJP1QB0l1Omr9qZLIPsUu0p4Fy2jshOg7oQyha5n163K3GJGeUhQQ682Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index 380d76727e0..128b413704b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@electron/notarize": "3.0.1", "@electron/rebuild": "4.0.1", "@eslint/compat": "2.0.0", - "@lit-labs/signals": "0.1.2", + "@lit-labs/signals": "0.1.3", "@ngtools/webpack": "20.3.12", "@storybook/addon-a11y": "9.1.16", "@storybook/addon-designs": "9.0.0-next.3", From fe4895d97e64ba54efb4972399b9583dc774bd3b Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:24:20 -0600 Subject: [PATCH 36/60] [PM-28264] Consolidate and update the UI for key connector migration/confirmation (#17642) * Consolidate the RemovePasswordComponent * Add getting confirmation details for confirm key connector * Add missing message --- apps/browser/src/_locales/en/messages.json | 42 +++++++++- .../remove-password.component.html | 51 ------------ .../remove-password.component.ts | 14 ---- apps/browser/src/popup/app-routing.module.ts | 24 +++++- apps/browser/src/popup/app.module.ts | 9 +-- apps/desktop/src/app/app-routing.module.ts | 25 ++++-- apps/desktop/src/app/app.module.ts | 2 - .../remove-password.component.html | 20 ----- .../remove-password.component.ts | 12 --- apps/desktop/src/locales/en/messages.json | 42 +++++++++- .../remove-password.component.html | 37 --------- .../remove-password.component.ts | 12 --- apps/web/src/app/oss-routing.module.ts | 11 ++- .../src/app/shared/loose-components.module.ts | 3 - apps/web/src/locales/en/messages.json | 42 +++++++++- .../src/services/jslib-services.module.ts | 7 ++ .../two-factor-auth.component.spec.ts | 1 + .../abstractions/key-connector-api.service.ts | 7 ++ .../key-connector-domain-confirmation.ts | 1 + ...connector-confirmation-details.response.ts | 10 +++ .../default-key-connector-api.service.spec.ts | 54 +++++++++++++ .../default-key-connector-api.service.ts | 20 +++++ .../services/key-connector.service.spec.ts | 5 +- .../services/key-connector.service.ts | 13 ++- ...onfirm-key-connector-domain.component.html | 29 +++++-- ...irm-key-connector-domain.component.spec.ts | 79 ++++++++++++++++++- .../confirm-key-connector-domain.component.ts | 61 +++++++++++++- .../remove-password.component.html | 35 ++++++++ .../remove-password.component.spec.ts | 2 + .../remove-password.component.ts | 32 ++++++-- 30 files changed, 496 insertions(+), 206 deletions(-) delete mode 100644 apps/browser/src/key-management/key-connector/remove-password.component.html delete mode 100644 apps/browser/src/key-management/key-connector/remove-password.component.ts delete mode 100644 apps/desktop/src/key-management/key-connector/remove-password.component.html delete mode 100644 apps/desktop/src/key-management/key-connector/remove-password.component.ts delete mode 100644 apps/web/src/app/key-management/key-connector/remove-password.component.html delete mode 100644 apps/web/src/app/key-management/key-connector/remove-password.component.ts create mode 100644 libs/common/src/key-management/key-connector/abstractions/key-connector-api.service.ts create mode 100644 libs/common/src/key-management/key-connector/models/response/key-connector-confirmation-details.response.ts create mode 100644 libs/common/src/key-management/key-connector/services/default-key-connector-api.service.spec.ts create mode 100644 libs/common/src/key-management/key-connector/services/default-key-connector-api.service.ts create mode 100644 libs/key-management-ui/src/key-connector/remove-password.component.html diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a90fbcbf332..09ea964823c 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3252,9 +3252,6 @@ "copyCustomFieldNameNotUnique": { "message": "No unique identifier found." }, - "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." - }, "organizationName": { "message": "Organization name" }, @@ -5891,6 +5888,45 @@ "cardNumberLabel": { "message": "Card number" }, + "removeMasterPasswordForOrgUserKeyConnector":{ + "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." + }, + "continueWithLogIn": { + "message": "Continue with log in" + }, + "doNotContinue": { + "message": "Do not continue" + }, + "domain": { + "message": "Domain" + }, + "keyConnectorDomainTooltip": { + "message": "This domain will store your account encryption keys, so make sure you trust it. If you're not sure, check with your admin." + }, + "verifyYourOrganization": { + "message": "Verify your organization to log in" + }, + "organizationVerified":{ + "message": "Organization verified" + }, + "domainVerified":{ + "message": "Domain verified" + }, + "leaveOrganizationContent": { + "message": "If you don't verify your organization, your access to the organization will be revoked." + }, + "leaveNow": { + "message": "Leave now" + }, + "verifyYourDomainToLogin": { + "message": "Verify your domain to log in" + }, + "verifyYourDomainDescription": { + "message": "To continue with log in, verify this domain." + }, + "confirmKeyConnectorOrganizationUserDescription": { + "message": "To continue with log in, verify the organization and domain." + }, "sessionTimeoutSettingsAction": { "message": "Timeout action" }, diff --git a/apps/browser/src/key-management/key-connector/remove-password.component.html b/apps/browser/src/key-management/key-connector/remove-password.component.html deleted file mode 100644 index 427065e83f3..00000000000 --- a/apps/browser/src/key-management/key-connector/remove-password.component.html +++ /dev/null @@ -1,51 +0,0 @@ -<popup-page> - <popup-header slot="header" pageTitle="{{ 'removeMasterPassword' | i18n }}"> - <ng-container slot="end"> - <app-pop-out></app-pop-out> - </ng-container> - </popup-header> - - @if (loading) { - <div class="tw-text-center"> - <i - class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted" - title="{{ 'loading' | i18n }}" - aria-hidden="true" - ></i> - <span class="tw-sr-only">{{ "loading" | i18n }}</span> - </div> - } @else { - <p>{{ "removeMasterPasswordForOrganizationUserKeyConnector" | i18n }}</p> - <p class="tw-mb-0">{{ "organizationName" | i18n }}:</p> - <p class="tw-text-muted tw-mb-6">{{ organization.name }}</p> - <p class="tw-mb-0">{{ "keyConnectorDomain" | i18n }}:</p> - <p class="tw-text-muted tw-mb-6">{{ organization.keyConnectorUrl }}</p> - <button - type="button" - bitButton - buttonType="primary" - block - (click)="convert()" - [disabled]="action" - class="tw-mb-2" - > - <i - class="bwi bwi-spinner bwi-spin" - title="{{ 'loading' | i18n }}" - aria-hidden="true" - *ngIf="continuing" - ></i> - {{ "removeMasterPassword" | i18n }} - </button> - - <button type="button" bitButton block (click)="leave()" [disabled]="action"> - <i - class="bwi bwi-spinner bwi-spin" - title="{{ 'loading' | i18n }}" - aria-hidden="true" - *ngIf="leaving" - ></i> - {{ "leaveOrganization" | i18n }} - </button> - } -</popup-page> diff --git a/apps/browser/src/key-management/key-connector/remove-password.component.ts b/apps/browser/src/key-management/key-connector/remove-password.component.ts deleted file mode 100644 index c4077a1eca9..00000000000 --- a/apps/browser/src/key-management/key-connector/remove-password.component.ts +++ /dev/null @@ -1,14 +0,0 @@ -// FIXME (PM-22628): angular imports are forbidden in background -// eslint-disable-next-line no-restricted-imports -import { Component } from "@angular/core"; - -import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui"; - -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@Component({ - selector: "app-remove-password", - templateUrl: "remove-password.component.html", - standalone: false, -}) -export class RemovePasswordComponent extends BaseRemovePasswordComponent {} diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 48f06147cdf..eb64c076192 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -43,7 +43,11 @@ import { TwoFactorAuthGuard, } from "@bitwarden/auth/angular"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; -import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; +import { + LockComponent, + ConfirmKeyConnectorDomainComponent, + RemovePasswordComponent, +} from "@bitwarden/key-management-ui"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant"; @@ -59,7 +63,6 @@ import { NotificationsSettingsComponent } from "../autofill/popup/settings/notif import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component"; import { PhishingWarning } from "../dirt/phishing-detection/popup/phishing-warning.component"; import { ProtectedByComponent } from "../dirt/phishing-detection/popup/protected-by-component"; -import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import BrowserPopupUtils from "../platform/browser/browser-popup-utils"; import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service"; import { RouteCacheOptions } from "../platform/services/popup-view-cache-background.service"; @@ -188,9 +191,22 @@ const routes: Routes = [ }, { path: "remove-password", - component: RemovePasswordComponent, + component: ExtensionAnonLayoutWrapperComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, + children: [ + { + path: "", + component: RemovePasswordComponent, + data: { + pageTitle: { + key: "verifyYourOrganization", + }, + showBackButton: false, + pageIcon: LockIcon, + } satisfies ExtensionAnonLayoutWrapperData, + }, + ], }, { path: "view-cipher", @@ -646,7 +662,7 @@ const routes: Routes = [ component: ConfirmKeyConnectorDomainComponent, data: { pageTitle: { - key: "confirmKeyConnectorDomain", + key: "verifyYourOrganization", }, showBackButton: true, pageIcon: DomainIcon, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 71846cc6444..d178cee2fc3 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -28,7 +28,6 @@ import { CurrentAccountComponent } from "../auth/popup/account-switching/current import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; import { AutofillComponent } from "../autofill/popup/settings/autofill.component"; import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component"; -import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import { PopOutComponent } from "../platform/popup/components/pop-out.component"; import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.component"; import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component"; @@ -85,13 +84,7 @@ import "../platform/popup/locales"; CalloutModule, LinkModule, ], - declarations: [ - AppComponent, - ColorPasswordPipe, - ColorPasswordCountPipe, - TabsV2Component, - RemovePasswordComponent, - ], + declarations: [AppComponent, ColorPasswordPipe, ColorPasswordCountPipe, TabsV2Component], exports: [CalloutModule], providers: [CurrencyPipe, DatePipe], bootstrap: [AppComponent], diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index fdc421153e1..6077afa8c12 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -42,14 +42,17 @@ import { } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; -import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; +import { + LockComponent, + ConfirmKeyConnectorDomainComponent, + RemovePasswordComponent, +} from "@bitwarden/key-management-ui"; import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; import { reactiveUnlockVaultGuard } from "../autofill/guards/reactive-vault-guard"; import { Fido2CreateComponent } from "../autofill/modal/credentials/fido2-create.component"; import { Fido2ExcludedCiphersComponent } from "../autofill/modal/credentials/fido2-excluded-ciphers.component"; import { Fido2VaultComponent } from "../autofill/modal/credentials/fido2-vault.component"; -import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import { VaultV2Component } from "../vault/app/vault/vault-v2.component"; import { VaultComponent } from "../vault/app/vault-v3/vault.component"; @@ -117,11 +120,6 @@ const routes: Routes = [ component: SendComponent, canActivate: [authGuard], }, - { - path: "remove-password", - component: RemovePasswordComponent, - canActivate: [authGuard], - }, { path: "fido2-assertion", component: Fido2VaultComponent, @@ -327,13 +325,24 @@ const routes: Routes = [ pageIcon: LockIcon, } satisfies AnonLayoutWrapperData, }, + { + path: "remove-password", + component: RemovePasswordComponent, + canActivate: [authGuard], + data: { + pageTitle: { + key: "verifyYourOrganization", + }, + pageIcon: LockIcon, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + }, { path: "confirm-key-connector-domain", component: ConfirmKeyConnectorDomainComponent, canActivate: [], data: { pageTitle: { - key: "confirmKeyConnectorDomain", + key: "verifyYourOrganization", }, pageIcon: DomainIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index 4f53e587994..31131c6202a 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -15,7 +15,6 @@ import { DeleteAccountComponent } from "../auth/delete-account.component"; import { LoginModule } from "../auth/login/login.module"; import { SshAgentService } from "../autofill/services/ssh-agent.service"; import { PremiumComponent } from "../billing/app/accounts/premium.component"; -import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import { VaultFilterModule } from "../vault/app/vault/vault-filter/vault-filter.module"; import { VaultV2Component } from "../vault/app/vault/vault-v2.component"; @@ -50,7 +49,6 @@ import { SharedModule } from "./shared/shared.module"; ColorPasswordCountPipe, HeaderComponent, PremiumComponent, - RemovePasswordComponent, SearchComponent, ], providers: [SshAgentService], diff --git a/apps/desktop/src/key-management/key-connector/remove-password.component.html b/apps/desktop/src/key-management/key-connector/remove-password.component.html deleted file mode 100644 index 5276e00c531..00000000000 --- a/apps/desktop/src/key-management/key-connector/remove-password.component.html +++ /dev/null @@ -1,20 +0,0 @@ -<div id="remove-password-page" *ngIf="!loading"> - <div class="content"> - <h1>{{ "removeMasterPassword" | i18n }}</h1> - <p>{{ "removeMasterPasswordForOrganizationUserKeyConnector" | i18n }}</p> - <p class="tw-mb-0">{{ "organizationName" | i18n }}:</p> - <p class="tw-text-muted tw-mb-6">{{ organization.name }}</p> - <p class="tw-mb-0">{{ "keyConnectorDomain" | i18n }}:</p> - <p class="tw-text-muted tw-mb-6">{{ organization.keyConnectorUrl }}</p> - <div class="buttons"> - <button type="submit" class="btn primary block" [disabled]="action" (click)="convert()"> - <b [hidden]="continuing">{{ "removeMasterPassword" | i18n }}</b> - <i class="bwi bwi-spinner bwi-spin" [hidden]="!continuing" aria-hidden="true"></i> - </button> - <button type="button" class="btn secondary block" [disabled]="action" (click)="leave()"> - <b [hidden]="leaving">{{ "leaveOrganization" | i18n }}</b> - <i class="bwi bwi-spinner bwi-spin" [hidden]="!leaving" aria-hidden="true"></i> - </button> - </div> - </div> -</div> diff --git a/apps/desktop/src/key-management/key-connector/remove-password.component.ts b/apps/desktop/src/key-management/key-connector/remove-password.component.ts deleted file mode 100644 index d9fea9409f8..00000000000 --- a/apps/desktop/src/key-management/key-connector/remove-password.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component } from "@angular/core"; - -import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui"; - -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@Component({ - selector: "app-remove-password", - templateUrl: "remove-password.component.html", - standalone: false, -}) -export class RemovePasswordComponent extends BaseRemovePasswordComponent {} diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 7a3abe528e8..48e346d9c68 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2637,9 +2637,6 @@ "removedMasterPassword": { "message": "Master password removed" }, - "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." - }, "organizationName": { "message": "Organization name" }, @@ -4337,6 +4334,45 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "removeMasterPasswordForOrgUserKeyConnector":{ + "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." + }, + "continueWithLogIn": { + "message": "Continue with log in" + }, + "doNotContinue": { + "message": "Do not continue" + }, + "domain": { + "message": "Domain" + }, + "keyConnectorDomainTooltip": { + "message": "This domain will store your account encryption keys, so make sure you trust it. If you're not sure, check with your admin." + }, + "verifyYourOrganization": { + "message": "Verify your organization to log in" + }, + "organizationVerified":{ + "message": "Organization verified" + }, + "domainVerified":{ + "message": "Domain verified" + }, + "leaveOrganizationContent": { + "message": "If you don't verify your organization, your access to the organization will be revoked." + }, + "leaveNow": { + "message": "Leave now" + }, + "verifyYourDomainToLogin": { + "message": "Verify your domain to log in" + }, + "verifyYourDomainDescription": { + "message": "To continue with log in, verify this domain." + }, + "confirmKeyConnectorOrganizationUserDescription": { + "message": "To continue with log in, verify the organization and domain." + }, "sessionTimeoutSettingsAction": { "message": "Timeout action" }, diff --git a/apps/web/src/app/key-management/key-connector/remove-password.component.html b/apps/web/src/app/key-management/key-connector/remove-password.component.html deleted file mode 100644 index aae660ce504..00000000000 --- a/apps/web/src/app/key-management/key-connector/remove-password.component.html +++ /dev/null @@ -1,37 +0,0 @@ -<div *ngIf="loading" class="tw-text-center"> - <i - class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted" - title="{{ 'loading' | i18n }}" - aria-hidden="true" - ></i> - <span class="tw-sr-only">{{ "loading" | i18n }}</span> -</div> - -<div *ngIf="!loading"> - <p>{{ "removeMasterPasswordForOrganizationUserKeyConnector" | i18n }}</p> - <p class="tw-mb-0">{{ "organizationName" | i18n }}:</p> - <p class="tw-text-muted tw-mb-6">{{ organization.name }}</p> - <p class="tw-mb-0">{{ "keyConnectorDomain" | i18n }}:</p> - <p class="tw-text-muted tw-mb-6">{{ organization.keyConnectorUrl }}</p> - - <button - bitButton - type="button" - buttonType="primary" - class="tw-w-full tw-mb-2" - [bitAction]="convert" - [block]="true" - > - {{ "removeMasterPassword" | i18n }} - </button> - <button - bitButton - type="button" - buttonType="secondary" - class="tw-w-full" - [bitAction]="leave" - [block]="true" - > - {{ "leaveOrganization" | i18n }} - </button> -</div> diff --git a/apps/web/src/app/key-management/key-connector/remove-password.component.ts b/apps/web/src/app/key-management/key-connector/remove-password.component.ts deleted file mode 100644 index d9fea9409f8..00000000000 --- a/apps/web/src/app/key-management/key-connector/remove-password.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component } from "@angular/core"; - -import { RemovePasswordComponent as BaseRemovePasswordComponent } from "@bitwarden/key-management-ui"; - -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@Component({ - selector: "app-remove-password", - templateUrl: "remove-password.component.html", - standalone: false, -}) -export class RemovePasswordComponent extends BaseRemovePasswordComponent {} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index ac9bdc4b946..e3c9da635f9 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -51,7 +51,7 @@ import { import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; -import { LockComponent } from "@bitwarden/key-management-ui"; +import { LockComponent, RemovePasswordComponent } from "@bitwarden/key-management-ui"; import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/guards/premium-interest-redirect/premium-interest-redirect.guard"; import { flagEnabled, Flags } from "../utils/flags"; @@ -80,7 +80,6 @@ import { RouteDataProperties } from "./core"; import { ReportsModule } from "./dirt/reports"; import { DataRecoveryComponent } from "./key-management/data-recovery/data-recovery.component"; import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component"; -import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component"; import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; import { UserLayoutComponent } from "./layouts/user-layout.component"; import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-landing/request-sm-access.component"; @@ -545,9 +544,9 @@ const routes: Routes = [ canActivate: [authGuard], data: { pageTitle: { - key: "removeMasterPassword", + key: "verifyYourOrganization", }, - titleId: "removeMasterPassword", + titleId: "verifyYourOrganization", pageIcon: LockIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, @@ -557,9 +556,9 @@ const routes: Routes = [ canActivate: [], data: { pageTitle: { - key: "confirmKeyConnectorDomain", + key: "verifyYourOrganization", }, - titleId: "confirmKeyConnectorDomain", + titleId: "verifyYourOrganization", pageIcon: DomainIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 0fff13f428c..f096ef6a292 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -7,7 +7,6 @@ import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.comp import { FreeBitwardenFamiliesComponent } from "../billing/members/free-bitwarden-families.component"; import { SponsoredFamiliesComponent } from "../billing/settings/sponsored-families.component"; import { SponsoringOrgRowComponent } from "../billing/settings/sponsoring-org-row.component"; -import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import { HeaderModule } from "../layouts/header/header.module"; import { OrganizationBadgeModule } from "../vault/individual-vault/organization-badge/organization-badge.module"; import { PipesModule } from "../vault/individual-vault/pipes/pipes.module"; @@ -21,7 +20,6 @@ import { SharedModule } from "./shared.module"; declarations: [ RecoverDeleteComponent, RecoverTwoFactorComponent, - RemovePasswordComponent, SponsoredFamiliesComponent, FreeBitwardenFamiliesComponent, SponsoringOrgRowComponent, @@ -31,7 +29,6 @@ import { SharedModule } from "./shared.module"; exports: [ RecoverDeleteComponent, RecoverTwoFactorComponent, - RemovePasswordComponent, SponsoredFamiliesComponent, VerifyEmailTokenComponent, VerifyRecoverDeleteComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index be2f72e34b0..3b0554547c5 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7136,9 +7136,6 @@ "invalidVerificationCode": { "message": "Invalid verification code" }, - "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." - }, "keyConnectorDomain": { "message": "Key Connector domain" }, @@ -12247,6 +12244,45 @@ } } }, + "removeMasterPasswordForOrgUserKeyConnector":{ + "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." + }, + "continueWithLogIn": { + "message": "Continue with log in" + }, + "doNotContinue": { + "message": "Do not continue" + }, + "domain": { + "message": "Domain" + }, + "keyConnectorDomainTooltip": { + "message": "This domain will store your account encryption keys, so make sure you trust it. If you're not sure, check with your admin." + }, + "verifyYourOrganization": { + "message": "Verify your organization to log in" + }, + "organizationVerified":{ + "message": "Organization verified" + }, + "domainVerified":{ + "message": "Domain verified" + }, + "leaveOrganizationContent": { + "message": "If you don't verify your organization, your access to the organization will be revoked." + }, + "leaveNow": { + "message": "Leave now" + }, + "verifyYourDomainToLogin": { + "message": "Verify your domain to log in" + }, + "verifyYourDomainDescription": { + "message": "To continue with log in, verify this domain." + }, + "confirmKeyConnectorOrganizationUserDescription": { + "message": "To continue with log in, verify the organization and domain." + }, "confirmNoSelectedCriticalApplicationsTitle": { "message": "No critical applications are selected" }, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6164c4e05d3..b26db7e9056 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -184,7 +184,9 @@ import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction"; import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service"; import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction"; +import { KeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector-api.service"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { DefaultKeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/services/default-key-connector-api.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction"; import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service"; @@ -1835,6 +1837,11 @@ const safeProviders: SafeProvider[] = [ useClass: IpcSessionRepository, deps: [StateProvider], }), + safeProvider({ + provide: KeyConnectorApiService, + useClass: DefaultKeyConnectorApiService, + deps: [ApiServiceAbstraction], + }), safeProvider({ provide: PremiumInterestStateService, useClass: NoopPremiumInterestStateService, diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts index 8c12060168b..9d7acd7d26e 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts @@ -421,6 +421,7 @@ describe("TwoFactorAuthComponent", () => { keyConnectorUrl: mockUserDecryptionOpts.noMasterPasswordWithKeyConnector.keyConnectorOption! .keyConnectorUrl, + organizationSsoIdentifier: "test-sso-id", }), ); const authResult = new AuthResult(); diff --git a/libs/common/src/key-management/key-connector/abstractions/key-connector-api.service.ts b/libs/common/src/key-management/key-connector/abstractions/key-connector-api.service.ts new file mode 100644 index 00000000000..10d55bfc3fb --- /dev/null +++ b/libs/common/src/key-management/key-connector/abstractions/key-connector-api.service.ts @@ -0,0 +1,7 @@ +import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response"; + +export abstract class KeyConnectorApiService { + abstract getConfirmationDetails( + orgSsoIdentifier: string, + ): Promise<KeyConnectorConfirmationDetailsResponse>; +} diff --git a/libs/common/src/key-management/key-connector/models/key-connector-domain-confirmation.ts b/libs/common/src/key-management/key-connector/models/key-connector-domain-confirmation.ts index 277057485c1..aa3596c1c99 100644 --- a/libs/common/src/key-management/key-connector/models/key-connector-domain-confirmation.ts +++ b/libs/common/src/key-management/key-connector/models/key-connector-domain-confirmation.ts @@ -1,3 +1,4 @@ export interface KeyConnectorDomainConfirmation { keyConnectorUrl: string; + organizationSsoIdentifier: string; } diff --git a/libs/common/src/key-management/key-connector/models/response/key-connector-confirmation-details.response.ts b/libs/common/src/key-management/key-connector/models/response/key-connector-confirmation-details.response.ts new file mode 100644 index 00000000000..bd6ce14194d --- /dev/null +++ b/libs/common/src/key-management/key-connector/models/response/key-connector-confirmation-details.response.ts @@ -0,0 +1,10 @@ +import { BaseResponse } from "../../../../models/response/base.response"; + +export class KeyConnectorConfirmationDetailsResponse extends BaseResponse { + organizationName: string; + + constructor(response: any) { + super(response); + this.organizationName = this.getResponseProperty("OrganizationName"); + } +} diff --git a/libs/common/src/key-management/key-connector/services/default-key-connector-api.service.spec.ts b/libs/common/src/key-management/key-connector/services/default-key-connector-api.service.spec.ts new file mode 100644 index 00000000000..553ce7a3ba0 --- /dev/null +++ b/libs/common/src/key-management/key-connector/services/default-key-connector-api.service.spec.ts @@ -0,0 +1,54 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "../../../abstractions/api.service"; +import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response"; + +import { DefaultKeyConnectorApiService } from "./default-key-connector-api.service"; + +describe("DefaultKeyConnectorApiService", () => { + let apiService: MockProxy<ApiService>; + let sut: DefaultKeyConnectorApiService; + + beforeEach(() => { + apiService = mock<ApiService>(); + sut = new DefaultKeyConnectorApiService(apiService); + }); + + describe("getConfirmationDetails", () => { + it("encodes orgSsoIdentifier in URL", async () => { + const orgSsoIdentifier = "test org/with special@chars"; + const expectedEncodedIdentifier = encodeURIComponent(orgSsoIdentifier); + const mockResponse = {}; + apiService.send.mockResolvedValue(mockResponse); + + await sut.getConfirmationDetails(orgSsoIdentifier); + + expect(apiService.send).toHaveBeenCalledWith( + "GET", + `/accounts/key-connector/confirmation-details/${expectedEncodedIdentifier}`, + null, + true, + true, + ); + }); + + it("returns expected response", async () => { + const orgSsoIdentifier = "test-org"; + const expectedOrgName = "example"; + const mockResponse = { OrganizationName: expectedOrgName }; + apiService.send.mockResolvedValue(mockResponse); + + const result = await sut.getConfirmationDetails(orgSsoIdentifier); + + expect(result).toBeInstanceOf(KeyConnectorConfirmationDetailsResponse); + expect(result.organizationName).toBe(expectedOrgName); + expect(apiService.send).toHaveBeenCalledWith( + "GET", + "/accounts/key-connector/confirmation-details/test-org", + null, + true, + true, + ); + }); + }); +}); diff --git a/libs/common/src/key-management/key-connector/services/default-key-connector-api.service.ts b/libs/common/src/key-management/key-connector/services/default-key-connector-api.service.ts new file mode 100644 index 00000000000..8bf0cdfed16 --- /dev/null +++ b/libs/common/src/key-management/key-connector/services/default-key-connector-api.service.ts @@ -0,0 +1,20 @@ +import { ApiService } from "../../../abstractions/api.service"; +import { KeyConnectorApiService } from "../abstractions/key-connector-api.service"; +import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response"; + +export class DefaultKeyConnectorApiService implements KeyConnectorApiService { + constructor(private apiService: ApiService) {} + + async getConfirmationDetails( + orgSsoIdentifier: string, + ): Promise<KeyConnectorConfirmationDetailsResponse> { + const r = await this.apiService.send( + "GET", + "/accounts/key-connector/confirmation-details/" + encodeURIComponent(orgSsoIdentifier), + null, + true, + true, + ); + return new KeyConnectorConfirmationDetailsResponse(r); + } +} diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts index bb458ff49f4..45b4f5e4ac6 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts @@ -603,7 +603,10 @@ describe("KeyConnectorService", () => { const data$ = keyConnectorService.requiresDomainConfirmation$(mockUserId); const data = await firstValueFrom(data$); - expect(data).toEqual({ keyConnectorUrl: conversion.keyConnectorUrl }); + expect(data).toEqual({ + keyConnectorUrl: conversion.keyConnectorUrl, + organizationSsoIdentifier: conversion.organizationId, + }); }); it("should return observable of null value when no data is set", async () => { diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.ts index f6730cf8870..8a75034cae1 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.ts @@ -202,9 +202,16 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { } requiresDomainConfirmation$(userId: UserId): Observable<KeyConnectorDomainConfirmation | null> { - return this.stateProvider - .getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId) - .pipe(map((data) => (data != null ? { keyConnectorUrl: data.keyConnectorUrl } : null))); + return this.stateProvider.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId).pipe( + map((data) => + data != null + ? { + keyConnectorUrl: data.keyConnectorUrl, + organizationSsoIdentifier: data.organizationId, + } + : null, + ), + ); } private handleKeyConnectorError(e: any) { diff --git a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.html b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.html index 11b34a8409f..b3bed15c698 100644 --- a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.html +++ b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.html @@ -8,17 +8,34 @@ <span class="tw-sr-only">{{ "loading" | i18n }}</span> </div> } @else { - <div class="tw-mb-4"> - <p class="tw-mb-1 tw-text-sm tw-font-medium">{{ "keyConnectorDomain" | i18n }}:</p> - <p class="tw-text-muted tw-break-all">{{ keyConnectorUrl }}</p> - </div> + @if (organizationName) { + <p>{{ "confirmKeyConnectorOrganizationUserDescription" | i18n }}</p> + + <p class="tw-mb-0 tw-font-bold">{{ "organization" | i18n }}</p> + <p class="tw-mb-6">{{ organizationName }}</p> + } @else { + <p>{{ "verifyYourDomainDescription" | i18n }}</p> + } + + <p class="tw-mb-0 tw-font-bold tw-inline-flex tw-items-center"> + {{ "domain" | i18n }} + <button + type="button" + [label]="'keyConnectorDomainTooltip' | i18n" + tooltipPosition="above-center" + bitIconButton="bwi-info-circle" + size="small" + ></button> + </p> + <p class="tw-mb-6 tw-font-mono">{{ keyConnectorHostName }}</p> <div class="tw-flex tw-flex-col tw-gap-2"> <button bitButton type="button" buttonType="primary" [bitAction]="confirm" [block]="true"> - {{ "confirm" | i18n }} + {{ "continueWithLogIn" | i18n }} </button> + <button bitButton type="button" buttonType="secondary" [bitAction]="cancel" [block]="true"> - {{ "cancel" | i18n }} + {{ "doNotContinue" | i18n }} </button> </div> } diff --git a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.spec.ts b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.spec.ts index b53b0a196f5..ad0b783eee3 100644 --- a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.spec.ts +++ b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.spec.ts @@ -2,13 +2,17 @@ import { Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { KeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector-api.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management/key-connector/models/key-connector-domain-confirmation"; +import { KeyConnectorConfirmationDetailsResponse } from "@bitwarden/common/key-management/key-connector/models/response/key-connector-confirmation-details.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { SyncService } from "@bitwarden/common/platform/sync"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components"; import { ConfirmKeyConnectorDomainComponent } from "./confirm-key-connector-domain.component"; @@ -16,8 +20,10 @@ describe("ConfirmKeyConnectorDomainComponent", () => { let component: ConfirmKeyConnectorDomainComponent; const userId = "test-user-id" as UserId; + const expectedHostName = "key-connector-url.com"; const confirmation: KeyConnectorDomainConfirmation = { keyConnectorUrl: "https://key-connector-url.com", + organizationSsoIdentifier: "org-sso-identifier", }; const mockRouter = mock<Router>(); @@ -25,6 +31,10 @@ describe("ConfirmKeyConnectorDomainComponent", () => { const mockKeyConnectorService = mock<KeyConnectorService>(); const mockLogService = mock<LogService>(); const mockMessagingService = mock<MessagingService>(); + const mockKeyConnectorApiService = mock<KeyConnectorApiService>(); + const mockToastService = mock<ToastService>(); + const mockI18nService = mock<I18nService>(); + const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>(); let mockAccountService = mockAccountServiceWith(userId); const onBeforeNavigation = jest.fn(); @@ -33,6 +43,8 @@ describe("ConfirmKeyConnectorDomainComponent", () => { mockAccountService = mockAccountServiceWith(userId); + mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`); + component = new ConfirmKeyConnectorDomainComponent( mockRouter, mockLogService, @@ -40,6 +52,10 @@ describe("ConfirmKeyConnectorDomainComponent", () => { mockMessagingService, mockSyncService, mockAccountService, + mockKeyConnectorApiService, + mockToastService, + mockI18nService, + mockAnonLayoutWrapperDataService, ); jest.spyOn(component, "onBeforeNavigation").mockImplementation(onBeforeNavigation); @@ -67,17 +83,41 @@ describe("ConfirmKeyConnectorDomainComponent", () => { expect(component.loading).toEqual(true); }); + it("sets organization name to undefined when getOrganizationName throws error", async () => { + mockKeyConnectorApiService.getConfirmationDetails.mockRejectedValue(new Error("API error")); + + await component.ngOnInit(); + + expect(component.organizationName).toBeUndefined(); + expect(component.userId).toEqual(userId); + expect(component.keyConnectorUrl).toEqual(confirmation.keyConnectorUrl); + expect(component.keyConnectorHostName).toEqual(expectedHostName); + expect(component.loading).toEqual(false); + expect(mockAnonLayoutWrapperDataService.setAnonLayoutWrapperData).toHaveBeenCalledWith({ + pageTitle: { key: "verifyYourDomainToLogin" }, + }); + }); + it("should set component properties correctly", async () => { + const expectedOrgName = "Test Organization"; + mockKeyConnectorApiService.getConfirmationDetails.mockResolvedValue({ + organizationName: expectedOrgName, + } as KeyConnectorConfirmationDetailsResponse); + await component.ngOnInit(); expect(component.userId).toEqual(userId); + expect(component.organizationName).toEqual(expectedOrgName); expect(component.keyConnectorUrl).toEqual(confirmation.keyConnectorUrl); + expect(component.keyConnectorHostName).toEqual(expectedHostName); expect(component.loading).toEqual(false); }); }); describe("confirm", () => { - it("should call keyConnectorService.convertNewSsoUserToKeyConnector with full sync and navigation to home page", async () => { + it("calls domain verified toast when organization name is not set", async () => { + mockKeyConnectorApiService.getConfirmationDetails.mockRejectedValue(new Error("API error")); + await component.ngOnInit(); await component.confirm(); @@ -94,6 +134,43 @@ describe("ConfirmKeyConnectorDomainComponent", () => { expect(mockSyncService.fullSync.mock.invocationCallOrder[0]).toBeLessThan( mockMessagingService.send.mock.invocationCallOrder[0], ); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "domainVerified-used-i18n", + }); + expect(mockMessagingService.send.mock.invocationCallOrder[0]).toBeLessThan( + onBeforeNavigation.mock.invocationCallOrder[0], + ); + expect(onBeforeNavigation.mock.invocationCallOrder[0]).toBeLessThan( + mockRouter.navigate.mock.invocationCallOrder[0], + ); + }); + + it("should call keyConnectorService.convertNewSsoUserToKeyConnector with full sync and navigation to home page", async () => { + mockKeyConnectorApiService.getConfirmationDetails.mockResolvedValue({ + organizationName: "Test Org Name", + } as KeyConnectorConfirmationDetailsResponse); + + await component.ngOnInit(); + + await component.confirm(); + + expect(mockKeyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(userId); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); + expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]); + expect(mockMessagingService.send).toHaveBeenCalledWith("loggedIn"); + expect(onBeforeNavigation).toHaveBeenCalled(); + + expect( + mockKeyConnectorService.convertNewSsoUserToKeyConnector.mock.invocationCallOrder[0], + ).toBeLessThan(mockSyncService.fullSync.mock.invocationCallOrder[0]); + expect(mockSyncService.fullSync.mock.invocationCallOrder[0]).toBeLessThan( + mockMessagingService.send.mock.invocationCallOrder[0], + ); + expect(mockToastService.showToast).toHaveBeenCalledWith({ + variant: "success", + message: "organizationVerified-used-i18n", + }); expect(mockMessagingService.send.mock.invocationCallOrder[0]).toBeLessThan( onBeforeNavigation.mock.invocationCallOrder[0], ); diff --git a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts index fe96e4620ad..aa65f4c43f9 100644 --- a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts +++ b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts @@ -5,12 +5,21 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { KeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector-api.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncService } from "@bitwarden/common/platform/sync"; import { UserId } from "@bitwarden/common/types/guid"; -import { BitActionDirective, ButtonModule } from "@bitwarden/components"; +import { + AnonLayoutWrapperDataService, + BitActionDirective, + ButtonModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -19,11 +28,13 @@ import { I18nPipe } from "@bitwarden/ui-common"; selector: "confirm-key-connector-domain", templateUrl: "confirm-key-connector-domain.component.html", standalone: true, - imports: [CommonModule, ButtonModule, I18nPipe, BitActionDirective], + imports: [CommonModule, ButtonModule, I18nPipe, BitActionDirective, IconButtonModule], }) export class ConfirmKeyConnectorDomainComponent implements OnInit { loading = true; keyConnectorUrl!: string; + keyConnectorHostName!: string; + organizationName: string | undefined; userId!: UserId; // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals @@ -37,6 +48,10 @@ export class ConfirmKeyConnectorDomainComponent implements OnInit { private messagingService: MessagingService, private syncService: SyncService, private accountService: AccountService, + private keyConnectorApiService: KeyConnectorApiService, + private toastService: ToastService, + private i18nService: I18nService, + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, ) {} async ngOnInit() { @@ -57,14 +72,36 @@ export class ConfirmKeyConnectorDomainComponent implements OnInit { return; } - this.keyConnectorUrl = confirmation.keyConnectorUrl; + this.organizationName = await this.getOrganizationName(confirmation.organizationSsoIdentifier); + // PM-29133 Remove during cleanup. + if (this.organizationName == undefined) { + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { key: "verifyYourDomainToLogin" }, + }); + } + + this.keyConnectorUrl = confirmation.keyConnectorUrl; + this.keyConnectorHostName = Utils.getHostname(confirmation.keyConnectorUrl); this.loading = false; } confirm = async () => { await this.keyConnectorService.convertNewSsoUserToKeyConnector(this.userId); + if (this.organizationName) { + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("organizationVerified"), + }); + } else { + // PM-29133 Remove during cleanup. + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("domainVerified"), + }); + } + await this.syncService.fullSync(true); this.messagingService.send("loggedIn"); @@ -77,4 +114,22 @@ export class ConfirmKeyConnectorDomainComponent implements OnInit { cancel = async () => { this.messagingService.send("logout"); }; + + private async getOrganizationName( + organizationSsoIdentifier: string, + ): Promise<string | undefined> { + try { + const details = + await this.keyConnectorApiService.getConfirmationDetails(organizationSsoIdentifier); + return details.organizationName; + } catch (error) { + // PM-29133 Remove during cleanup. + // Old self hosted servers may not have this endpoint yet. On error log a warning and continue without organization name. + this.logService.warning( + `[ConfirmKeyConnectorDomainComponent] Unable to get key connector confirmation details for organizationSsoIdentifier ${organizationSsoIdentifier}:`, + error, + ); + return undefined; + } + } } diff --git a/libs/key-management-ui/src/key-connector/remove-password.component.html b/libs/key-management-ui/src/key-connector/remove-password.component.html new file mode 100644 index 00000000000..42cee2fe168 --- /dev/null +++ b/libs/key-management-ui/src/key-connector/remove-password.component.html @@ -0,0 +1,35 @@ +@if (loading) { + <div class="tw-text-center"> + <i + class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted" + title="{{ 'loading' | i18n }}" + aria-hidden="true" + ></i> + <span class="tw-sr-only">{{ "loading" | i18n }}</span> + </div> +} @else { + <p>{{ "removeMasterPasswordForOrgUserKeyConnector" | i18n }}</p> + <p class="tw-mb-0 tw-font-bold">{{ "organization" | i18n }}</p> + <p class="tw-mb-6">{{ organization.name }}</p> + <p class="tw-mb-0 tw-font-bold tw-inline-flex tw-items-center"> + {{ "domain" | i18n }} + <button + type="button" + [label]="'keyConnectorDomainTooltip' | i18n" + tooltipPosition="above-center" + bitIconButton="bwi-info-circle" + size="small" + ></button> + </p> + <p class="tw-mb-6 tw-font-mono">{{ keyConnectorHostName }}</p> + + <div class="tw-flex tw-flex-col tw-gap-2"> + <button bitButton type="button" buttonType="primary" [block]="true" [bitAction]="convert"> + {{ "continueWithLogIn" | i18n }} + </button> + + <button bitButton type="button" buttonType="secondary" [block]="true" [bitAction]="leave"> + {{ "doNotContinue" | i18n }} + </button> + </div> +} diff --git a/libs/key-management-ui/src/key-connector/remove-password.component.spec.ts b/libs/key-management-ui/src/key-connector/remove-password.component.spec.ts index eb11932d931..240cddee2f9 100644 --- a/libs/key-management-ui/src/key-connector/remove-password.component.spec.ts +++ b/libs/key-management-ui/src/key-connector/remove-password.component.spec.ts @@ -19,6 +19,7 @@ describe("RemovePasswordComponent", () => { let component: RemovePasswordComponent; const userId = "test-user-id" as UserId; + const expectedHostName = "key-connector-url.com"; const organization = { id: "test-organization-id", name: "test-organization-name", @@ -62,6 +63,7 @@ describe("RemovePasswordComponent", () => { expect(component["activeUserId"]).toBe("test-user-id"); expect(component.organization).toEqual(organization); expect(component.loading).toEqual(false); + expect(component.keyConnectorHostName).toBe(expectedHostName); expect(mockKeyConnectorService.getManagingOrganization).toHaveBeenCalledWith(userId); expect(mockSyncService.fullSync).toHaveBeenCalledWith(false); diff --git a/libs/key-management-ui/src/key-connector/remove-password.component.ts b/libs/key-management-ui/src/key-connector/remove-password.component.ts index a31989ffc49..bb17475230d 100644 --- a/libs/key-management-ui/src/key-connector/remove-password.component.ts +++ b/libs/key-management-ui/src/key-connector/remove-password.component.ts @@ -1,4 +1,5 @@ -import { Directive, OnInit } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; import { Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; @@ -9,17 +10,33 @@ import { KeyConnectorService } from "@bitwarden/common/key-management/key-connec import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { + DialogService, + ToastService, + ButtonModule, + BitActionDirective, + IconButtonModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; -@Directive() +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "km-ui-remove-password", + templateUrl: "remove-password.component.html", + standalone: true, + imports: [CommonModule, ButtonModule, I18nPipe, BitActionDirective, IconButtonModule], +}) export class RemovePasswordComponent implements OnInit { continuing = false; leaving = false; loading = true; organization!: Organization; + keyConnectorHostName!: string; private activeUserId!: UserId; constructor( @@ -55,6 +72,7 @@ export class RemovePasswordComponent implements OnInit { await this.router.navigate(["/"]); return; } + this.keyConnectorHostName = Utils.getHostname(this.organization.keyConnectorUrl); this.loading = false; } @@ -73,7 +91,7 @@ export class RemovePasswordComponent implements OnInit { this.toastService.showToast({ variant: "success", - message: this.i18nService.t("removedMasterPassword"), + message: this.i18nService.t("organizationVerified"), }); await this.router.navigate(["/"]); @@ -86,9 +104,11 @@ export class RemovePasswordComponent implements OnInit { leave = async () => { const confirmed = await this.dialogService.openSimpleDialog({ - title: this.organization.name, - content: { key: "leaveOrganizationConfirmation" }, + title: { key: "leaveOrganization" }, + content: { key: "leaveOrganizationContent" }, type: "warning", + acceptButtonText: { key: "leaveNow" }, + cancelButtonText: { key: "cancel" }, }); if (!confirmed) { From f5cdee3fa6d747cf53c6c29571d9fa82dfe52c25 Mon Sep 17 00:00:00 2001 From: bmbitwarden <bmcferren@bitwarden.com> Date: Wed, 10 Dec 2025 20:03:40 -0500 Subject: [PATCH 37/60] PM-28180 responsively hide deletion date column in sends table (#17652) * PM-28180 responsively hide options in sends table * PM-28180 resolved pr comments * PM-28180 revert named container change * PM-28180 resolved pr comment re naming container * PM-28180 resolved double class issue --- apps/web/src/app/tools/send/send.component.html | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index b8538606aec..e593f5c1176 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -80,13 +80,15 @@ </div> </div> </div> - <div class="tw-col-span-9"> + <div class="tw-col-span-9 tw-@container/send-table"> <!--Listing Table--> <bit-table [dataSource]="dataSource" *ngIf="filteredSends && filteredSends.length"> <ng-container header> <tr> <th bitCell bitSortable="name" default>{{ "name" | i18n }}</th> - <th bitCell bitSortable="deletionDate">{{ "deletionDate" | i18n }}</th> + <th bitCell bitSortable="deletionDate" class="@lg/send-table:tw-table-cell tw-hidden"> + {{ "deletionDate" | i18n }} + </th> <th bitCell>{{ "options" | i18n }}</th> </tr> </ng-container> @@ -148,8 +150,14 @@ </ng-container> </div> </td> - <td bitCell class="tw-text-muted" (click)="editSend(s)" class="tw-cursor-pointer"> - <small bitTypography="body2" appStopProp>{{ s.deletionDate | date: "medium" }}</small> + <td + bitCell + (click)="editSend(s)" + class="tw-text-muted tw-cursor-pointer @lg/send-table:tw-table-cell tw-hidden" + > + <small bitTypography="body2" appStopProp> + {{ s.deletionDate | date: "medium" }} + </small> </td> <td bitCell class="tw-w-0 tw-text-right"> <button From 7183d77f7b2ed1bd21c0d5d8855c78afc6dc5854 Mon Sep 17 00:00:00 2001 From: Derek Nance <dnance@bitwarden.com> Date: Thu, 11 Dec 2025 02:03:49 -0600 Subject: [PATCH 38/60] Remove parse5 override (#17916) * chore(deps): Remove parse5 from Platform-owned deps This reverts commit 8182f6fa02a49d26fbc6dea0948b86dc044447a5. * chore(deps): Remove parse5 override This commit reverts c8eae5897e15f23ea330aa827f44c609ddd4d2ab. --- .github/renovate.json5 | 1 - package-lock.json | 69 ++++++++++++++++++++++++++++++++++++++++++ package.json | 1 - 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 997812735de..858dcccc094 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -197,7 +197,6 @@ "nx", "oo7", "oslog", - "parse5", "pin-project", "pkg", "postcss", diff --git a/package-lock.json b/package-lock.json index d3908e71266..dc8694f77b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2416,6 +2416,30 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/cdk/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@angular/cdk/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/@angular/cli": { "version": "20.3.12", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-20.3.12.tgz", @@ -12489,6 +12513,12 @@ "node": "*" } }, + "node_modules/@nx/webpack/node_modules/parse5": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", + "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==", + "license": "MIT" + }, "node_modules/@nx/webpack/node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -34644,6 +34674,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parse5-html-rewriting-stream/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parse5-htmlparser2-tree-adapter": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", @@ -34684,6 +34727,32 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-sax-parser/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-sax-parser/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parse5/node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", diff --git a/package.json b/package.json index 128b413704b..cd1262ac373 100644 --- a/package.json +++ b/package.json @@ -217,7 +217,6 @@ "eslint": "$eslint" }, "tailwindcss": "$tailwindcss", - "parse5": "7.3.0", "react": "18.3.1", "react-dom": "18.3.1", "@types/react": "18.3.27" From 267e4883903f3a91ce3f644c95e55dda5176e76f Mon Sep 17 00:00:00 2001 From: Oscar Hinton <Hinton@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:04:15 +0100 Subject: [PATCH 39/60] [BEEEP] [PM-28239] Migrate generators to standalone (#17386) * Migrate generators to use standalone and control flow * Resolve feedback * Add variable for account * Fix generators --- .../src/popup/services/services.module.ts | 3 +- .../src/app/services/services.module.ts | 3 +- apps/web/src/app/core/core.module.ts | 3 +- .../src/catchall-settings.component.ts | 7 +- ...al-generator-history-dialog.component.html | 7 +- ...redential-generator-history.component.html | 46 +++--- .../credential-generator-history.component.ts | 4 +- .../src/credential-generator.component.html | 151 ++++++++++-------- .../src/credential-generator.component.ts | 57 ++++++- .../src/forwarder-settings.component.html | 58 ++++--- .../src/forwarder-settings.component.ts | 20 ++- .../components/src/generator.module.ts | 58 +------ libs/tools/generator/components/src/index.ts | 10 ++ .../nudge-generator-spotlight.component.html | 34 ++-- .../src/passphrase-settings.component.html | 12 +- .../src/passphrase-settings.component.ts | 28 +++- .../src/password-generator.component.html | 57 ++++--- .../src/password-generator.component.ts | 34 +++- .../src/password-settings.component.html | 18 ++- .../src/password-settings.component.ts | 28 +++- .../src/subaddress-settings.component.ts | 7 +- .../src/username-generator.component.html | 67 ++++---- .../src/username-generator.component.ts | 50 +++++- .../src/username-settings.component.ts | 7 +- 24 files changed, 481 insertions(+), 288 deletions(-) diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 0a82a07b722..bb89eff1147 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -136,6 +136,7 @@ import { DialogService, ToastService, } from "@bitwarden/components"; +import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { BiometricsService, @@ -743,7 +744,7 @@ const safeProviders: SafeProvider[] = [ ]; @NgModule({ - imports: [JslibServicesModule], + imports: [JslibServicesModule, GeneratorServicesModule], declarations: [], // Do not register your dependency here! Add it to the typesafeProviders array using the helper function providers: safeProviders, diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 5e20b2fa921..1b373f08881 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -102,6 +102,7 @@ import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/s import { SyncService } from "@bitwarden/common/platform/sync"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { KdfConfigService, @@ -499,7 +500,7 @@ const safeProviders: SafeProvider[] = [ ]; @NgModule({ - imports: [JslibServicesModule], + imports: [JslibServicesModule, GeneratorServicesModule], declarations: [], // Do not register your dependency here! Add it to the typesafeProviders array using the helper function providers: safeProviders, diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index ab8f06dcf32..fb42e19f863 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -112,6 +112,7 @@ import { } from "@bitwarden/common/platform/theming/theme-state.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { KdfConfigService, @@ -484,7 +485,7 @@ const safeProviders: SafeProvider[] = [ @NgModule({ declarations: [], - imports: [CommonModule, JslibServicesModule], + imports: [CommonModule, JslibServicesModule, GeneratorServicesModule], // Do not register your dependency here! Add it to the typesafeProviders array using the helper function providers: safeProviders, }) diff --git a/libs/tools/generator/components/src/catchall-settings.component.ts b/libs/tools/generator/components/src/catchall-settings.component.ts index 0fb953b86dc..a30a1c65b8c 100644 --- a/libs/tools/generator/components/src/catchall-settings.component.ts +++ b/libs/tools/generator/components/src/catchall-settings.component.ts @@ -8,15 +8,18 @@ import { Output, SimpleChanges, } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { map, ReplaySubject, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { FormFieldModule } from "@bitwarden/components"; import { CatchallGenerationOptions, CredentialGeneratorService, BuiltIn, } from "@bitwarden/generator-core"; +import { I18nPipe } from "@bitwarden/ui-common"; /** Options group for catchall emails */ // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -24,7 +27,7 @@ import { @Component({ selector: "tools-catchall-settings", templateUrl: "catchall-settings.component.html", - standalone: false, + imports: [ReactiveFormsModule, FormFieldModule, JslibModule, I18nPipe], }) export class CatchallSettingsComponent implements OnInit, OnDestroy, OnChanges { /** Instantiates the component diff --git a/libs/tools/generator/components/src/credential-generator-history-dialog.component.html b/libs/tools/generator/components/src/credential-generator-history-dialog.component.html index e107e33ca50..c392b323a6b 100644 --- a/libs/tools/generator/components/src/credential-generator-history-dialog.component.html +++ b/libs/tools/generator/components/src/credential-generator-history-dialog.component.html @@ -1,8 +1,11 @@ <bit-dialog #dialog background="alt"> <span bitDialogTitle>{{ "generatorHistory" | i18n }}</span> <ng-container bitDialogContent> - <bit-empty-credential-history *ngIf="!(hasHistory$ | async)" style="display: contents" /> - <bit-credential-generator-history [account]="account$ | async" *ngIf="hasHistory$ | async" /> + @if (hasHistory$ | async) { + <bit-credential-generator-history [account]="account$ | async" /> + } @else { + <bit-empty-credential-history style="display: contents" /> + } </ng-container> <ng-container bitDialogFooter> <button diff --git a/libs/tools/generator/components/src/credential-generator-history.component.html b/libs/tools/generator/components/src/credential-generator-history.component.html index 1f2f3d99e00..c00f9760cea 100644 --- a/libs/tools/generator/components/src/credential-generator-history.component.html +++ b/libs/tools/generator/components/src/credential-generator-history.component.html @@ -1,22 +1,24 @@ -<bit-item *ngFor="let credential of credentials$ | async"> - <bit-item-content> - <bit-color-password class="tw-font-mono" [password]="credential.credential" /> - <div slot="secondary"> - {{ credential.generationDate | date: "medium" }} - </div> - </bit-item-content> - <ng-container slot="end"> - <bit-item-action> - <button - type="button" - bitIconButton="bwi-clone" - [appCopyClick]="credential.credential" - [valueLabel]="getGeneratedValueText(credential)" - [label]="getCopyText(credential)" - showToast - > - {{ getCopyText(credential) }} - </button> - </bit-item-action> - </ng-container> -</bit-item> +@for (credential of credentials$ | async; track credential) { + <bit-item> + <bit-item-content> + <bit-color-password class="tw-font-mono" [password]="credential.credential" /> + <div slot="secondary"> + {{ credential.generationDate | date: "medium" }} + </div> + </bit-item-content> + <ng-container slot="end"> + <bit-item-action> + <button + type="button" + bitIconButton="bwi-clone" + [appCopyClick]="credential.credential" + [valueLabel]="getGeneratedValueText(credential)" + [label]="getCopyText(credential)" + showToast + > + {{ getCopyText(credential) }} + </button> + </bit-item-action> + </ng-container> + </bit-item> +} diff --git a/libs/tools/generator/components/src/credential-generator-history.component.ts b/libs/tools/generator/components/src/credential-generator-history.component.ts index a09a82c74b8..e732aa0198e 100644 --- a/libs/tools/generator/components/src/credential-generator-history.component.ts +++ b/libs/tools/generator/components/src/credential-generator-history.component.ts @@ -23,7 +23,6 @@ import { import { AlgorithmsByType, CredentialGeneratorService } from "@bitwarden/generator-core"; import { GeneratedCredential, GeneratorHistoryService } from "@bitwarden/generator-history"; -import { GeneratorModule } from "./generator.module"; import { translate } from "./util"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -32,13 +31,12 @@ import { translate } from "./util"; selector: "bit-credential-generator-history", templateUrl: "credential-generator-history.component.html", imports: [ - ColorPasswordModule, CommonModule, + ColorPasswordModule, IconButtonModule, NoItemsModule, JslibModule, ItemModule, - GeneratorModule, ], }) export class CredentialGeneratorHistoryComponent implements OnChanges, OnInit, OnDestroy { diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 124de1e3c45..f97e1500b33 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -6,9 +6,11 @@ (selectedChange)="onRootChanged({ nav: $event })" attr.aria-label="{{ 'type' | i18n }}" > - <bit-toggle *ngFor="let option of rootOptions$ | async" [value]="option.value"> - {{ option.label }} - </bit-toggle> + @for (option of rootOptions$ | async; track option) { + <bit-toggle [value]="option.value"> + {{ option.label }} + </bit-toggle> + } </bit-toggle-group> <nudge-generator-spotlight></nudge-generator-spotlight> @@ -40,69 +42,80 @@ ></button> </div> </bit-card> -<tools-password-settings - class="tw-mt-6" - *ngIf="(showAlgorithm$ | async)?.id === Algorithm.password" - [account]="account$ | async" - (onUpdated)="generate('password settings')" -/> -<tools-passphrase-settings - class="tw-mt-6" - *ngIf="(showAlgorithm$ | async)?.id === Algorithm.passphrase" - [account]="account$ | async" - (onUpdated)="generate('passphrase settings')" -/> -<bit-section *ngIf="(category$ | async) !== 'password'"> - <bit-section-header> - <h2 bitTypography="h6">{{ "options" | i18n }}</h2> - </bit-section-header> - <div class="tw-mb-4"> - <bit-card> - <form [formGroup]="username" class="tw-container"> - <bit-form-field> - <bit-label>{{ "type" | i18n }}</bit-label> - <bit-select - [items]="usernameOptions$ | async" - formControlName="nav" - data-testid="username-type" - > - </bit-select> - <bit-hint *ngIf="!!(credentialTypeHint$ | async)">{{ - credentialTypeHint$ | async - }}</bit-hint> - </bit-form-field> - </form> - <form *ngIf="showForwarder$ | async" [formGroup]="forwarder" class="tw-container"> - <bit-form-field> - <bit-label>{{ "service" | i18n }}</bit-label> - <bit-select - [items]="forwarderOptions$ | async" - formControlName="nav" - data-testid="email-forwarding-service" - > - </bit-select> - </bit-form-field> - </form> - <tools-catchall-settings - *ngIf="(showAlgorithm$ | async)?.id === Algorithm.catchall" - [account]="account$ | async" - (onUpdated)="generate('catchall settings')" - /> - <tools-forwarder-settings - *ngIf="!!(forwarderId$ | async)" - [account]="account$ | async" - [forwarder]="forwarderId$ | async" - /> - <tools-subaddress-settings - *ngIf="(showAlgorithm$ | async)?.id === Algorithm.plusAddress" - [account]="account$ | async" - (onUpdated)="generate('subaddress settings')" - /> - <tools-username-settings - *ngIf="(showAlgorithm$ | async)?.id === Algorithm.username" - [account]="account$ | async" - (onUpdated)="generate('username settings')" - /> - </bit-card> - </div> -</bit-section> +@let showAlgorithm = showAlgorithm$ | async; +@let account = account$ | async; +@switch (showAlgorithm?.id) { + @case (Algorithm.password) { + <tools-password-settings + class="tw-mt-6" + [account]="account" + (onUpdated)="generate('password settings')" + /> + } + @case (Algorithm.passphrase) { + <tools-passphrase-settings + class="tw-mt-6" + [account]="account" + (onUpdated)="generate('passphrase settings')" + /> + } +} +@if ((category$ | async) !== "password") { + <bit-section> + <bit-section-header> + <h2 bitTypography="h6">{{ "options" | i18n }}</h2> + </bit-section-header> + <div class="tw-mb-4"> + <bit-card> + <form [formGroup]="username" class="tw-container"> + <bit-form-field> + <bit-label>{{ "type" | i18n }}</bit-label> + <bit-select + [items]="usernameOptions$ | async" + formControlName="nav" + data-testid="username-type" + > + </bit-select> + @if (credentialTypeHint$ | async) { + <bit-hint>{{ credentialTypeHint$ | async }}</bit-hint> + } + </bit-form-field> + </form> + @if (showForwarder$ | async) { + <form [formGroup]="forwarder" class="tw-container"> + <bit-form-field> + <bit-label>{{ "service" | i18n }}</bit-label> + <bit-select + [items]="forwarderOptions$ | async" + formControlName="nav" + data-testid="email-forwarding-service" + > + </bit-select> + </bit-form-field> + </form> + } + @if (showAlgorithm?.id === Algorithm.catchall) { + <tools-catchall-settings + [account]="account" + (onUpdated)="generate('catchall settings')" + /> + } + @if (forwarderId$ | async; as forwarderId) { + <tools-forwarder-settings [account]="account" [forwarder]="forwarderId" /> + } + @if (showAlgorithm?.id === Algorithm.plusAddress) { + <tools-subaddress-settings + [account]="account" + (onUpdated)="generate('subaddress settings')" + /> + } + @if (showAlgorithm?.id === Algorithm.username) { + <tools-username-settings + [account]="account" + (onUpdated)="generate('username settings')" + /> + } + </bit-card> + </div> + </bit-section> +} diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index f48180d93bd..af6791f673b 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -1,4 +1,5 @@ import { LiveAnnouncer } from "@angular/cdk/a11y"; +import { AsyncPipe } from "@angular/common"; import { Component, EventEmitter, @@ -10,7 +11,7 @@ import { Output, SimpleChanges, } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { BehaviorSubject, catchError, @@ -27,6 +28,7 @@ import { withLatestFrom, } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -37,7 +39,23 @@ import { ifEnabledSemanticLoggerProvider, } from "@bitwarden/common/tools/log"; import { UserId } from "@bitwarden/common/types/guid"; -import { ToastService, Option } from "@bitwarden/components"; +import { + ToastService, + Option, + BaseCardDirective, + CardComponent, + ColorPasswordComponent, + AriaDisableDirective, + TooltipDirective, + BitIconButtonComponent, + CopyClickDirective, + SectionComponent, + SectionHeaderComponent, + ToggleGroupModule, + TypographyModule, + FormFieldModule, + SelectModule, +} from "@bitwarden/components"; import { CredentialType, CredentialGeneratorService, @@ -55,7 +73,15 @@ import { Type, } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { CatchallSettingsComponent } from "./catchall-settings.component"; +import { ForwarderSettingsComponent } from "./forwarder-settings.component"; +import { NudgeGeneratorSpotlightComponent } from "./nudge-generator-spotlight.component"; +import { PassphraseSettingsComponent } from "./passphrase-settings.component"; +import { PasswordSettingsComponent } from "./password-settings.component"; +import { SubaddressSettingsComponent } from "./subaddress-settings.component"; +import { UsernameSettingsComponent } from "./username-settings.component"; import { translate } from "./util"; // constants used to identify navigation selections that are not @@ -69,7 +95,32 @@ const NONE_SELECTED = "none"; @Component({ selector: "tools-credential-generator", templateUrl: "credential-generator.component.html", - standalone: false, + imports: [ + ToggleGroupModule, + NudgeGeneratorSpotlightComponent, + BaseCardDirective, + CardComponent, + ColorPasswordComponent, + AriaDisableDirective, + TooltipDirective, + BitIconButtonComponent, + CopyClickDirective, + PasswordSettingsComponent, + PassphraseSettingsComponent, + SectionComponent, + SectionHeaderComponent, + TypographyModule, + ReactiveFormsModule, + FormFieldModule, + SelectModule, + CatchallSettingsComponent, + ForwarderSettingsComponent, + SubaddressSettingsComponent, + UsernameSettingsComponent, + AsyncPipe, + JslibModule, + I18nPipe, + ], }) export class CredentialGeneratorComponent implements OnInit, OnChanges, OnDestroy { private readonly destroyed = new Subject<void>(); diff --git a/libs/tools/generator/components/src/forwarder-settings.component.html b/libs/tools/generator/components/src/forwarder-settings.component.html index 8ad6eab1acf..0d213179c2b 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.html +++ b/libs/tools/generator/components/src/forwarder-settings.component.html @@ -1,28 +1,34 @@ <form [formGroup]="settings" class="tw-container"> - <bit-form-field *ngIf="displayDomain"> - <bit-label>{{ "forwarderDomainName" | i18n }}</bit-label> - <input - bitInput - formControlName="domain" - type="text" - placeholder="example.com" - (change)="save('domain')" - /> - <bit-hint>{{ "forwarderDomainNameHint" | i18n }}</bit-hint> - </bit-form-field> - <bit-form-field *ngIf="displayToken"> - <bit-label>{{ "apiKey" | i18n }}</bit-label> - <input bitInput formControlName="token" type="password" (change)="save('password')" /> - <button - type="button" - bitIconButton - bitSuffix - bitPasswordInputToggle - (change)="save('token')" - ></button> - </bit-form-field> - <bit-form-field *ngIf="displayBaseUrl" disableMargin> - <bit-label>{{ "selfHostBaseUrl" | i18n }}</bit-label> - <input bitInput formControlName="baseUrl" type="text" (change)="save('baseUrl')" /> - </bit-form-field> + @if (displayDomain) { + <bit-form-field> + <bit-label>{{ "forwarderDomainName" | i18n }}</bit-label> + <input + bitInput + formControlName="domain" + type="text" + placeholder="example.com" + (change)="save('domain')" + /> + <bit-hint>{{ "forwarderDomainNameHint" | i18n }}</bit-hint> + </bit-form-field> + } + @if (displayToken) { + <bit-form-field> + <bit-label>{{ "apiKey" | i18n }}</bit-label> + <input bitInput formControlName="token" type="password" (change)="save('password')" /> + <button + type="button" + bitIconButton + bitSuffix + bitPasswordInputToggle + (change)="save('token')" + ></button> + </bit-form-field> + } + @if (displayBaseUrl) { + <bit-form-field disableMargin> + <bit-label>{{ "selfHostBaseUrl" | i18n }}</bit-label> + <input bitInput formControlName="baseUrl" type="text" (change)="save('baseUrl')" /> + </bit-form-field> + } </form> diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts index 32fa3effdf6..0f1c863b2f6 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.ts +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -8,16 +8,24 @@ import { Output, SimpleChanges, } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { map, ReplaySubject, skip, Subject, switchAll, takeUntil, withLatestFrom } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { + FormFieldModule, + AriaDisableDirective, + TooltipDirective, + BitIconButtonComponent, +} from "@bitwarden/components"; import { CredentialGeneratorService, ForwarderOptions, GeneratorMetadata, } from "@bitwarden/generator-core"; +import { I18nPipe } from "@bitwarden/ui-common"; const Controls = Object.freeze({ domain: "domain", @@ -31,7 +39,15 @@ const Controls = Object.freeze({ @Component({ selector: "tools-forwarder-settings", templateUrl: "forwarder-settings.component.html", - standalone: false, + imports: [ + ReactiveFormsModule, + FormFieldModule, + AriaDisableDirective, + TooltipDirective, + BitIconButtonComponent, + JslibModule, + I18nPipe, + ], }) export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the component diff --git a/libs/tools/generator/components/src/generator.module.ts b/libs/tools/generator/components/src/generator.module.ts index d710f368106..795a5ffe972 100644 --- a/libs/tools/generator/components/src/generator.module.ts +++ b/libs/tools/generator/components/src/generator.module.ts @@ -1,67 +1,13 @@ -import { CommonModule } from "@angular/common"; import { NgModule } from "@angular/core"; -import { ReactiveFormsModule } from "@angular/forms"; -import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { - CardComponent, - ColorPasswordModule, - CheckboxModule, - FormFieldModule, - IconButtonModule, - InputModule, - ItemModule, - SectionComponent, - SectionHeaderComponent, - SelectModule, - ToggleGroupModule, - TypographyModule, -} from "@bitwarden/components"; - -import { CatchallSettingsComponent } from "./catchall-settings.component"; import { CredentialGeneratorComponent } from "./credential-generator.component"; -import { ForwarderSettingsComponent } from "./forwarder-settings.component"; -import { GeneratorServicesModule } from "./generator-services.module"; -import { NudgeGeneratorSpotlightComponent } from "./nudge-generator-spotlight.component"; -import { PassphraseSettingsComponent } from "./passphrase-settings.component"; import { PasswordGeneratorComponent } from "./password-generator.component"; -import { PasswordSettingsComponent } from "./password-settings.component"; -import { SubaddressSettingsComponent } from "./subaddress-settings.component"; import { UsernameGeneratorComponent } from "./username-generator.component"; -import { UsernameSettingsComponent } from "./username-settings.component"; /** Shared module containing generator component dependencies */ +/** @deprecated Use individual components instead. */ @NgModule({ - imports: [ - CardComponent, - ColorPasswordModule, - CheckboxModule, - CommonModule, - FormFieldModule, - GeneratorServicesModule, - IconButtonModule, - InputModule, - ItemModule, - JslibModule, - ReactiveFormsModule, - SectionComponent, - SectionHeaderComponent, - SelectModule, - ToggleGroupModule, - TypographyModule, - NudgeGeneratorSpotlightComponent, - ], - declarations: [ - CatchallSettingsComponent, - CredentialGeneratorComponent, - ForwarderSettingsComponent, - SubaddressSettingsComponent, - PasswordGeneratorComponent, - PassphraseSettingsComponent, - PasswordSettingsComponent, - UsernameGeneratorComponent, - UsernameSettingsComponent, - ], + imports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], }) export class GeneratorModule { diff --git a/libs/tools/generator/components/src/index.ts b/libs/tools/generator/components/src/index.ts index 4ec32032de0..bfb19b07b05 100644 --- a/libs/tools/generator/components/src/index.ts +++ b/libs/tools/generator/components/src/index.ts @@ -1,5 +1,15 @@ +/** + * This file contains the public interface for the generator components library. + * + * Be mindful of what you export here, as those components should be considered stable + * and part of the public API contract. + */ + +export { CredentialGeneratorComponent } from "./credential-generator.component"; export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component"; export { CredentialGeneratorHistoryDialogComponent } from "./credential-generator-history-dialog.component"; export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component"; export { GeneratorModule } from "./generator.module"; export { GeneratorServicesModule, SYSTEM_SERVICE_PROVIDER } from "./generator-services.module"; +export { PasswordGeneratorComponent } from "./password-generator.component"; +export { UsernameGeneratorComponent } from "./username-generator.component"; diff --git a/libs/tools/generator/components/src/nudge-generator-spotlight.component.html b/libs/tools/generator/components/src/nudge-generator-spotlight.component.html index 581825936be..74fa5eb484c 100644 --- a/libs/tools/generator/components/src/nudge-generator-spotlight.component.html +++ b/libs/tools/generator/components/src/nudge-generator-spotlight.component.html @@ -1,16 +1,18 @@ -<div class="tw-mb-4" *ngIf="showGeneratorSpotlight$ | async"> - <bit-spotlight - [title]="'generatorNudgeTitle' | i18n" - (onDismiss)="dismissGeneratorSpotlight(NudgeType.GeneratorNudgeStatus)" - > - <p class="tw-text-main tw-mb-0" bitTypography="body2"> - <span class="tw-sr-only"> - {{ "generatorNudgeBodyAria" | i18n }} - </span> - <span aria-hidden="true"> - {{ "generatorNudgeBodyOne" | i18n }} <i class="bwi bwi-generate"></i> - {{ "generatorNudgeBodyTwo" | i18n }} - </span> - </p> - </bit-spotlight> -</div> +@if (showGeneratorSpotlight$ | async) { + <div class="tw-mb-4"> + <bit-spotlight + [title]="'generatorNudgeTitle' | i18n" + (onDismiss)="dismissGeneratorSpotlight(NudgeType.GeneratorNudgeStatus)" + > + <p class="tw-text-main tw-mb-0" bitTypography="body2"> + <span class="tw-sr-only"> + {{ "generatorNudgeBodyAria" | i18n }} + </span> + <span aria-hidden="true"> + {{ "generatorNudgeBodyOne" | i18n }} <i class="bwi bwi-generate"></i> + {{ "generatorNudgeBodyTwo" | i18n }} + </span> + </p> + </bit-spotlight> + </div> +} diff --git a/libs/tools/generator/components/src/passphrase-settings.component.html b/libs/tools/generator/components/src/passphrase-settings.component.html index 0af27f7fdcb..d40e91f7013 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.html +++ b/libs/tools/generator/components/src/passphrase-settings.component.html @@ -1,7 +1,9 @@ <bit-section [disableMargin]="disableMargin"> - <bit-section-header *ngIf="showHeader"> - <h6 bitTypography="h6">{{ "options" | i18n }}</h6> - </bit-section-header> + @if (showHeader) { + <bit-section-header> + <h6 bitTypography="h6">{{ "options" | i18n }}</h6> + </bit-section-header> + } <form [formGroup]="settings" class="tw-container"> <div class="tw-mb-4"> <bit-card> @@ -51,7 +53,9 @@ /> <bit-label>{{ "includeNumber" | i18n }}</bit-label> </bit-form-control> - <p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p> + @if (policyInEffect) { + <p bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p> + } </bit-card> </div> </form> diff --git a/libs/tools/generator/components/src/passphrase-settings.component.ts b/libs/tools/generator/components/src/passphrase-settings.component.ts index 7e4ae8b5af9..6ab3020c688 100644 --- a/libs/tools/generator/components/src/passphrase-settings.component.ts +++ b/libs/tools/generator/components/src/passphrase-settings.component.ts @@ -1,4 +1,5 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { AsyncPipe } from "@angular/common"; import { OnInit, Input, @@ -9,9 +10,10 @@ import { SimpleChanges, OnChanges, } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { skip, takeUntil, Subject, map, withLatestFrom, ReplaySubject, tap } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -20,11 +22,21 @@ import { disabledSemanticLoggerProvider, ifEnabledSemanticLoggerProvider, } from "@bitwarden/common/tools/log"; +import { + SectionComponent, + SectionHeaderComponent, + BaseCardDirective, + CardComponent, + TypographyModule, + FormFieldModule, + CheckboxModule, +} from "@bitwarden/components"; import { CredentialGeneratorService, PassphraseGenerationOptions, BuiltIn, } from "@bitwarden/generator-core"; +import { I18nPipe } from "@bitwarden/ui-common"; const Controls = Object.freeze({ numWords: "numWords", @@ -39,7 +51,19 @@ const Controls = Object.freeze({ @Component({ selector: "tools-passphrase-settings", templateUrl: "passphrase-settings.component.html", - standalone: false, + imports: [ + SectionComponent, + SectionHeaderComponent, + TypographyModule, + ReactiveFormsModule, + BaseCardDirective, + CardComponent, + FormFieldModule, + CheckboxModule, + AsyncPipe, + JslibModule, + I18nPipe, + ], }) export class PassphraseSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the component diff --git a/libs/tools/generator/components/src/password-generator.component.html b/libs/tools/generator/components/src/password-generator.component.html index 9995613685b..0088628dff0 100644 --- a/libs/tools/generator/components/src/password-generator.component.html +++ b/libs/tools/generator/components/src/password-generator.component.html @@ -1,15 +1,18 @@ -<bit-toggle-group - fullWidth - class="tw-mb-4" - [selected]="credentialType$ | async" - (selectedChange)="onCredentialTypeChanged($event)" - *ngIf="showCredentialTypes$ | async" - attr.aria-label="{{ 'type' | i18n }}" -> - <bit-toggle *ngFor="let option of passwordOptions$ | async" [value]="option.value"> - {{ option.label }} - </bit-toggle> -</bit-toggle-group> +@if (showCredentialTypes$ | async) { + <bit-toggle-group + fullWidth + class="tw-mb-4" + [selected]="credentialType$ | async" + (selectedChange)="onCredentialTypeChanged($event)" + attr.aria-label="{{ 'type' | i18n }}" + > + @for (option of passwordOptions$ | async; track option) { + <bit-toggle [value]="option.value"> + {{ option.label }} + </bit-toggle> + } + </bit-toggle-group> +} <bit-card class="tw-flex tw-justify-between tw-mb-4"> <div class="tw-grow tw-flex tw-items-center tw-min-w-0"> <bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password> @@ -37,17 +40,19 @@ ></button> </div> </bit-card> -<tools-password-settings - class="tw-mt-6" - *ngIf="(algorithm$ | async)?.id === Algorithm.password" - [account]="account$ | async" - [disableMargin]="disableMargin" - (onUpdated)="generate('password settings')" -/> -<tools-passphrase-settings - class="tw-mt-6" - *ngIf="(algorithm$ | async)?.id === Algorithm.passphrase" - [account]="account$ | async" - (onUpdated)="generate('passphrase settings')" - [disableMargin]="disableMargin" -/> +@if ((algorithm$ | async)?.id === Algorithm.password) { + <tools-password-settings + class="tw-mt-6" + [account]="account$ | async" + [disableMargin]="disableMargin" + (onUpdated)="generate('password settings')" + /> +} +@if ((algorithm$ | async)?.id === Algorithm.passphrase) { + <tools-passphrase-settings + class="tw-mt-6" + [account]="account$ | async" + (onUpdated)="generate('passphrase settings')" + [disableMargin]="disableMargin" + /> +} diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 2b1d5044651..52cf902293d 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -1,5 +1,6 @@ import { LiveAnnouncer } from "@angular/cdk/a11y"; import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { AsyncPipe } from "@angular/common"; import { Component, EventEmitter, @@ -24,6 +25,7 @@ import { withLatestFrom, } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -33,7 +35,18 @@ import { ifEnabledSemanticLoggerProvider, } from "@bitwarden/common/tools/log"; import { UserId } from "@bitwarden/common/types/guid"; -import { ToastService, Option } from "@bitwarden/components"; +import { + ToastService, + Option, + BaseCardDirective, + CardComponent, + ColorPasswordComponent, + AriaDisableDirective, + TooltipDirective, + BitIconButtonComponent, + CopyClickDirective, + ToggleGroupModule, +} from "@bitwarden/components"; import { CredentialGeneratorService, GeneratedCredential, @@ -49,7 +62,10 @@ import { Profile, } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { PassphraseSettingsComponent } from "./passphrase-settings.component"; +import { PasswordSettingsComponent } from "./password-settings.component"; import { toAlgorithmInfo, translate } from "./util"; /** Options group for passwords */ @@ -58,7 +74,21 @@ import { toAlgorithmInfo, translate } from "./util"; @Component({ selector: "tools-password-generator", templateUrl: "password-generator.component.html", - standalone: false, + imports: [ + ToggleGroupModule, + BaseCardDirective, + CardComponent, + ColorPasswordComponent, + AriaDisableDirective, + TooltipDirective, + BitIconButtonComponent, + CopyClickDirective, + PasswordSettingsComponent, + PassphraseSettingsComponent, + AsyncPipe, + JslibModule, + I18nPipe, + ], }) export class PasswordGeneratorComponent implements OnInit, OnChanges, OnDestroy { constructor( diff --git a/libs/tools/generator/components/src/password-settings.component.html b/libs/tools/generator/components/src/password-settings.component.html index 13bf6822462..b435a13fe4c 100644 --- a/libs/tools/generator/components/src/password-settings.component.html +++ b/libs/tools/generator/components/src/password-settings.component.html @@ -1,7 +1,9 @@ <bit-section [disableMargin]="disableMargin"> - <bit-section-header *ngIf="showHeader"> - <h2 bitTypography="h6">{{ "options" | i18n }}</h2> - </bit-section-header> + @if (showHeader) { + <bit-section-header> + <h2 bitTypography="h6">{{ "options" | i18n }}</h2> + </bit-section-header> + } <form [formGroup]="settings" class="tw-container"> <div class="tw-mb-4"> <bit-card> @@ -62,9 +64,9 @@ (change)="save('special')" /> <!-- hard-coded the special characters string because `$` indicates an i18n interpolation, - and is handled inconsistently across browsers. Angular template syntax is used to - ensure special characters are entity-encoded. - --> + and is handled inconsistently across browsers. Angular template syntax is used to + ensure special characters are entity-encoded. + --> <bit-label>{{ "!@#$%^&*" }}</bit-label> </bit-form-control> </div> @@ -97,7 +99,9 @@ /> <bit-label>{{ "avoidAmbiguous" | i18n }}</bit-label> </bit-form-control> - <p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p> + @if (policyInEffect) { + <p bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p> + } </bit-card> </div> </form> diff --git a/libs/tools/generator/components/src/password-settings.component.ts b/libs/tools/generator/components/src/password-settings.component.ts index 5d5980edf1b..9445e655310 100644 --- a/libs/tools/generator/components/src/password-settings.component.ts +++ b/libs/tools/generator/components/src/password-settings.component.ts @@ -1,4 +1,5 @@ import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { AsyncPipe } from "@angular/common"; import { OnInit, Input, @@ -9,16 +10,27 @@ import { SimpleChanges, OnChanges, } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { takeUntil, Subject, map, filter, tap, skip, ReplaySubject, withLatestFrom } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + SectionComponent, + SectionHeaderComponent, + BaseCardDirective, + CardComponent, + FormFieldModule, + TypographyModule, + CheckboxModule, +} from "@bitwarden/components"; import { CredentialGeneratorService, PasswordGenerationOptions, BuiltIn, } from "@bitwarden/generator-core"; +import { I18nPipe } from "@bitwarden/ui-common"; import { hasRangeOfValues } from "./util"; @@ -39,7 +51,19 @@ const Controls = Object.freeze({ @Component({ selector: "tools-password-settings", templateUrl: "password-settings.component.html", - standalone: false, + imports: [ + SectionComponent, + SectionHeaderComponent, + TypographyModule, + ReactiveFormsModule, + BaseCardDirective, + CardComponent, + FormFieldModule, + CheckboxModule, + AsyncPipe, + JslibModule, + I18nPipe, + ], }) export class PasswordSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the component diff --git a/libs/tools/generator/components/src/subaddress-settings.component.ts b/libs/tools/generator/components/src/subaddress-settings.component.ts index f9cef2341ba..068511e29e5 100644 --- a/libs/tools/generator/components/src/subaddress-settings.component.ts +++ b/libs/tools/generator/components/src/subaddress-settings.component.ts @@ -8,15 +8,18 @@ import { Output, SimpleChanges, } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { map, ReplaySubject, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { FormFieldModule } from "@bitwarden/components"; import { CredentialGeneratorService, BuiltIn, SubaddressGenerationOptions, } from "@bitwarden/generator-core"; +import { I18nPipe } from "@bitwarden/ui-common"; /** Options group for plus-addressed emails */ // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -24,7 +27,7 @@ import { @Component({ selector: "tools-subaddress-settings", templateUrl: "subaddress-settings.component.html", - standalone: false, + imports: [ReactiveFormsModule, FormFieldModule, JslibModule, I18nPipe], }) export class SubaddressSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the component diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index 0f3182118a1..9f74f5d1fea 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -42,42 +42,41 @@ data-testid="username-type" > </bit-select> - <bit-hint *ngIf="!!(credentialTypeHint$ | async)">{{ - credentialTypeHint$ | async - }}</bit-hint> + @if (credentialTypeHint$ | async) { + <bit-hint>{{ credentialTypeHint$ | async }}</bit-hint> + } </bit-form-field> </form> - <form *ngIf="showForwarder$ | async" [formGroup]="forwarder" class="tw-container"> - <bit-form-field> - <bit-label>{{ "service" | i18n }}</bit-label> - <bit-select - [items]="forwarderOptions$ | async" - formControlName="nav" - data-testid="email-forwarding-service" - > - </bit-select> - </bit-form-field> - </form> - <tools-catchall-settings - *ngIf="(showAlgorithm$ | async)?.id === Algorithm.catchall" - [account]="account$ | async" - (onUpdated)="generate('catchall settings')" - /> - <tools-forwarder-settings - *ngIf="!!(forwarderId$ | async)" - [forwarder]="forwarderId$ | async" - [account]="account$ | async" - /> - <tools-subaddress-settings - *ngIf="(showAlgorithm$ | async)?.id === Algorithm.plusAddress" - [account]="account$ | async" - (onUpdated)="generate('subaddress settings')" - /> - <tools-username-settings - *ngIf="(showAlgorithm$ | async)?.id === Algorithm.username" - [account]="account$ | async" - (onUpdated)="generate('username settings')" - /> + @if (showForwarder$ | async) { + <form [formGroup]="forwarder" class="tw-container"> + <bit-form-field> + <bit-label>{{ "service" | i18n }}</bit-label> + <bit-select + [items]="forwarderOptions$ | async" + formControlName="nav" + data-testid="email-forwarding-service" + > + </bit-select> + </bit-form-field> + </form> + } + @let showAlgorithm = showAlgorithm$ | async; + @let account = account$ | async; + @if (showAlgorithm?.id === Algorithm.catchall) { + <tools-catchall-settings [account]="account" (onUpdated)="generate('catchall settings')" /> + } + @if (forwarderId$ | async; as forwarderId) { + <tools-forwarder-settings [forwarder]="forwarderId" [account]="account" /> + } + @if (showAlgorithm?.id === Algorithm.plusAddress) { + <tools-subaddress-settings + [account]="account" + (onUpdated)="generate('subaddress settings')" + /> + } + @if (showAlgorithm?.id === Algorithm.username) { + <tools-username-settings [account]="account" (onUpdated)="generate('username settings')" /> + } </bit-card> </div> </bit-section> diff --git a/libs/tools/generator/components/src/username-generator.component.ts b/libs/tools/generator/components/src/username-generator.component.ts index dc4b8d26e7e..faf9c4dde2c 100644 --- a/libs/tools/generator/components/src/username-generator.component.ts +++ b/libs/tools/generator/components/src/username-generator.component.ts @@ -1,5 +1,6 @@ import { LiveAnnouncer } from "@angular/cdk/a11y"; import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { NgClass, AsyncPipe } from "@angular/common"; import { Component, EventEmitter, @@ -11,7 +12,7 @@ import { Output, SimpleChanges, } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { BehaviorSubject, catchError, @@ -28,6 +29,7 @@ import { withLatestFrom, } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -38,7 +40,22 @@ import { ifEnabledSemanticLoggerProvider, } from "@bitwarden/common/tools/log"; import { UserId } from "@bitwarden/common/types/guid"; -import { ToastService, Option } from "@bitwarden/components"; +import { + ToastService, + Option, + AriaDisableDirective, + BaseCardDirective, + CardComponent, + ColorPasswordComponent, + CopyClickDirective, + BitIconButtonComponent, + TooltipDirective, + SectionComponent, + SectionHeaderComponent, + SelectComponent, + TypographyModule, + FormFieldModule, +} from "@bitwarden/components"; import { AlgorithmInfo, CredentialGeneratorService, @@ -55,7 +72,12 @@ import { Algorithm, } from "@bitwarden/generator-core"; import { GeneratorHistoryService } from "@bitwarden/generator-history"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { CatchallSettingsComponent } from "./catchall-settings.component"; +import { ForwarderSettingsComponent } from "./forwarder-settings.component"; +import { SubaddressSettingsComponent } from "./subaddress-settings.component"; +import { UsernameSettingsComponent } from "./username-settings.component"; import { toAlgorithmInfo, translate } from "./util"; // constants used to identify navigation selections that are not @@ -69,7 +91,29 @@ const NONE_SELECTED = "none"; @Component({ selector: "tools-username-generator", templateUrl: "username-generator.component.html", - standalone: false, + imports: [ + BaseCardDirective, + CardComponent, + ColorPasswordComponent, + AriaDisableDirective, + TooltipDirective, + BitIconButtonComponent, + CopyClickDirective, + SectionComponent, + SectionHeaderComponent, + TypographyModule, + NgClass, + ReactiveFormsModule, + SelectComponent, + FormFieldModule, + CatchallSettingsComponent, + ForwarderSettingsComponent, + SubaddressSettingsComponent, + UsernameSettingsComponent, + AsyncPipe, + JslibModule, + I18nPipe, + ], }) export class UsernameGeneratorComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the username generator diff --git a/libs/tools/generator/components/src/username-settings.component.ts b/libs/tools/generator/components/src/username-settings.component.ts index fae1a3aca04..f4ccf362b69 100644 --- a/libs/tools/generator/components/src/username-settings.component.ts +++ b/libs/tools/generator/components/src/username-settings.component.ts @@ -8,15 +8,18 @@ import { Output, SimpleChanges, } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { map, ReplaySubject, skip, Subject, takeUntil, withLatestFrom } from "rxjs"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { FormFieldModule, CheckboxModule } from "@bitwarden/components"; import { CredentialGeneratorService, EffUsernameGenerationOptions, BuiltIn, } from "@bitwarden/generator-core"; +import { I18nPipe } from "@bitwarden/ui-common"; /** Options group for usernames */ // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush @@ -24,7 +27,7 @@ import { @Component({ selector: "tools-username-settings", templateUrl: "username-settings.component.html", - standalone: false, + imports: [ReactiveFormsModule, FormFieldModule, CheckboxModule, JslibModule, I18nPipe], }) export class UsernameSettingsComponent implements OnInit, OnChanges, OnDestroy { /** Instantiates the component From 404e07b6bdfae803c0ecc073f318182d99270381 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann <mail@quexten.com> Date: Thu, 11 Dec 2025 12:47:00 +0100 Subject: [PATCH 40/60] [PM-27225] Fix nothing showing when biometrics unavailable (#17209) * Fix nothing showing when biometrics unavailable * Cleanup * Switch to tooltip * Fix type error * Fix type check * Fix includes * Fix types * Fix tests * Add missing return * Add DesktopDisconnected to canUseBiometrics * Apply suggestions * Move comment * Cleanup * Fix typing for null value * Add tests * Fix QA bugs --- .../foreground-browser-biometrics.ts | 6 +- .../src/app/services/services.module.ts | 5 +- .../biometrics/renderer-biometrics.service.ts | 10 + .../src/lock/components/lock.component.html | 1 + .../lock/components/lock.component.spec.ts | 249 +++++++++++++++++- .../src/lock/components/lock.component.ts | 40 ++- 6 files changed, 305 insertions(+), 6 deletions(-) diff --git a/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts index b6e84fee31a..d803a457a81 100644 --- a/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts +++ b/apps/browser/src/key-management/biometrics/foreground-browser-biometrics.ts @@ -48,7 +48,11 @@ export class ForegroundBrowserBiometricsService extends BiometricsService { result: BiometricsStatus; error: string; }>(BiometricsCommands.GetBiometricsStatusForUser, { userId: id }); - return response.result; + if (response != null) { + return response.result; + } else { + return BiometricsStatus.DesktopDisconnected; + } } async getShouldAutopromptNow(): Promise<boolean> { diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 1b373f08881..e4dd144fa20 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -51,6 +51,7 @@ import { } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { ClientType } from "@bitwarden/common/enums"; @@ -167,12 +168,12 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: BiometricsService, useClass: RendererBiometricsService, - deps: [], + deps: [TokenService], }), safeProvider({ provide: DesktopBiometricsService, useClass: RendererBiometricsService, - deps: [], + deps: [TokenService], }), safeProvider(NativeMessagingService), safeProvider(BiometricMessageHandlerService), diff --git a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts index 8e28d3ca614..3a47086b1aa 100644 --- a/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts +++ b/apps/desktop/src/key-management/biometrics/renderer-biometrics.service.ts @@ -1,5 +1,7 @@ import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; @@ -13,6 +15,10 @@ import { DesktopBiometricsService } from "./desktop.biometrics.service"; */ @Injectable() export class RendererBiometricsService extends DesktopBiometricsService { + constructor(private tokenService: TokenService) { + super(); + } + async authenticateWithBiometrics(): Promise<boolean> { return await ipc.keyManagement.biometric.authenticateWithBiometrics(); } @@ -31,6 +37,10 @@ export class RendererBiometricsService extends DesktopBiometricsService { } async getBiometricsStatusForUser(id: UserId): Promise<BiometricsStatus> { + if ((await firstValueFrom(this.tokenService.hasAccessToken$(id))) === false) { + return BiometricsStatus.NotEnabledInConnectedDesktopApp; + } + return await ipc.keyManagement.biometric.getBiometricsStatusForUser(id); } diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index 77f603204b3..71201361a0c 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -16,6 +16,7 @@ [disabled]="unlockingViaBiometrics || !biometricsAvailable" [loading]="unlockingViaBiometrics" block + [bitTooltip]="biometricUnavailabilityReason" (click)="unlockViaBiometrics()" > <span> {{ biometricUnlockBtnText | i18n }}</span> diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 943beef8091..5d35746ff19 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -11,7 +11,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { LogoutService } from "@bitwarden/auth/common"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -57,6 +57,7 @@ import { import { LockComponentService, UnlockOption, + UnlockOptionValue, UnlockOptions, } from "../services/lock-component.service"; @@ -878,4 +879,250 @@ describe("LockComponent", () => { expect(mockRouter.navigate).not.toHaveBeenCalled(); }); }); + + describe("setDefaultActiveUnlockOption", () => { + it.each([ + [ + "biometrics enabled", + { + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + pin: { enabled: false }, + masterPassword: { enabled: false }, + } as UnlockOptions, + UnlockOption.Biometrics, + ], + [ + "biometrics disabled, pin enabled", + { + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally }, + pin: { enabled: true }, + masterPassword: { enabled: false }, + } as UnlockOptions, + UnlockOption.Pin, + ], + [ + "biometrics and pin disabled, masterPassword enabled", + { + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally }, + pin: { enabled: false }, + masterPassword: { enabled: true }, + } as UnlockOptions, + UnlockOption.MasterPassword, + ], + [ + "hardware unavailable, no other options", + { + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.HardwareUnavailable }, + pin: { enabled: false }, + masterPassword: { enabled: false }, + } as UnlockOptions, + UnlockOption.Biometrics, + ], + [ + "desktop disconnected, no other options", + { + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.DesktopDisconnected }, + pin: { enabled: false }, + masterPassword: { enabled: false }, + } as UnlockOptions, + UnlockOption.Biometrics, + ], + [ + "not enabled in connected desktop app, no other options", + { + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.NotEnabledInConnectedDesktopApp, + }, + pin: { enabled: false }, + masterPassword: { enabled: false }, + } as UnlockOptions, + UnlockOption.Biometrics, + ], + [ + "biometrics over pin priority", + { + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + pin: { enabled: true }, + masterPassword: { enabled: false }, + } as UnlockOptions, + UnlockOption.Biometrics, + ], + [ + "biometrics over masterPassword priority", + { + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + pin: { enabled: false }, + masterPassword: { enabled: true }, + } as UnlockOptions, + UnlockOption.Biometrics, + ], + [ + "pin over masterPassword priority", + { + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.NotEnabledLocally }, + pin: { enabled: true }, + masterPassword: { enabled: true }, + } as UnlockOptions, + UnlockOption.Pin, + ], + [ + "all options enabled", + { + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + pin: { enabled: true }, + masterPassword: { enabled: true }, + } as UnlockOptions, + UnlockOption.Biometrics, + ], + ])( + "should set active unlock option to $1 when %s", + async ( + description: string, + unlockOptions: UnlockOptions, + expectedOption: UnlockOptionValue, + ) => { + await component["setDefaultActiveUnlockOption"](unlockOptions); + + expect(component.activeUnlockOption).toBe(expectedOption); + }, + ); + }); + + describe("handleActiveAccountChange", () => { + const mockActiveAccount: Account = { + id: userId, + email: "test@example.com", + name: "Test User", + } as Account; + + beforeEach(async () => { + component.activeAccount = mockActiveAccount; + }); + + it("should return early when account already has user key", async () => { + mockKeyService.hasUserKey.mockResolvedValue(true); + + await component["handleActiveAccountChange"](mockActiveAccount); + + expect(mockKeyService.hasUserKey).toHaveBeenCalledWith(userId); + expect(mockAnonLayoutWrapperDataService.setAnonLayoutWrapperData).not.toHaveBeenCalled(); + }); + + it("should set email as page subtitle when account is unlocked", async () => { + mockKeyService.hasUserKey.mockResolvedValue(false); + mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( + of({ + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + pin: { enabled: false }, + masterPassword: { enabled: false }, + } as UnlockOptions), + ); + mockBiometricService.getBiometricsStatusForUser.mockResolvedValue(BiometricsStatus.Available); + + await component["handleActiveAccountChange"](mockActiveAccount); + + expect(mockAnonLayoutWrapperDataService.setAnonLayoutWrapperData).toHaveBeenCalledWith({ + pageSubtitle: mockActiveAccount.email, + }); + }); + + it("should logout user when no unlock options are available", async () => { + mockKeyService.hasUserKey.mockResolvedValue(false); + mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( + of({ + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.UnlockNeeded }, + pin: { enabled: false }, + masterPassword: { enabled: false }, + } as UnlockOptions), + ); + mockBiometricService.getBiometricsStatusForUser.mockResolvedValue( + BiometricsStatus.UnlockNeeded, + ); + + await component["handleActiveAccountChange"](mockActiveAccount); + + expect(mockLogService.warning).toHaveBeenCalledWith( + "[LockComponent] User cannot unlock again. Logging out!", + ); + expect(mockLogoutService.logout).toHaveBeenCalledWith(userId); + }); + + it("should not logout when master password is enabled", async () => { + mockKeyService.hasUserKey.mockResolvedValue(false); + mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( + of({ + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.UnlockNeeded }, + pin: { enabled: false }, + masterPassword: { enabled: true }, + } as UnlockOptions), + ); + mockBiometricService.getBiometricsStatusForUser.mockResolvedValue( + BiometricsStatus.UnlockNeeded, + ); + + await component["handleActiveAccountChange"](mockActiveAccount); + + expect(mockLogoutService.logout).not.toHaveBeenCalled(); + expect(component.activeUnlockOption).toBe(UnlockOption.MasterPassword); + }); + + it("should not logout when pin is enabled", async () => { + mockKeyService.hasUserKey.mockResolvedValue(false); + mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( + of({ + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.UnlockNeeded }, + pin: { enabled: true }, + masterPassword: { enabled: false }, + } as UnlockOptions), + ); + mockBiometricService.getBiometricsStatusForUser.mockResolvedValue( + BiometricsStatus.UnlockNeeded, + ); + + await component["handleActiveAccountChange"](mockActiveAccount); + + expect(mockLogoutService.logout).not.toHaveBeenCalled(); + expect(component.activeUnlockOption).toBe(UnlockOption.Pin); + }); + + it("should not logout when biometrics is available", async () => { + mockKeyService.hasUserKey.mockResolvedValue(false); + mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( + of({ + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + pin: { enabled: false }, + masterPassword: { enabled: false }, + } as UnlockOptions), + ); + mockBiometricService.getBiometricsStatusForUser.mockResolvedValue(BiometricsStatus.Available); + + await component["handleActiveAccountChange"](mockActiveAccount); + + expect(mockLogoutService.logout).not.toHaveBeenCalled(); + expect(component.activeUnlockOption).toBe(UnlockOption.Biometrics); + }); + + it("should not logout when biometrics is temporarily unavailable but no other options", async () => { + mockKeyService.hasUserKey.mockResolvedValue(false); + mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue( + of({ + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.HardwareUnavailable, + }, + pin: { enabled: false }, + masterPassword: { enabled: false }, + } as UnlockOptions), + ); + mockBiometricService.getBiometricsStatusForUser.mockResolvedValue( + BiometricsStatus.HardwareUnavailable, + ); + + await component["handleActiveAccountChange"](mockActiveAccount); + + expect(mockLogoutService.logout).not.toHaveBeenCalled(); + expect(component.activeUnlockOption).toBe(UnlockOption.Biometrics); + }); + }); }); diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index ae8cdc843cf..ec7ef822335 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -44,6 +44,7 @@ import { SyncService } from "@bitwarden/common/platform/sync"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { UserKey } from "@bitwarden/common/types/key"; import { + TooltipDirective, AsyncActionsModule, AnonLayoutWrapperDataService, ButtonModule, @@ -87,6 +88,12 @@ type AfterUnlockActions = { /// Fixes safari autoprompt behavior const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; +const BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES = [ + BiometricsStatus.HardwareUnavailable, + BiometricsStatus.DesktopDisconnected, + BiometricsStatus.NotEnabledInConnectedDesktopApp, +]; + // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ @@ -101,6 +108,7 @@ const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; AsyncActionsModule, IconButtonModule, MasterPasswordLockComponent, + TooltipDirective, ], }) export class LockComponent implements OnInit, OnDestroy { @@ -212,6 +220,10 @@ export class LockComponent implements OnInit, OnDestroy { this.unlockOptions = await firstValueFrom( this.lockComponentService.getAvailableUnlockOptions$(this.activeAccount.id), ); + if (this.activeUnlockOption == null) { + this.loading = false; + await this.setDefaultActiveUnlockOption(this.unlockOptions); + } } }), takeUntil(this.destroy$), @@ -280,7 +292,22 @@ export class LockComponent implements OnInit, OnDestroy { this.lockComponentService.getAvailableUnlockOptions$(activeAccount.id), ); - this.setDefaultActiveUnlockOption(this.unlockOptions); + const canUseBiometrics = [ + BiometricsStatus.Available, + ...BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES, + ].includes(await this.biometricService.getBiometricsStatusForUser(activeAccount.id)); + if ( + !this.unlockOptions?.masterPassword.enabled && + !this.unlockOptions?.pin.enabled && + !canUseBiometrics + ) { + // User has no available unlock options, force logout. This happens for TDE users without a masterpassword, that don't have a persistent unlock method set. + this.logService.warning("[LockComponent] User cannot unlock again. Logging out!"); + await this.logoutService.logout(activeAccount.id); + return; + } + + await this.setDefaultActiveUnlockOption(this.unlockOptions); if (this.unlockOptions?.biometrics.enabled) { await this.handleBiometricsUnlockEnabled(); @@ -303,7 +330,7 @@ export class LockComponent implements OnInit, OnDestroy { }); } - private setDefaultActiveUnlockOption(unlockOptions: UnlockOptions | null) { + private async setDefaultActiveUnlockOption(unlockOptions: UnlockOptions | null) { // Priorities should be Biometrics > Pin > Master Password for speed if (unlockOptions?.biometrics.enabled) { this.activeUnlockOption = UnlockOption.Biometrics; @@ -311,6 +338,15 @@ export class LockComponent implements OnInit, OnDestroy { this.activeUnlockOption = UnlockOption.Pin; } else if (unlockOptions?.masterPassword.enabled) { this.activeUnlockOption = UnlockOption.MasterPassword; + } else if ( + unlockOptions != null && + BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES.includes( + unlockOptions.biometrics.biometricsStatus, + ) + ) { + // If biometrics is temporarily unavailable for masterpassword-less users, but they have biometrics configured, + // then show the biometrics screen so the user knows why they can't unlock, and to give them the option to log out. + this.activeUnlockOption = UnlockOption.Biometrics; } } From 51d29f777e38a7fec15b355579696d2873388647 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann <mail@quexten.com> Date: Thu, 11 Dec 2025 13:01:09 +0100 Subject: [PATCH 41/60] [PM-24353] Drop legacy pin support (#17328) * Drop legacy pin support * Fix cli build * Fix browser build * Remove pin key * Fix comment * Fix CI / tests * Add migration to remove key * Inline export key * Extract vault export key generation * Cleanup * Add migrator * Fix mv2 build --- .../browser/src/background/main.background.ts | 9 +- .../service-container/service-container.ts | 9 +- .../src/services/jslib-services.module.ts | 15 +-- .../default-key-generation.service.ts | 8 ++ .../key-generation/key-generation.service.ts | 15 +++ .../pin/pin-state.service.abstraction.ts | 8 -- .../pin/pin-state.service.implementation.ts | 18 +-- .../pin/pin-state.service.spec.ts | 106 ---------------- .../pin/pin.service.abstraction.ts | 11 +- .../pin/pin.service.implementation.ts | 119 ++++-------------- .../key-management/pin/pin.service.spec.ts | 94 +------------- .../src/key-management/pin/pin.state.ts | 16 --- .../default-process-reload.service.ts | 2 +- libs/common/src/types/key.ts | 2 - .../src/components/importer-providers.ts | 4 +- ...warden-password-protected-importer.spec.ts | 10 +- .../bitwarden-password-protected-importer.ts | 6 +- .../src/services/import.service.spec.ts | 8 +- libs/importer/src/services/import.service.ts | 6 +- libs/state/src/state-migrations/migrate.ts | 6 +- .../migrations/74-remove-legacy-pin.spec.ts | 50 ++++++++ .../migrations/74-remove-legacy-pin.ts | 30 +++++ .../src/services/base-vault-export.service.ts | 7 +- .../individual-vault-export.service.spec.ts | 8 +- .../individual-vault-export.service.ts | 6 +- .../src/services/org-vault-export.service.ts | 6 +- 26 files changed, 175 insertions(+), 404 deletions(-) create mode 100644 libs/state/src/state-migrations/migrations/74-remove-legacy-pin.spec.ts create mode 100644 libs/state/src/state-migrations/migrations/74-remove-legacy-pin.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 1bd47186914..2540571abb0 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -841,10 +841,7 @@ export default class MainBackground { ); this.pinService = new PinService( - this.accountService, this.encryptService, - this.kdfConfigService, - this.keyGenerationService, this.logService, this.keyService, this.sdkService, @@ -1112,7 +1109,7 @@ export default class MainBackground { this.collectionService, this.keyService, this.encryptService, - this.pinService, + this.keyGenerationService, this.accountService, this.restrictedItemTypesService, ); @@ -1120,7 +1117,7 @@ export default class MainBackground { this.individualVaultExportService = new IndividualVaultExportService( this.folderService, this.cipherService, - this.pinService, + this.keyGenerationService, this.keyService, this.encryptService, this.cryptoFunctionService, @@ -1134,7 +1131,7 @@ export default class MainBackground { this.organizationVaultExportService = new OrganizationVaultExportService( this.cipherService, this.exportApiService, - this.pinService, + this.keyGenerationService, this.keyService, this.encryptService, this.cryptoFunctionService, diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 83c64c61423..2d4ea7d00b5 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -492,10 +492,7 @@ export class ServiceContainer { const pinStateService = new PinStateService(this.stateProvider); this.pinService = new PinService( - this.accountService, this.encryptService, - this.kdfConfigService, - this.keyGenerationService, this.logService, this.keyService, this.sdkService, @@ -908,7 +905,7 @@ export class ServiceContainer { this.collectionService, this.keyService, this.encryptService, - this.pinService, + this.keyGenerationService, this.accountService, this.restrictedItemTypesService, ); @@ -916,7 +913,7 @@ export class ServiceContainer { this.individualExportService = new IndividualVaultExportService( this.folderService, this.cipherService, - this.pinService, + this.keyGenerationService, this.keyService, this.encryptService, this.cryptoFunctionService, @@ -930,7 +927,7 @@ export class ServiceContainer { this.organizationExportService = new OrganizationVaultExportService( this.cipherService, this.vaultExportApiService, - this.pinService, + this.keyGenerationService, this.keyService, this.encryptService, this.cryptoFunctionService, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index b26db7e9056..816e09fd45d 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -952,7 +952,7 @@ const safeProviders: SafeProvider[] = [ deps: [ FolderServiceAbstraction, CipherServiceAbstraction, - PinServiceAbstraction, + KeyGenerationService, KeyService, EncryptService, CryptoFunctionServiceAbstraction, @@ -972,7 +972,7 @@ const safeProviders: SafeProvider[] = [ deps: [ CipherServiceAbstraction, VaultExportApiService, - PinServiceAbstraction, + KeyGenerationService, KeyService, EncryptService, CryptoFunctionServiceAbstraction, @@ -1357,16 +1357,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: PinServiceAbstraction, useClass: PinService, - deps: [ - AccountServiceAbstraction, - EncryptService, - KdfConfigService, - KeyGenerationService, - LogService, - KeyService, - SdkService, - PinStateServiceAbstraction, - ], + deps: [EncryptService, LogService, KeyService, SdkService, PinStateServiceAbstraction], }), safeProvider({ provide: WebAuthnLoginPrfKeyServiceAbstraction, diff --git a/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts index 8e8d2de1ce4..5f5da741707 100644 --- a/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts +++ b/libs/common/src/key-management/crypto/key-generation/default-key-generation.service.ts @@ -91,4 +91,12 @@ export class DefaultKeyGenerationService implements KeyGenerationService { return new SymmetricCryptoKey(newKey); } + + async deriveVaultExportKey( + password: string, + salt: string, + kdfConfig: KdfConfig, + ): Promise<SymmetricCryptoKey> { + return await this.stretchKey(await this.deriveKeyFromPassword(password, salt, kdfConfig)); + } } diff --git a/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts b/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts index d6be436384e..ddc3829aa9f 100644 --- a/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts +++ b/libs/common/src/key-management/crypto/key-generation/key-generation.service.ts @@ -87,4 +87,19 @@ export abstract class KeyGenerationService { * @returns 64 byte derived key. */ abstract stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey>; + + /** + * Derives a 64 byte key for encrypting and decrypting vault exports. + * + * @deprecated Do not use this for new use-cases. + * @param password Password to derive the key from. + * @param salt Salt for the key derivation function. + * @param kdfConfig Configuration for the key derivation function. + * @returns 64 byte derived key. + */ + abstract deriveVaultExportKey( + password: string, + salt: string, + kdfConfig: KdfConfig, + ): Promise<SymmetricCryptoKey>; } diff --git a/libs/common/src/key-management/pin/pin-state.service.abstraction.ts b/libs/common/src/key-management/pin/pin-state.service.abstraction.ts index cd10b8b7fe2..4aef268c1c4 100644 --- a/libs/common/src/key-management/pin/pin-state.service.abstraction.ts +++ b/libs/common/src/key-management/pin/pin-state.service.abstraction.ts @@ -45,14 +45,6 @@ export abstract class PinStateServiceAbstraction { pinLockType: PinLockType, ): Promise<PasswordProtectedKeyEnvelope | null>; - /** - * Gets the user's legacy PIN-protected UserKey - * @deprecated Use {@link getPinProtectedUserKeyEnvelope} instead. Only for migration support. - * @param userId The user's id - * @throws If the user id is not provided - */ - abstract getLegacyPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise<EncString | null>; - /** * Sets the PIN state for the user * @deprecated - This is not a public API. DO NOT USE IT diff --git a/libs/common/src/key-management/pin/pin-state.service.implementation.ts b/libs/common/src/key-management/pin/pin-state.service.implementation.ts index 0bf6cb60fb0..d5b2608f280 100644 --- a/libs/common/src/key-management/pin/pin-state.service.implementation.ts +++ b/libs/common/src/key-management/pin/pin-state.service.implementation.ts @@ -13,7 +13,6 @@ import { PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, USER_KEY_ENCRYPTED_PIN, - PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, } from "./pin.state"; export class PinStateService implements PinStateServiceAbstraction { @@ -36,9 +35,7 @@ export class PinStateService implements PinStateServiceAbstraction { assertNonNullish(userId, "userId"); const isPersistentPinSet = - (await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null || - // Deprecated - (await this.getLegacyPinKeyEncryptedUserKeyPersistent(userId)) != null; + (await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null; const isPinSet = (await firstValueFrom(this.stateProvider.getUserState$(USER_KEY_ENCRYPTED_PIN, userId))) != null; @@ -71,16 +68,6 @@ export class PinStateService implements PinStateServiceAbstraction { } } - async getLegacyPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise<EncString | null> { - assertNonNullish(userId, "userId"); - - return await firstValueFrom( - this.stateProvider - .getUserState$(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, userId) - .pipe(map((value) => (value ? new EncString(value) : null))), - ); - } - async setPinState( userId: UserId, pinProtectedUserKeyEnvelope: PasswordProtectedKeyEnvelope, @@ -116,9 +103,6 @@ export class PinStateService implements PinStateServiceAbstraction { await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, userId); await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, null, userId); await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, null, userId); - - // Note: This can be deleted after sufficiently many PINs are migrated and the state is removed. - await this.stateProvider.setUserState(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, null, userId); } async clearEphemeralPinState(userId: UserId): Promise<void> { diff --git a/libs/common/src/key-management/pin/pin-state.service.spec.ts b/libs/common/src/key-management/pin/pin-state.service.spec.ts index be85a15e6d3..7406701c28d 100644 --- a/libs/common/src/key-management/pin/pin-state.service.spec.ts +++ b/libs/common/src/key-management/pin/pin-state.service.spec.ts @@ -13,7 +13,6 @@ import { USER_KEY_ENCRYPTED_PIN, PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, - PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, } from "./pin.state"; describe("PinStateService", () => { @@ -121,21 +120,6 @@ describe("PinStateService", () => { expect(result).toBe("PERSISTENT"); }); - it("should return 'PERSISTENT' if a legacy pin key encrypted user key (persistent) is found", async () => { - // Arrange - await stateProvider.setUserState( - PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, - mockUserKeyEncryptedPin, - mockUserId, - ); - - // Act - const result = await sut.getPinLockType(mockUserId); - - // Assert - expect(result).toBe("PERSISTENT"); - }); - it("should return 'EPHEMERAL' if only user key encrypted pin is found", async () => { // Arrange await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, mockUserKeyEncryptedPin, mockUserId); @@ -164,7 +148,6 @@ describe("PinStateService", () => { null, mockUserId, ); - await stateProvider.setUserState(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, null, mockUserId); await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, mockUserId); // Act @@ -290,45 +273,6 @@ describe("PinStateService", () => { }); }); - describe("getLegacyPinKeyEncryptedUserKeyPersistent()", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - test.each([null, undefined])("throws if userId is %p", async (userId) => { - // Act & Assert - await expect(() => - sut.getLegacyPinKeyEncryptedUserKeyPersistent(userId as any), - ).rejects.toThrow("userId is null or undefined."); - }); - - it("should return EncString when legacy key is set", async () => { - // Arrange - await stateProvider.setUserState( - PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, - mockUserKeyEncryptedPin, - mockUserId, - ); - - // Act - const result = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId); - - // Assert - expect(result?.encryptedString).toEqual(mockUserKeyEncryptedPin); - }); - - test.each([null, undefined])("should return null when legacy key is %p", async (value) => { - // Arrange - await stateProvider.setUserState(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, value, mockUserId); - - // Act - const result = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId); - - // Assert - expect(result).toBeNull(); - }); - }); - describe("setPinState()", () => { beforeEach(() => { jest.clearAllMocks(); @@ -464,22 +408,6 @@ describe("PinStateService", () => { expect(result).toBeNull(); }); - it("clears legacy PIN key encrypted user key persistent", async () => { - // Arrange - await stateProvider.setUserState( - PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, - mockUserKeyEncryptedPin, - mockUserId, - ); - - // Act - await sut.clearPinState(mockUserId); - - // Assert - const result = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId); - expect(result).toBeNull(); - }); - it("clears all PIN state when all types are set", async () => { // Arrange - set up all possible PIN state await sut.setPinState( @@ -494,17 +422,11 @@ describe("PinStateService", () => { mockUserKeyEncryptedPin, "EPHEMERAL", ); - await stateProvider.setUserState( - PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, - mockUserKeyEncryptedPin, - mockUserId, - ); // Verify all state is set before clearing expect(await firstValueFrom(sut.userKeyEncryptedPin$(mockUserId))).not.toBeNull(); expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL")).not.toBeNull(); expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "PERSISTENT")).not.toBeNull(); - expect(await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId)).not.toBeNull(); // Act await sut.clearPinState(mockUserId); @@ -513,7 +435,6 @@ describe("PinStateService", () => { expect(await firstValueFrom(sut.userKeyEncryptedPin$(mockUserId))).toBeNull(); expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL")).toBeNull(); expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "PERSISTENT")).toBeNull(); - expect(await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId)).toBeNull(); }); it("results in PIN lock type DISABLED after clearing", async () => { @@ -545,7 +466,6 @@ describe("PinStateService", () => { expect(await firstValueFrom(sut.userKeyEncryptedPin$(mockUserId))).toBeNull(); expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL")).toBeNull(); expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "PERSISTENT")).toBeNull(); - expect(await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId)).toBeNull(); expect(await sut.getPinLockType(mockUserId)).toBe("DISABLED"); }); }); @@ -623,32 +543,6 @@ describe("PinStateService", () => { expect(ephemeralResult).toBeNull(); }); - it("does not clear legacy PIN key encrypted user key persistent", async () => { - // Arrange - set up ephemeral state and legacy state - await sut.setPinState( - mockUserId, - mockEphemeralEnvelope, - mockUserKeyEncryptedPin, - "EPHEMERAL", - ); - await stateProvider.setUserState( - PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, - mockUserKeyEncryptedPin, - mockUserId, - ); - - // Act - await sut.clearEphemeralPinState(mockUserId); - - // Assert - legacy PIN should still be present - const legacyResult = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId); - expect(legacyResult?.encryptedString).toEqual(mockUserKeyEncryptedPin); - - // Assert - ephemeral envelope should be cleared - const ephemeralResult = await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL"); - expect(ephemeralResult).toBeNull(); - }); - it("changes PIN lock type from EPHEMERAL to DISABLED when no other PIN state exists", async () => { // Arrange - set up only ephemeral PIN state await sut.setPinState( diff --git a/libs/common/src/key-management/pin/pin.service.abstraction.ts b/libs/common/src/key-management/pin/pin.service.abstraction.ts index b242320c06e..c4551d3522f 100644 --- a/libs/common/src/key-management/pin/pin.service.abstraction.ts +++ b/libs/common/src/key-management/pin/pin.service.abstraction.ts @@ -1,8 +1,5 @@ -// eslint-disable-next-line no-restricted-imports -import { KdfConfig } from "@bitwarden/key-management"; - import { UserId } from "../../types/guid"; -import { PinKey, UserKey } from "../../types/key"; +import { UserKey } from "../../types/key"; import { PinLockType } from "./pin-lock-type"; @@ -69,10 +66,4 @@ export abstract class PinServiceAbstraction { * @deprecated This is not deprecated, but only meant to be called by KeyService. DO NOT USE IT. */ abstract userUnlocked(userId: UserId): Promise<void>; - - /** - * Makes a PinKey from the provided PIN. - * @deprecated - Note: This is currently re-used by vault exports, which is still permitted but should be refactored out to use a different construct. - */ - abstract makePinKey(pin: string, salt: string, kdfConfig: KdfConfig): Promise<PinKey>; } diff --git a/libs/common/src/key-management/pin/pin.service.implementation.ts b/libs/common/src/key-management/pin/pin.service.implementation.ts index 29a10d1a0a4..da6d3f20eaf 100644 --- a/libs/common/src/key-management/pin/pin.service.implementation.ts +++ b/libs/common/src/key-management/pin/pin.service.implementation.ts @@ -1,18 +1,15 @@ import { firstValueFrom, map } from "rxjs"; // eslint-disable-next-line no-restricted-imports -import { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management"; +import { KeyService } from "@bitwarden/key-management"; -import { AccountService } from "../../auth/abstractions/account.service"; import { assertNonNullish } from "../../auth/utils"; import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "../../key-management/crypto/models/enc-string"; import { LogService } from "../../platform/abstractions/log.service"; import { SdkService } from "../../platform/abstractions/sdk/sdk.service"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { UserId } from "../../types/guid"; -import { PinKey, UserKey } from "../../types/key"; -import { KeyGenerationService } from "../crypto"; +import { UserKey } from "../../types/key"; import { firstValueFromOrThrow } from "../utils"; import { PinLockType } from "./pin-lock-type"; @@ -21,10 +18,7 @@ import { PinServiceAbstraction } from "./pin.service.abstraction"; export class PinService implements PinServiceAbstraction { constructor( - private accountService: AccountService, private encryptService: EncryptService, - private kdfConfigService: KdfConfigService, - private keyGenerationService: KeyGenerationService, private logService: LogService, private keyService: KeyService, private sdkService: SdkService, @@ -56,19 +50,6 @@ export class PinService implements PinServiceAbstraction { // On first unlock, set the ephemeral pin envelope, if it is not set yet const pin = await this.getPin(userId); await this.setPin(pin, "EPHEMERAL", userId); - } else if ((await this.pinStateService.getPinLockType(userId)) === "PERSISTENT") { - // Encrypted migration for persistent pin unlock to pin envelopes. - // This will be removed at the earliest in 2026.1.0 - // - // ----- ENCRYPTION MIGRATION ----- - // Pin-key encrypted user-keys are eagerly migrated to the new pin-protected user key envelope format. - if ((await this.pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent(userId)) != null) { - this.logService.info( - "[Pin Service] Migrating legacy PIN key to PinProtectedUserKeyEnvelope", - ); - const pin = await this.getPin(userId); - await this.setPin(pin, "PERSISTENT", userId); - } } } @@ -144,86 +125,30 @@ export class PinService implements PinServiceAbstraction { assertNonNullish(pin, "pin"); assertNonNullish(userId, "userId"); - const hasPinProtectedKeyEnvelopeSet = - (await this.pinStateService.getPinProtectedUserKeyEnvelope(userId, "EPHEMERAL")) != null || - (await this.pinStateService.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null; + this.logService.info("[Pin Service] Pin-unlock via PinProtectedUserKeyEnvelope"); - if (hasPinProtectedKeyEnvelopeSet) { - this.logService.info("[Pin Service] Pin-unlock via PinProtectedUserKeyEnvelope"); + const pinLockType = await this.pinStateService.getPinLockType(userId); + const envelope = await this.pinStateService.getPinProtectedUserKeyEnvelope(userId, pinLockType); - const pinLockType = await this.pinStateService.getPinLockType(userId); - const envelope = await this.pinStateService.getPinProtectedUserKeyEnvelope( - userId, - pinLockType, + try { + // Use the sdk to create an enrollment, not yet persisting it to state + const startTime = performance.now(); + const userKeyBytes = await firstValueFrom( + this.sdkService.client$.pipe( + map((sdk) => { + if (!sdk) { + throw new Error("SDK not available"); + } + return sdk.crypto().unseal_password_protected_key_envelope(pin, envelope!); + }), + ), ); + this.logService.measure(startTime, "Crypto", "PinService", "UnsealPinEnvelope"); - try { - // Use the sdk to create an enrollment, not yet persisting it to state - const startTime = performance.now(); - const userKeyBytes = await firstValueFrom( - this.sdkService.client$.pipe( - map((sdk) => { - if (!sdk) { - throw new Error("SDK not available"); - } - return sdk.crypto().unseal_password_protected_key_envelope(pin, envelope!); - }), - ), - ); - this.logService.measure(startTime, "Crypto", "PinService", "UnsealPinEnvelope"); - - return new SymmetricCryptoKey(userKeyBytes) as UserKey; - } catch (error) { - this.logService.error(`Failed to unseal pin: ${error}`); - return null; - } - } else { - this.logService.info("[Pin Service] Pin-unlock via legacy PinKeyEncryptedUserKey"); - - // This branch is deprecated and will be removed in the future, but is kept for migration. - try { - const pinKeyEncryptedUserKey = - await this.pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent(userId); - const email = await firstValueFrom( - this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)), - ); - const kdfConfig = await this.kdfConfigService.getKdfConfig(userId); - return await this.decryptUserKey(pin, email, kdfConfig, pinKeyEncryptedUserKey!); - } catch (error) { - this.logService.error(`Error decrypting user key with pin: ${error}`); - return null; - } + return new SymmetricCryptoKey(userKeyBytes) as UserKey; + } catch (error) { + this.logService.error(`Failed to unseal pin: ${error}`); + return null; } } - - /// Anything below here is deprecated and will be removed subsequently - - async makePinKey(pin: string, salt: string, kdfConfig: KdfConfig): Promise<PinKey> { - const startTime = performance.now(); - const pinKey = await this.keyGenerationService.deriveKeyFromPassword(pin, salt, kdfConfig); - this.logService.measure(startTime, "Crypto", "PinService", "makePinKey"); - - return (await this.keyGenerationService.stretchKey(pinKey)) as PinKey; - } - - /** - * Decrypts the UserKey with the provided PIN. - * @deprecated - * @throws If the PIN does not match the PIN that was used to encrypt the user key - * @throws If the salt, or KDF don't match the salt / KDF used to encrypt the user key - */ - private async decryptUserKey( - pin: string, - salt: string, - kdfConfig: KdfConfig, - pinKeyEncryptedUserKey: EncString, - ): Promise<UserKey> { - assertNonNullish(pin, "pin"); - assertNonNullish(salt, "salt"); - assertNonNullish(kdfConfig, "kdfConfig"); - assertNonNullish(pinKeyEncryptedUserKey, "pinKeyEncryptedUserKey"); - const pinKey = await this.makePinKey(pin, salt, kdfConfig); - const userKey = await this.encryptService.unwrapSymmetricKey(pinKeyEncryptedUserKey, pinKey); - return userKey as UserKey; - } } diff --git a/libs/common/src/key-management/pin/pin.service.spec.ts b/libs/common/src/key-management/pin/pin.service.spec.ts index 8d78255bb1b..644fd1d8d75 100644 --- a/libs/common/src/key-management/pin/pin.service.spec.ts +++ b/libs/common/src/key-management/pin/pin.service.spec.ts @@ -2,17 +2,15 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, filter } from "rxjs"; // eslint-disable-next-line no-restricted-imports -import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management"; +import { KeyService } from "@bitwarden/key-management"; import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal"; import { MockSdkService } from "../..//platform/spec/mock-sdk.service"; -import { FakeAccountService, mockAccountServiceWith, mockEnc } from "../../../spec"; import { LogService } from "../../platform/abstractions/log.service"; import { Utils } from "../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { UserId } from "../../types/guid"; -import { PinKey, UserKey } from "../../types/key"; -import { KeyGenerationService } from "../crypto"; +import { UserKey } from "../../types/key"; import { EncryptService } from "../crypto/abstractions/encrypt.service"; import { EncryptedString, EncString } from "../crypto/models/enc-string"; @@ -22,16 +20,10 @@ import { PinService } from "./pin.service.implementation"; describe("PinService", () => { let sut: PinService; - let accountService: FakeAccountService; - const encryptService = mock<EncryptService>(); - const kdfConfigService = mock<KdfConfigService>(); - const keyGenerationService = mock<KeyGenerationService>(); const logService = mock<LogService>(); const mockUserId = Utils.newGuid() as UserId; const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const mockPinKey = new SymmetricCryptoKey(randomBytes(32)) as PinKey; - const mockUserEmail = "user@example.com"; const mockPin = "1234"; const mockUserKeyEncryptedPin = new EncString("userKeyEncryptedPin"); const mockEphemeralEnvelope = "mock-ephemeral-envelope" as PasswordProtectedKeyEnvelope; @@ -42,7 +34,6 @@ describe("PinService", () => { const behaviorSubject = new BehaviorSubject<{ userId: UserId; userKey: UserKey }>(null); beforeEach(() => { - accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail }); (keyService as any)["unlockedUserKeys$"] = behaviorSubject .asObservable() .pipe(filter((x) => x != null)); @@ -50,16 +41,7 @@ describe("PinService", () => { .mockDeep() .unseal_password_protected_key_envelope.mockReturnValue(new Uint8Array(64)); - sut = new PinService( - accountService, - encryptService, - kdfConfigService, - keyGenerationService, - logService, - keyService, - sdkService, - pinStateService, - ); + sut = new PinService(encryptService, logService, keyService, sdkService, pinStateService); }); it("should instantiate the PinService", () => { @@ -89,26 +71,6 @@ describe("PinService", () => { ); }); - it("should migrate legacy persistent PIN if needed", async () => { - // Arrange - pinStateService.getPinLockType.mockResolvedValue("PERSISTENT"); - pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent.mockResolvedValue( - mockEnc("legacy-key"), - ); - const getPinSpy = jest.spyOn(sut, "getPin").mockResolvedValue(mockPin); - const setPinSpy = jest.spyOn(sut, "setPin").mockResolvedValue(); - - // Act - await sut.userUnlocked(mockUserId); - - // Assert - expect(getPinSpy).toHaveBeenCalledWith(mockUserId); - expect(setPinSpy).toHaveBeenCalledWith(mockPin, "PERSISTENT", mockUserId); - expect(logService.info).toHaveBeenCalledWith( - "[Pin Service] Migrating legacy PIN key to PinProtectedUserKeyEnvelope", - ); - }); - it("should do nothing if no migration or setup is needed", async () => { // Arrange pinStateService.getPinLockType.mockResolvedValue("DISABLED"); @@ -124,28 +86,6 @@ describe("PinService", () => { }); }); - describe("makePinKey()", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("should make a PinKey", async () => { - // Arrange - keyGenerationService.deriveKeyFromPassword.mockResolvedValue(mockPinKey); - - // Act - await sut.makePinKey(mockPin, mockUserEmail, DEFAULT_KDF_CONFIG); - - // Assert - expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith( - mockPin, - mockUserEmail, - DEFAULT_KDF_CONFIG, - ); - expect(keyGenerationService.stretchKey).toHaveBeenCalledWith(mockPinKey); - }); - }); - describe("getPin()", () => { beforeEach(() => { jest.clearAllMocks(); @@ -383,7 +323,6 @@ describe("PinService", () => { jest.clearAllMocks(); pinStateService.userKeyEncryptedPin$.mockReset(); pinStateService.getPinProtectedUserKeyEnvelope.mockReset(); - pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent.mockReset(); }); it("should throw an error if userId is null", async () => { @@ -423,32 +362,5 @@ describe("PinService", () => { // Assert expect(result).toEqual(mockUserKey); }); - - it("should return userkey with legacy pin PERSISTENT", async () => { - keyGenerationService.deriveKeyFromPassword.mockResolvedValue(mockPinKey); - keyGenerationService.stretchKey.mockResolvedValue(mockPinKey); - kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG); - encryptService.unwrapSymmetricKey.mockResolvedValue(mockUserKey); - - // Arrange - const mockPin = "1234"; - pinStateService.userKeyEncryptedPin$.mockReturnValueOnce( - new BehaviorSubject(mockUserKeyEncryptedPin), - ); - pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent.mockResolvedValueOnce( - mockUserKeyEncryptedPin, - ); - - // Act - const result = await sut.decryptUserKeyWithPin(mockPin, mockUserId); - - // Assert - expect(result).toEqual(mockUserKey); - }); }); }); - -// Test helpers -function randomBytes(length: number): Uint8Array { - return new Uint8Array(Array.from({ length }, (_, k) => k % 255)); -} diff --git a/libs/common/src/key-management/pin/pin.state.ts b/libs/common/src/key-management/pin/pin.state.ts index 4ad0524035f..c3bbad7644c 100644 --- a/libs/common/src/key-management/pin/pin.state.ts +++ b/libs/common/src/key-management/pin/pin.state.ts @@ -3,22 +3,6 @@ import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal"; import { EncryptedString } from "../crypto/models/enc-string"; -/** - * The persistent (stored on disk) version of the UserKey, encrypted by the PinKey. - * - * @deprecated - * @remarks Persists through a client reset. Used when `requireMasterPasswordOnClientRestart` is disabled. - * @see SetPinComponent.setPinForm.requireMasterPasswordOnClientRestart - */ -export const PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT = new UserKeyDefinition<EncryptedString>( - PIN_DISK, - "pinKeyEncryptedUserKeyPersistent", - { - deserializer: (jsonValue) => jsonValue, - clearOn: ["logout"], - }, -); - /** * The persistent (stored on disk) version of the UserKey, stored in a `PasswordProtectedKeyEnvelope`. * diff --git a/libs/common/src/key-management/services/default-process-reload.service.ts b/libs/common/src/key-management/services/default-process-reload.service.ts index ddbcf7e5530..ecd9ddafa40 100644 --- a/libs/common/src/key-management/services/default-process-reload.service.ts +++ b/libs/common/src/key-management/services/default-process-reload.service.ts @@ -56,7 +56,7 @@ export class DefaultProcessReloadService implements ProcessReloadServiceAbstract return; } - // If there is an active user, check if they have a pinKeyEncryptedUserKeyEphemeral. If so, prevent process reload upon lock. + // If there is an active user, check if they have an ephemeral PIN. If so, prevent process reload upon lock. const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; if (userId != null) { if ((await this.pinService.getPinLockType(userId)) === "EPHEMERAL") { diff --git a/libs/common/src/types/key.ts b/libs/common/src/types/key.ts index 46dcc14a8b2..3c5487b30e0 100644 --- a/libs/common/src/types/key.ts +++ b/libs/common/src/types/key.ts @@ -9,8 +9,6 @@ export type PrfKey = Opaque<SymmetricCryptoKey, "PrfKey">; export type UserKey = Opaque<SymmetricCryptoKey, "UserKey">; /** @deprecated Interacting with the master key directly is prohibited. Use a high level function from MasterPasswordService instead. */ export type MasterKey = Opaque<SymmetricCryptoKey, "MasterKey">; -/** @deprecated */ -export type PinKey = Opaque<SymmetricCryptoKey, "PinKey">; export type OrgKey = Opaque<SymmetricCryptoKey, "OrgKey">; export type ProviderKey = Opaque<SymmetricCryptoKey, "ProviderKey">; export type CipherKey = Opaque<SymmetricCryptoKey, "CipherKey">; diff --git a/libs/importer/src/components/importer-providers.ts b/libs/importer/src/components/importer-providers.ts index c48f7c1b096..18c148ebe2e 100644 --- a/libs/importer/src/components/importer-providers.ts +++ b/libs/importer/src/components/importer-providers.ts @@ -7,8 +7,8 @@ import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/sa import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -84,7 +84,7 @@ export const ImporterProviders: SafeProvider[] = [ CollectionService, KeyService, EncryptService, - PinServiceAbstraction, + KeyGenerationService, AccountService, RestrictedItemTypesService, ], diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts index 44a55af8f62..6e98b21977d 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts @@ -2,8 +2,8 @@ import { mock, MockProxy } from "jest-mock-extended"; import { of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { emptyGuid, OrganizationId } from "@bitwarden/common/types/guid"; @@ -24,7 +24,7 @@ describe("BitwardenPasswordProtectedImporter", () => { let encryptService: MockProxy<EncryptService>; let i18nService: MockProxy<I18nService>; let cipherService: MockProxy<CipherService>; - let pinService: MockProxy<PinServiceAbstraction>; + let keyGenerationService: MockProxy<KeyGenerationService>; let accountService: MockProxy<AccountService>; const password = Utils.newGuid(); const promptForPassword_callback = async () => { @@ -36,7 +36,7 @@ describe("BitwardenPasswordProtectedImporter", () => { encryptService = mock<EncryptService>(); i18nService = mock<I18nService>(); cipherService = mock<CipherService>(); - pinService = mock<PinServiceAbstraction>(); + keyGenerationService = mock<KeyGenerationService>(); accountService = mock<AccountService>(); accountService.activeAccount$ = of({ @@ -71,7 +71,7 @@ describe("BitwardenPasswordProtectedImporter", () => { encryptService, i18nService, cipherService, - pinService, + keyGenerationService, accountService, promptForPassword_callback, ); @@ -105,7 +105,7 @@ describe("BitwardenPasswordProtectedImporter", () => { encryptService, i18nService, cipherService, - pinService, + keyGenerationService, accountService, promptForPassword_callback, ); diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts index 7062089482d..b685ddf0fb5 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts @@ -1,9 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -29,7 +29,7 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im encryptService: EncryptService, i18nService: I18nService, cipherService: CipherService, - private pinService: PinServiceAbstraction, + private keyGenerationService: KeyGenerationService, accountService: AccountService, private promptForPassword_callback: () => Promise<string>, ) { @@ -86,7 +86,7 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im ? new PBKDF2KdfConfig(jdoc.kdfIterations) : new Argon2KdfConfig(jdoc.kdfIterations, jdoc.kdfMemory, jdoc.kdfParallelism); - this.key = await this.pinService.makePinKey(password, jdoc.salt, kdfConfig); + this.key = await this.keyGenerationService.deriveVaultExportKey(password, jdoc.salt, kdfConfig); const encKeyValidation = new EncString(jdoc.encKeyValidation_DO_NOT_EDIT); diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index b1c028ff063..b82772669de 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -8,8 +8,8 @@ import { CollectionView, } from "@bitwarden/admin-console/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; @@ -36,7 +36,7 @@ describe("ImportService", () => { let collectionService: MockProxy<CollectionService>; let keyService: MockProxy<KeyService>; let encryptService: MockProxy<EncryptService>; - let pinService: MockProxy<PinServiceAbstraction>; + let keyGenerationService: MockProxy<KeyGenerationService>; let accountService: MockProxy<AccountService>; let restrictedItemTypesService: MockProxy<RestrictedItemTypesService>; @@ -48,7 +48,7 @@ describe("ImportService", () => { collectionService = mock<CollectionService>(); keyService = mock<KeyService>(); encryptService = mock<EncryptService>(); - pinService = mock<PinServiceAbstraction>(); + keyGenerationService = mock<KeyGenerationService>(); restrictedItemTypesService = mock<RestrictedItemTypesService>(); importService = new ImportService( @@ -59,7 +59,7 @@ describe("ImportService", () => { collectionService, keyService, encryptService, - pinService, + keyGenerationService, accountService, restrictedItemTypesService, ); diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index f62054f9414..400beae5179 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -12,8 +12,8 @@ import { } from "@bitwarden/admin-console/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { ImportCiphersRequest } from "@bitwarden/common/models/request/import-ciphers.request"; import { ImportOrganizationCiphersRequest } from "@bitwarden/common/models/request/import-organization-ciphers.request"; import { KvpRequest } from "@bitwarden/common/models/request/kvp.request"; @@ -119,7 +119,7 @@ export class ImportService implements ImportServiceAbstraction { private collectionService: CollectionService, private keyService: KeyService, private encryptService: EncryptService, - private pinService: PinServiceAbstraction, + private keyGenerationService: KeyGenerationService, private accountService: AccountService, private restrictedItemTypesService: RestrictedItemTypesService, ) {} @@ -238,7 +238,7 @@ export class ImportService implements ImportServiceAbstraction { this.encryptService, this.i18nService, this.cipherService, - this.pinService, + this.keyGenerationService, this.accountService, promptForPassword_callback, ); diff --git a/libs/state/src/state-migrations/migrate.ts b/libs/state/src/state-migrations/migrate.ts index bf4cd17adba..a051c25695a 100644 --- a/libs/state/src/state-migrations/migrate.ts +++ b/libs/state/src/state-migrations/migrate.ts @@ -70,12 +70,13 @@ import { RemoveAcBannersDismissed } from "./migrations/70-remove-ac-banner-dismi import { RemoveNewCustomizationOptionsCalloutDismissed } from "./migrations/71-remove-new-customization-options-callout-dismissed"; import { RemoveAccountDeprovisioningBannerDismissed } from "./migrations/72-remove-account-deprovisioning-banner-dismissed"; import { AddMasterPasswordUnlockData } from "./migrations/73-add-master-password-unlock-data"; +import { RemoveLegacyPin } from "./migrations/74-remove-legacy-pin"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global"; import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 73; +export const CURRENT_VERSION = 74; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -150,7 +151,8 @@ export function createMigrationBuilder() { .with(RemoveAcBannersDismissed, 69, 70) .with(RemoveNewCustomizationOptionsCalloutDismissed, 70, 71) .with(RemoveAccountDeprovisioningBannerDismissed, 71, 72) - .with(AddMasterPasswordUnlockData, 72, CURRENT_VERSION); + .with(AddMasterPasswordUnlockData, 72, 73) + .with(RemoveLegacyPin, 73, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/state/src/state-migrations/migrations/74-remove-legacy-pin.spec.ts b/libs/state/src/state-migrations/migrations/74-remove-legacy-pin.spec.ts new file mode 100644 index 00000000000..842410b2706 --- /dev/null +++ b/libs/state/src/state-migrations/migrations/74-remove-legacy-pin.spec.ts @@ -0,0 +1,50 @@ +import { runMigrator } from "../migration-helper.spec"; +import { IRREVERSIBLE } from "../migrator"; + +import { RemoveLegacyPin } from "./74-remove-legacy-pin"; + +describe("RemoveLegacyPin", () => { + const sut = new RemoveLegacyPin(73, 74); + + describe("migrate", () => { + it("deletes legacy pin from all users", async () => { + const output = await runMigrator(sut, { + global_account_accounts: { + user1: { + email: "user1@email.com", + name: "User 1", + emailVerified: true, + }, + user2: { + email: "user2@email.com", + name: "User 2", + emailVerified: true, + }, + }, + user_user1_pinUnlock_pinKeyEncryptedUserKeyPersistent: "abc", + user_user2_pinUnlock_pinKeyEncryptedUserKeyPersistent: "def", + }); + + expect(output).toEqual({ + global_account_accounts: { + user1: { + email: "user1@email.com", + name: "User 1", + emailVerified: true, + }, + user2: { + email: "user2@email.com", + name: "User 2", + emailVerified: true, + }, + }, + }); + }); + }); + + describe("rollback", () => { + it("is irreversible", async () => { + await expect(runMigrator(sut, {}, "rollback")).rejects.toThrow(IRREVERSIBLE); + }); + }); +}); diff --git a/libs/state/src/state-migrations/migrations/74-remove-legacy-pin.ts b/libs/state/src/state-migrations/migrations/74-remove-legacy-pin.ts new file mode 100644 index 00000000000..277ae5832b5 --- /dev/null +++ b/libs/state/src/state-migrations/migrations/74-remove-legacy-pin.ts @@ -0,0 +1,30 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { IRREVERSIBLE, Migrator } from "../migrator"; + +type ExpectedAccountType = NonNullable<unknown>; + +export const PinProtectedUserKey: KeyDefinitionLike = { + key: "pinKeyEncryptedUserKeyPersistent", + stateDefinition: { + name: "pinUnlock", + }, +}; + +export class RemoveLegacyPin extends Migrator<73, 74> { + async migrate(helper: MigrationHelper): Promise<void> { + const accounts = await helper.getAccounts<ExpectedAccountType>(); + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise<void> { + const pinProtectedUserKey = await helper.getFromUser(userId, PinProtectedUserKey); + + if (pinProtectedUserKey != null) { + await helper.removeFromUser(userId, PinProtectedUserKey); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise<void> { + throw IRREVERSIBLE; + } +} diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts index c11ab3d9e6b..620f465789c 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/base-vault-export.service.ts @@ -1,8 +1,8 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -12,7 +12,7 @@ import { KdfConfig, KdfConfigService, KdfType } from "@bitwarden/key-management" import { BitwardenCsvExportType, BitwardenPasswordProtectedFileFormat } from "../types"; export class BaseVaultExportService { constructor( - protected pinService: PinServiceAbstraction, + protected keyGenerationService: KeyGenerationService, protected encryptService: EncryptService, private cryptoFunctionService: CryptoFunctionService, private kdfConfigService: KdfConfigService, @@ -26,7 +26,8 @@ export class BaseVaultExportService { const kdfConfig: KdfConfig = await this.kdfConfigService.getKdfConfig(userId); const salt = Utils.fromBufferToB64(await this.cryptoFunctionService.randomBytes(16)); - const key = await this.pinService.makePinKey(password, salt, kdfConfig); + + const key = await this.keyGenerationService.deriveVaultExportKey(password, salt, kdfConfig); const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), key); const encText = await this.encryptService.encryptString(clearText, key); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index 4214873feed..33dde9ae51a 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -3,13 +3,13 @@ import * as JSZip from "jszip"; import { BehaviorSubject, of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptedString, EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherId, emptyGuid, UserId } from "@bitwarden/common/types/guid"; @@ -169,7 +169,7 @@ describe("VaultExportService", () => { let exportService: IndividualVaultExportService; let cryptoFunctionService: MockProxy<CryptoFunctionService>; let cipherService: MockProxy<CipherService>; - let pinService: MockProxy<PinServiceAbstraction>; + let keyGenerationService: MockProxy<KeyGenerationService>; let folderService: MockProxy<FolderService>; let keyService: MockProxy<KeyService>; let encryptService: MockProxy<EncryptService>; @@ -184,7 +184,7 @@ describe("VaultExportService", () => { beforeEach(() => { cryptoFunctionService = mock<CryptoFunctionService>(); cipherService = mock<CipherService>(); - pinService = mock<PinServiceAbstraction>(); + keyGenerationService = mock<KeyGenerationService>(); folderService = mock<FolderService>(); keyService = mock<KeyService>(); encryptService = mock<EncryptService>(); @@ -220,7 +220,7 @@ describe("VaultExportService", () => { exportService = new IndividualVaultExportService( folderService, cipherService, - pinService, + keyGenerationService, keyService, encryptService, cryptoFunctionService, diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index e7a97801e09..ddda96b21e0 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -5,9 +5,9 @@ import * as papa from "papaparse"; import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { CipherWithIdExport, FolderWithIdExport } from "@bitwarden/common/models/export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherId, UserId } from "@bitwarden/common/types/guid"; @@ -42,7 +42,7 @@ export class IndividualVaultExportService constructor( private folderService: FolderService, private cipherService: CipherService, - pinService: PinServiceAbstraction, + keyGenerationService: KeyGenerationService, private keyService: KeyService, encryptService: EncryptService, cryptoFunctionService: CryptoFunctionService, @@ -50,7 +50,7 @@ export class IndividualVaultExportService private apiService: ApiService, private restrictedItemTypesService: RestrictedItemTypesService, ) { - super(pinService, encryptService, cryptoFunctionService, kdfConfigService); + super(keyGenerationService, encryptService, cryptoFunctionService, kdfConfigService); } /** Creates an export of an individual vault (My Vault). Based on the provided format it will either be unencrypted, encrypted or password protected and in case zip is selected will include attachments diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts index 678dd600f94..ed3a16516f2 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/org-vault-export.service.ts @@ -10,9 +10,9 @@ import { CollectionDetailsResponse, CollectionView, } from "@bitwarden/admin-console/common"; +import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { CipherWithIdExport, CollectionWithIdExport } from "@bitwarden/common/models/export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; @@ -46,7 +46,7 @@ export class OrganizationVaultExportService constructor( private cipherService: CipherService, private vaultExportApiService: VaultExportApiService, - pinService: PinServiceAbstraction, + keyGenerationService: KeyGenerationService, private keyService: KeyService, encryptService: EncryptService, cryptoFunctionService: CryptoFunctionService, @@ -54,7 +54,7 @@ export class OrganizationVaultExportService kdfConfigService: KdfConfigService, private restrictedItemTypesService: RestrictedItemTypesService, ) { - super(pinService, encryptService, cryptoFunctionService, kdfConfigService); + super(keyGenerationService, encryptService, cryptoFunctionService, kdfConfigService); } /** Creates a password protected export of an organizational vault. From 33d909b0bbe0ce0d9658404a4bbc998eb18627ed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:28:40 +0100 Subject: [PATCH 42/60] [deps] Platform: Update Rust crate rand to v0.9.2 (#17550) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 12 ++++++------ apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 53eee09d9b8..5978659f21e 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -130,7 +130,7 @@ dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand 0.9.1", + "rand 0.9.2", "serde", "serde_repr", "tokio", @@ -590,7 +590,7 @@ dependencies = [ "hex", "oo7", "pbkdf2", - "rand 0.9.1", + "rand 0.9.2", "rusqlite", "security-framework", "serde", @@ -833,7 +833,7 @@ dependencies = [ "memsec", "oo7", "pin-project", - "rand 0.9.1", + "rand 0.9.2", "scopeguard", "secmem-proc", "security-framework", @@ -2153,7 +2153,7 @@ dependencies = [ "num", "num-bigint-dig", "pbkdf2", - "rand 0.9.1", + "rand 0.9.2", "serde", "sha2", "subtle", @@ -2536,9 +2536,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 74a1b6bb1da..26f791fd660 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -48,7 +48,7 @@ napi-derive = "=3.2.5" oo7 = "=0.5.0" pin-project = "=1.1.10" pkcs8 = "=0.10.2" -rand = "=0.9.1" +rand = "=0.9.2" rsa = "=0.9.6" russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" From bcc2bda4174c6e1c61feee1fc9a84a24bdbcda07 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann <mail@quexten.com> Date: Thu, 11 Dec 2025 14:29:48 +0100 Subject: [PATCH 43/60] Fix kdf prompt not working on browser (#17902) --- .../encrypted-migrations-scheduler.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts index cf79c65e998..dd248a582d3 100644 --- a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts +++ b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts @@ -38,7 +38,7 @@ export const ENCRYPTED_MIGRATION_DISMISSED = new UserKeyDefinition<Date>( }, ); const DISMISS_TIME_HOURS = 24; -const VAULT_ROUTE = "/vault"; +const VAULT_ROUTES = ["/vault", "/tabs/vault", "/tabs/current"]; /** * This services schedules encrypted migrations for users on clients that are interactive (non-cli), and handles manual interaction, @@ -85,7 +85,7 @@ export class DefaultEncryptedMigrationsSchedulerService implements EncryptedMigr ]).pipe( filter( ([authStatus, _date, url]) => - authStatus === AuthenticationStatus.Unlocked && url === VAULT_ROUTE, + authStatus === AuthenticationStatus.Unlocked && VAULT_ROUTES.includes(url), ), concatMap(() => this.runMigrationsIfNeeded(userId)), ), From 458da1adc0eca479ff35f5a4b1b327b37ad33d53 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:38:33 +0100 Subject: [PATCH 44/60] [PM-29565] Delete deprecated callout component (#17895) * Replace usages of app-callout with bit-callout * Delete callout.component --------- Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com> --- .../components/approve-ssh-request.html | 10 ++- .../components/approve-ssh-request.ts | 2 + .../auth/verify-recover-delete.component.html | 2 +- ...ify-recover-delete-provider.component.html | 2 +- .../src/components/callout.component.html | 26 ------- .../src/components/callout.component.ts | 70 ------------------- libs/angular/src/jslib.module.ts | 3 - 7 files changed, 8 insertions(+), 107 deletions(-) delete mode 100644 libs/angular/src/components/callout.component.html delete mode 100644 libs/angular/src/components/callout.component.ts diff --git a/apps/desktop/src/platform/components/approve-ssh-request.html b/apps/desktop/src/platform/components/approve-ssh-request.html index 55092788079..c691891487e 100644 --- a/apps/desktop/src/platform/components/approve-ssh-request.html +++ b/apps/desktop/src/platform/components/approve-ssh-request.html @@ -2,13 +2,11 @@ <bit-dialog> <div class="tw-font-medium" bitDialogTitle>{{ "sshkeyApprovalTitle" | i18n }}</div> <div bitDialogContent> - <app-callout - type="warning" - title="{{ 'agentForwardingWarningTitle' | i18n }}" - *ngIf="params.isAgentForwarding" - > + @if (params.isAgentForwarding) { + <bit-callout type="warning" title="{{ 'agentForwardingWarningTitle' | i18n }}"> {{ 'agentForwardingWarningText' | i18n }} - </app-callout> + </bit-callout> + } <b>{{params.applicationName}}</b> {{ "sshkeyApprovalMessageInfix" | i18n }} <b>{{params.cipherName}}</b> diff --git a/apps/desktop/src/platform/components/approve-ssh-request.ts b/apps/desktop/src/platform/components/approve-ssh-request.ts index 1741124774d..a2cae3d59e7 100644 --- a/apps/desktop/src/platform/components/approve-ssh-request.ts +++ b/apps/desktop/src/platform/components/approve-ssh-request.ts @@ -12,6 +12,7 @@ import { FormFieldModule, IconButtonModule, DialogService, + CalloutModule, } from "@bitwarden/components"; export interface ApproveSshRequestParams { @@ -35,6 +36,7 @@ export interface ApproveSshRequestParams { ReactiveFormsModule, AsyncActionsModule, FormFieldModule, + CalloutModule, ], }) export class ApproveSshRequestComponent { diff --git a/apps/web/src/app/auth/verify-recover-delete.component.html b/apps/web/src/app/auth/verify-recover-delete.component.html index 02581b21418..27eda24a118 100644 --- a/apps/web/src/app/auth/verify-recover-delete.component.html +++ b/apps/web/src/app/auth/verify-recover-delete.component.html @@ -1,5 +1,5 @@ <form [bitSubmit]="submit" [formGroup]="formGroup"> - <app-callout type="warning">{{ "deleteAccountWarning" | i18n }}</app-callout> + <bit-callout type="warning">{{ "deleteAccountWarning" | i18n }}</bit-callout> <p bitTypography="body1" class="tw-text-center"> <strong>{{ email }}</strong> </p> diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.html index e1f99122b22..6965dbe8198 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.html @@ -1,5 +1,5 @@ <h2 class="tw-text-center tw-mb-4">{{ "deleteProvider" | i18n }}</h2> -<app-callout type="warning">{{ "deleteProviderWarning" | i18n }}</app-callout> +<bit-callout type="warning">{{ "deleteProviderWarning" | i18n }}</bit-callout> <p class="tw-text-center"> <strong>{{ name }}</strong> </p> diff --git a/libs/angular/src/components/callout.component.html b/libs/angular/src/components/callout.component.html deleted file mode 100644 index 7e352fa0ced..00000000000 --- a/libs/angular/src/components/callout.component.html +++ /dev/null @@ -1,26 +0,0 @@ -<bit-callout [icon]="icon" [title]="title" [type]="$any(type)" [useAlertRole]="useAlertRole"> - <div class="tw-pl-7 tw-m-0" *ngIf="enforcedPolicyOptions"> - {{ enforcedPolicyMessage }} - <ul> - <li *ngIf="enforcedPolicyOptions?.minComplexity > 0"> - {{ "policyInEffectMinComplexity" | i18n: getPasswordScoreAlertDisplay() }} - </li> - <li *ngIf="enforcedPolicyOptions?.minLength > 0"> - {{ "policyInEffectMinLength" | i18n: enforcedPolicyOptions?.minLength.toString() }} - </li> - <li *ngIf="enforcedPolicyOptions?.requireUpper"> - {{ "policyInEffectUppercase" | i18n }} - </li> - <li *ngIf="enforcedPolicyOptions?.requireLower"> - {{ "policyInEffectLowercase" | i18n }} - </li> - <li *ngIf="enforcedPolicyOptions?.requireNumbers"> - {{ "policyInEffectNumbers" | i18n }} - </li> - <li *ngIf="enforcedPolicyOptions?.requireSpecial"> - {{ "policyInEffectSpecial" | i18n: "!@#$%^&*" }} - </li> - </ul> - </div> - <ng-content></ng-content> -</bit-callout> diff --git a/libs/angular/src/components/callout.component.ts b/libs/angular/src/components/callout.component.ts deleted file mode 100644 index 9630b761076..00000000000 --- a/libs/angular/src/components/callout.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input, OnInit } from "@angular/core"; - -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CalloutTypes } from "@bitwarden/components"; - -/** - * @deprecated use the CL's `CalloutComponent` instead - */ -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@Component({ - selector: "app-callout", - templateUrl: "callout.component.html", - standalone: false, -}) -export class DeprecatedCalloutComponent implements OnInit { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() type: CalloutTypes = "info"; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() icon: string; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() title: string; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() enforcedPolicyOptions: MasterPasswordPolicyOptions; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() enforcedPolicyMessage: string; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() useAlertRole = false; - - calloutStyle: string; - - constructor(private i18nService: I18nService) {} - - ngOnInit() { - this.calloutStyle = this.type; - - if (this.enforcedPolicyMessage === undefined) { - this.enforcedPolicyMessage = this.i18nService.t("masterPasswordPolicyInEffect"); - } - } - - getPasswordScoreAlertDisplay() { - if (this.enforcedPolicyOptions == null) { - return ""; - } - - let str: string; - switch (this.enforcedPolicyOptions.minComplexity) { - case 4: - str = this.i18nService.t("strong"); - break; - case 3: - str = this.i18nService.t("good"); - break; - default: - str = this.i18nService.t("weak"); - break; - } - return str + " (" + this.enforcedPolicyOptions.minComplexity + ")"; - } -} diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index 446530a1111..8d222a4aaf9 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -26,7 +26,6 @@ import { import { TwoFactorIconComponent } from "./auth/components/two-factor-icon.component"; import { NotPremiumDirective } from "./billing/directives/not-premium.directive"; -import { DeprecatedCalloutComponent } from "./components/callout.component"; import { A11yInvalidDirective } from "./directives/a11y-invalid.directive"; import { ApiActionDirective } from "./directives/api-action.directive"; import { BoxRowDirective } from "./directives/box-row.directive"; @@ -86,7 +85,6 @@ import { IconComponent } from "./vault/components/icon.component"; A11yInvalidDirective, ApiActionDirective, BoxRowDirective, - DeprecatedCalloutComponent, CopyTextDirective, CreditCardNumberPipe, EllipsisPipe, @@ -115,7 +113,6 @@ import { IconComponent } from "./vault/components/icon.component"; AutofocusDirective, ToastModule, BoxRowDirective, - DeprecatedCalloutComponent, CopyTextDirective, CreditCardNumberPipe, EllipsisPipe, From dc763f6291e70126ec9492bbe991592bc464c836 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:58:21 +0100 Subject: [PATCH 45/60] Group all tokio related packages in renovate (#17922) Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com> --- .github/renovate.json5 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 858dcccc094..96e16776545 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -214,6 +214,8 @@ "simplelog", "style-loader", "sysinfo", + "tokio", + "tokio-util", "tracing", "tracing-subscriber", "ts-node", @@ -260,6 +262,11 @@ groupName: "windows", matchPackageNames: ["windows", "windows-core", "windows-future", "windows-registry"], }, + { + // We need to group all tokio-related packages together to avoid build errors caused by version incompatibilities. + groupName: "tokio", + matchPackageNames: ["bytes", "tokio", "tokio-util"], + }, { // We group all webpack build-related minor and patch updates together to reduce PR noise. // We include patch updates here because we want PRs for webpack patch updates and it's in this group. From 50dff4e0326b3b474ea30cea6d12c358b21a26fe Mon Sep 17 00:00:00 2001 From: Brandon Treston <btreston@bitwarden.com> Date: Thu, 11 Dec 2025 10:30:05 -0500 Subject: [PATCH 46/60] [PM-28422] Client batching for member actions (#17805) * add member action batching, feature flag, and implement batching for reinvite * clean up, fix tests, remove redundant tests * cleanup * clean up tests * bump cloud limit to 8k --- .../common/people-table-data-source.ts | 2 +- .../members/members.component.ts | 2 +- .../member-actions.service.spec.ts | 343 +++++++++++++++--- .../member-actions/member-actions.service.ts | 89 ++++- 4 files changed, 378 insertions(+), 58 deletions(-) diff --git a/apps/web/src/app/admin-console/common/people-table-data-source.ts b/apps/web/src/app/admin-console/common/people-table-data-source.ts index 0228edb1e8c..9ac370d8c0d 100644 --- a/apps/web/src/app/admin-console/common/people-table-data-source.ts +++ b/apps/web/src/app/admin-console/common/people-table-data-source.ts @@ -24,7 +24,7 @@ export const MaxCheckedCount = 500; * Maximum for bulk reinvite operations when the IncreaseBulkReinviteLimitForCloud * feature flag is enabled on cloud environments. */ -export const CloudBulkReinviteLimit = 4000; +export const CloudBulkReinviteLimit = 8000; /** * Returns true if the user matches the status, or where the status is `null`, if the user is active (not revoked). diff --git a/apps/web/src/app/admin-console/organizations/members/members.component.ts b/apps/web/src/app/admin-console/organizations/members/members.component.ts index ac25278a636..51a2a6dafc0 100644 --- a/apps/web/src/app/admin-console/organizations/members/members.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/members.component.ts @@ -443,7 +443,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView> try { const result = await this.memberActionsService.bulkReinvite( organization, - filteredUsers.map((user) => user.id), + filteredUsers.map((user) => user.id as UserId), ); if (!result.successful) { diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts index e856ab7afd1..80a330b0db1 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.spec.ts @@ -4,6 +4,7 @@ import { of } from "rxjs"; import { OrganizationUserApiService, OrganizationUserBulkResponse, + OrganizationUserService, } from "@bitwarden/admin-console/common"; import { OrganizationUserType, @@ -22,9 +23,8 @@ import { newGuid } from "@bitwarden/guid"; import { KeyService } from "@bitwarden/key-management"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; -import { OrganizationUserService } from "../organization-user/organization-user.service"; -import { MemberActionsService } from "./member-actions.service"; +import { REQUESTS_PER_BATCH, MemberActionsService } from "./member-actions.service"; describe("MemberActionsService", () => { let service: MemberActionsService; @@ -308,41 +308,308 @@ describe("MemberActionsService", () => { }); describe("bulkReinvite", () => { - const userIds = [newGuid(), newGuid(), newGuid()]; + const userIds = [newGuid() as UserId, newGuid() as UserId, newGuid() as UserId]; - it("should successfully reinvite multiple users", async () => { - const mockResponse = { - data: userIds.map((id) => ({ - id, - error: null, - })), - continuationToken: null, - } as ListResponse<OrganizationUserBulkResponse>; - organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - - const result = await service.bulkReinvite(mockOrganization, userIds); - - expect(result).toEqual({ - successful: mockResponse, - failed: [], + describe("when feature flag is false", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + }); + + it("should successfully reinvite multiple users", async () => { + const mockResponse = new ListResponse( + { + data: userIds.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); + + const result = await service.bulkReinvite(mockOrganization, userIds); + + expect(result.failed).toEqual([]); + expect(result.successful).toBeDefined(); + expect(result.successful).toEqual(mockResponse); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( + organizationId, + userIds, + ); + }); + + it("should handle bulk reinvite errors", async () => { + const errorMessage = "Bulk reinvite failed"; + organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( + new Error(errorMessage), + ); + + const result = await service.bulkReinvite(mockOrganization, userIds); + + expect(result.successful).toBeUndefined(); + expect(result.failed).toHaveLength(3); + expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage }); }); - expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( - organizationId, - userIds, - ); }); - it("should handle bulk reinvite errors", async () => { - const errorMessage = "Bulk reinvite failed"; - organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( - new Error(errorMessage), - ); + describe("when feature flag is true (batching behavior)", () => { + beforeEach(() => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + }); + it("should process users in a single batch when count equals REQUESTS_PER_BATCH", async () => { + const userIdsBatch = Array.from({ length: REQUESTS_PER_BATCH }, () => newGuid() as UserId); + const mockResponse = new ListResponse( + { + data: userIdsBatch.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); - const result = await service.bulkReinvite(mockOrganization, userIds); + organizationUserApiService.postManyOrganizationUserReinvite.mockResolvedValue(mockResponse); - expect(result.successful).toBeUndefined(); - expect(result.failed).toHaveLength(3); - expect(result.failed[0]).toEqual({ id: userIds[0], error: errorMessage }); + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( + 1, + ); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledWith( + organizationId, + userIdsBatch, + ); + }); + + it("should process users in multiple batches when count exceeds REQUESTS_PER_BATCH", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.failed).toHaveLength(0); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( + 2, + ); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 1, + organizationId, + userIdsBatch.slice(0, REQUESTS_PER_BATCH), + ); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenNthCalledWith( + 2, + organizationId, + userIdsBatch.slice(REQUESTS_PER_BATCH), + ); + }); + + it("should aggregate results across multiple successful batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: userIdsBatch.slice(REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(totalUsers); + expect(result.successful?.response.slice(0, REQUESTS_PER_BATCH)).toEqual( + mockResponse1.data, + ); + expect(result.successful?.response.slice(REQUESTS_PER_BATCH)).toEqual(mockResponse2.data); + expect(result.failed).toHaveLength(0); + }); + + it("should handle mixed individual errors across multiple batches", async () => { + const totalUsers = REQUESTS_PER_BATCH + 4; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id, index) => ({ + id, + error: index % 10 === 0 ? "Rate limit exceeded" : null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: [ + { id: userIdsBatch[REQUESTS_PER_BATCH], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 1], error: "Invalid email" }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 2], error: null }, + { id: userIdsBatch[REQUESTS_PER_BATCH + 3], error: "User suspended" }, + ], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + // Count expected failures: every 10th index (0, 10, 20, ..., 490) in first batch + 2 explicit in second batch + // Indices 0 to REQUESTS_PER_BATCH-1 where index % 10 === 0: that's floor((BATCH_SIZE-1)/10) + 1 values + const expectedFailuresInBatch1 = Math.floor((REQUESTS_PER_BATCH - 1) / 10) + 1; + const expectedFailuresInBatch2 = 2; + const expectedTotalFailures = expectedFailuresInBatch1 + expectedFailuresInBatch2; + const expectedSuccesses = totalUsers - expectedTotalFailures; + + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(expectedSuccesses); + expect(result.failed).toHaveLength(expectedTotalFailures); + expect(result.failed.some((f) => f.error === "Rate limit exceeded")).toBe(true); + expect(result.failed.some((f) => f.error === "Invalid email")).toBe(true); + expect(result.failed.some((f) => f.error === "User suspended")).toBe(true); + }); + + it("should aggregate all failures when all batches fail", async () => { + const totalUsers = REQUESTS_PER_BATCH + 100; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const errorMessage = "All batches failed"; + + organizationUserApiService.postManyOrganizationUserReinvite.mockRejectedValue( + new Error(errorMessage), + ); + + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(result.successful).toBeUndefined(); + expect(result.failed).toHaveLength(totalUsers); + expect(result.failed.every((f) => f.error === errorMessage)).toBe(true); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( + 2, + ); + }); + + it("should handle empty data in batch response", async () => { + const totalUsers = REQUESTS_PER_BATCH + 50; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + + const mockResponse1 = new ListResponse( + { + data: userIdsBatch.slice(0, REQUESTS_PER_BATCH).map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + const mockResponse2 = new ListResponse( + { + data: [], + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + + organizationUserApiService.postManyOrganizationUserReinvite + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result = await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(result.successful).toBeDefined(); + expect(result.successful?.response).toHaveLength(REQUESTS_PER_BATCH); + expect(result.failed).toHaveLength(0); + }); + + it("should process batches sequentially in order", async () => { + const totalUsers = REQUESTS_PER_BATCH * 2; + const userIdsBatch = Array.from({ length: totalUsers }, () => newGuid() as UserId); + const callOrder: number[] = []; + + organizationUserApiService.postManyOrganizationUserReinvite.mockImplementation( + async (orgId, ids) => { + const batchIndex = ids.includes(userIdsBatch[0]) ? 1 : 2; + callOrder.push(batchIndex); + + return new ListResponse( + { + data: ids.map((id) => ({ + id, + error: null, + })), + continuationToken: null, + }, + OrganizationUserBulkResponse, + ); + }, + ); + + await service.bulkReinvite(mockOrganization, userIdsBatch); + + expect(callOrder).toEqual([1, 2]); + expect(organizationUserApiService.postManyOrganizationUserReinvite).toHaveBeenCalledTimes( + 2, + ); + }); }); }); @@ -427,14 +694,6 @@ describe("MemberActionsService", () => { expect(result).toBe(false); }); - it("should not allow reset password when organization lacks public and private keys", () => { - const org = { ...mockOrganization, hasPublicAndPrivateKeys: false } as Organization; - - const result = service.allowResetPassword(mockOrgUser, org, resetPasswordEnabled); - - expect(result).toBe(false); - }); - it("should not allow reset password when user is not enrolled in reset password", () => { const user = { ...mockOrgUser, resetPasswordEnrolled: false } as OrganizationUserView; @@ -443,12 +702,6 @@ describe("MemberActionsService", () => { expect(result).toBe(false); }); - it("should not allow reset password when reset password is disabled", () => { - const result = service.allowResetPassword(mockOrgUser, mockOrganization, false); - - expect(result).toBe(false); - }); - it("should not allow reset password when user status is not confirmed", () => { const user = { ...mockOrgUser, diff --git a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts index 5e19e26954e..f3774e3cb25 100644 --- a/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts +++ b/apps/web/src/app/admin-console/organizations/members/services/member-actions/member-actions.service.ts @@ -20,9 +20,12 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; import { OrganizationUserView } from "../../../core/views/organization-user.view"; +export const REQUESTS_PER_BATCH = 500; + export interface MemberActionResult { success: boolean; error?: string; @@ -162,20 +165,36 @@ export class MemberActionsService { } } - async bulkReinvite(organization: Organization, userIds: string[]): Promise<BulkActionResult> { - try { - const result = await this.organizationUserApiService.postManyOrganizationUserReinvite( - organization.id, - userIds, - ); - return { successful: result, failed: [] }; - } catch (error) { - return { - failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), - }; + async bulkReinvite(organization: Organization, userIds: UserId[]): Promise<BulkActionResult> { + const increaseBulkReinviteLimitForCloud = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.IncreaseBulkReinviteLimitForCloud), + ); + if (increaseBulkReinviteLimitForCloud) { + return await this.vNextBulkReinvite(organization, userIds); + } else { + try { + const result = await this.organizationUserApiService.postManyOrganizationUserReinvite( + organization.id, + userIds, + ); + return { successful: result, failed: [] }; + } catch (error) { + return { + failed: userIds.map((id) => ({ id, error: (error as Error).message ?? String(error) })), + }; + } } } + async vNextBulkReinvite( + organization: Organization, + userIds: UserId[], + ): Promise<BulkActionResult> { + return this.processBatchedOperation(userIds, REQUESTS_PER_BATCH, (batch) => + this.organizationUserApiService.postManyOrganizationUserReinvite(organization.id, batch), + ); + } + allowResetPassword( orgUser: OrganizationUserView, organization: Organization, @@ -207,4 +226,52 @@ export class MemberActionsService { orgUser.status === OrganizationUserStatusType.Confirmed ); } + + /** + * Processes user IDs in sequential batches and aggregates results. + * @param userIds - Array of user IDs to process + * @param batchSize - Number of IDs to process per batch + * @param processBatch - Async function that processes a single batch and returns the result + * @returns Aggregated bulk action result + */ + private async processBatchedOperation( + userIds: UserId[], + batchSize: number, + processBatch: (batch: string[]) => Promise<ListResponse<OrganizationUserBulkResponse>>, + ): Promise<BulkActionResult> { + const allSuccessful: OrganizationUserBulkResponse[] = []; + const allFailed: { id: string; error: string }[] = []; + + for (let i = 0; i < userIds.length; i += batchSize) { + const batch = userIds.slice(i, i + batchSize); + + try { + const result = await processBatch(batch); + + if (result?.data) { + for (const response of result.data) { + if (response.error) { + allFailed.push({ id: response.id, error: response.error }); + } else { + allSuccessful.push(response); + } + } + } + } catch (error) { + allFailed.push( + ...batch.map((id) => ({ id, error: (error as Error).message ?? String(error) })), + ); + } + } + + const successful = + allSuccessful.length > 0 + ? new ListResponse(allSuccessful, OrganizationUserBulkResponse) + : undefined; + + return { + successful, + failed: allFailed, + }; + } } From f7d2dd0cd0b3a83dc060d81a037ca9217dcc93da Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:13:22 -0700 Subject: [PATCH 47/60] Desktop use debug level file filter if developer build (#17910) --- apps/desktop/src/platform/services/electron-log.main.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/platform/services/electron-log.main.service.ts b/apps/desktop/src/platform/services/electron-log.main.service.ts index 947f4449271..e148f7a45c8 100644 --- a/apps/desktop/src/platform/services/electron-log.main.service.ts +++ b/apps/desktop/src/platform/services/electron-log.main.service.ts @@ -22,7 +22,7 @@ export class ElectronLogMainService extends BaseLogService { return; } - log.transports.file.level = "info"; + log.transports.file.level = isDev() ? "debug" : "info"; if (this.logDir != null) { log.transports.file.resolvePathFn = () => path.join(this.logDir, "app.log"); } From 4c971c70c01de3bdab4ea0906abb7112461cc6a2 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham <bcunningham@bitwarden.com> Date: Thu, 11 Dec 2025 13:56:13 -0500 Subject: [PATCH 48/60] [CL-927] anon layout header actions slot (#17796) * add a slot for consumers to show user actions in anon layout header * remove commented code * ensure logo stays top aligned * switch to dashed naming * fix ngif statements * remove empty selector * remove unnecessary containers * use smaller logo on smaller screens * remove commented code from extension layout * remove dupe slot * only take extension screenshots on small screens * take screenshot at 380 * take large and small screenshot * update story to use new control flow --- ...tension-anon-layout-wrapper.component.html | 24 +++------ .../extension-anon-layout-wrapper.stories.ts | 5 ++ .../anon-layout-wrapper.component.html | 1 + .../anon-layout-wrapper.stories.ts | 14 +++++ .../anon-layout/anon-layout.component.html | 54 +++++++++++-------- .../src/anon-layout/anon-layout.stories.ts | 21 +++++++- 6 files changed, 78 insertions(+), 41 deletions(-) diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index dcd0496ed30..7a1815b86ed 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -1,31 +1,21 @@ <popup-page [disablePadding]="true"> - <popup-header - slot="header" - [background]="'alt'" - [showBackButton]="showBackButton" - [pageTitle]="''" - > - <div class="tw-w-32"> - <bit-icon *ngIf="showLogo" [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon> - </div> - - <ng-container slot="end"> - <app-pop-out></app-pop-out> - <app-current-account *ngIf="showAcctSwitcher && hasLoggedInAccount"></app-current-account> - </ng-container> - </popup-header> - <auth-anon-layout [title]="pageTitle" [subtitle]="pageSubtitle" [icon]="pageIcon" [showReadonlyHostname]="showReadonlyHostname" - [hideLogo]="true" + [hideLogo]="!showLogo" [maxWidth]="maxWidth" [hideFooter]="hideFooter" [hideCardWrapper]="hideCardWrapper" > <router-outlet></router-outlet> + <div class="tw-flex tw-gap-2" slot="header-actions"> + <app-pop-out></app-pop-out> + @if (showAcctSwitcher && hasLoggedInAccount) { + <app-current-account></app-current-account> + } + </div> <router-outlet slot="secondary" name="secondary"></router-outlet> <router-outlet slot="environment-selector" name="environment-selector"></router-outlet> </auth-anon-layout> diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index 7a30e15582c..57ef285bdf5 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -238,6 +238,11 @@ export const DefaultContentExample: Story = { }, ], }), + parameters: { + chromatic: { + viewports: [380, 1280], + }, + }, }; // Dynamic Content Example diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.html b/libs/components/src/anon-layout/anon-layout-wrapper.component.html index 73a3d34261b..1079329448b 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.html +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.html @@ -7,6 +7,7 @@ [hideCardWrapper]="hideCardWrapper" [hideBackgroundIllustration]="hideBackgroundIllustration" > + <router-outlet slot="header-actions" name="header-actions"></router-outlet> <router-outlet></router-outlet> <router-outlet slot="secondary" name="secondary"></router-outlet> <router-outlet slot="environment-selector" name="environment-selector"></router-outlet> diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts b/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts index 76fcc8976c7..63181e04649 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.stories.ts @@ -130,6 +130,15 @@ export class DefaultSecondaryOutletExampleComponent {} }) export class DefaultEnvSelectorOutletExampleComponent {} +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "bit-header-actions-outlet-example-component", + template: "<p>Header Actions Outlet Example: <br> your header actions component goes here</p>", + standalone: false, +}) +export class DefaultHeaderActionsOutletExampleComponent {} + export const DefaultContentExample: Story = { render: (args) => ({ props: args, @@ -171,6 +180,11 @@ export const DefaultContentExample: Story = { component: DefaultEnvSelectorOutletExampleComponent, outlet: "environment-selector", }, + { + path: "", + component: DefaultHeaderActionsOutletExampleComponent, + outlet: "header-actions", + }, ], }, ], diff --git a/libs/components/src/anon-layout/anon-layout.component.html b/libs/components/src/anon-layout/anon-layout.component.html index 15f7d107542..edb73bbf588 100644 --- a/libs/components/src/anon-layout/anon-layout.component.html +++ b/libs/components/src/anon-layout/anon-layout.component.html @@ -5,13 +5,19 @@ 'tw-min-h-full': clientType === 'browser' || clientType === 'desktop', }" > - <a - *ngIf="!hideLogo()" - [routerLink]="['/']" - class="tw-w-[200px] tw-block tw-mb-12 [&>*]:tw-align-top" - > - <bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon> - </a> + <div class="tw-flex tw-justify-between tw-items-center tw-w-full tw-mb-12"> + @if (!hideLogo()) { + <a + [routerLink]="['/']" + class="tw-w-32 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top" + > + <bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon> + </a> + } + <div class="tw-ms-auto"> + <ng-content select="[slot=header-actions]"></ng-content> + </div> + </div> <div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass"> @let iconInput = icon(); @@ -25,7 +31,7 @@ <bit-icon [icon]="iconInput"></bit-icon> </div> - <ng-container *ngIf="title()"> + @if (title()) { <!-- Small screens --> <h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden"> {{ title() }} @@ -34,9 +40,11 @@ <h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block"> {{ title() }} </h1> - </ng-container> + } - <div *ngIf="subtitle()" class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div> + @if (subtitle()) { + <div class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div> + } </div> <div @@ -57,18 +65,20 @@ <ng-content select="[slot=secondary]"></ng-content> </div> - <footer *ngIf="!hideFooter()" class="tw-text-center tw-mt-4 sm:tw-mt-6"> - <div *ngIf="showReadonlyHostname()" bitTypography="body2"> - {{ "accessing" | i18n }} {{ hostname }} - </div> - <ng-container *ngIf="!showReadonlyHostname()"> - <ng-content select="[slot=environment-selector]"></ng-content> - </ng-container> - <ng-container *ngIf="!hideYearAndVersion"> - <div bitTypography="body2">&copy; {{ year }} Bitwarden Inc.</div> - <div bitTypography="body2">{{ version }}</div> - </ng-container> - </footer> + @if (!hideFooter()) { + <footer class="tw-text-center tw-mt-4 sm:tw-mt-6"> + @if (showReadonlyHostname()) { + <div bitTypography="body2">{{ "accessing" | i18n }} {{ hostname }}</div> + } @else { + <ng-content select="[slot=environment-selector]"></ng-content> + } + + @if (!hideYearAndVersion) { + <div bitTypography="body2">&copy; {{ year }} Bitwarden Inc.</div> + <div bitTypography="body2">{{ version }}</div> + } + </footer> + } @if (!hideBackgroundIllustration()) { <div diff --git a/libs/components/src/anon-layout/anon-layout.stories.ts b/libs/components/src/anon-layout/anon-layout.stories.ts index fb3bacb838f..01cdc04ad73 100644 --- a/libs/components/src/anon-layout/anon-layout.stories.ts +++ b/libs/components/src/anon-layout/anon-layout.stories.ts @@ -8,6 +8,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { AvatarModule } from "../avatar"; import { ButtonModule } from "../button"; import { I18nMockService } from "../utils/i18n-mock.service"; @@ -23,6 +24,7 @@ type StoryArgs = AnonLayoutComponent & { showSecondary: boolean; useDefaultIcon: boolean; icon: Icon; + includeHeaderActions: boolean; }; export default { @@ -30,7 +32,7 @@ export default { component: AnonLayoutComponent, decorators: [ moduleMetadata({ - imports: [ButtonModule, RouterModule], + imports: [ButtonModule, RouterModule, AvatarModule], providers: [ { provide: PlatformUtilsService, @@ -76,6 +78,14 @@ export default { [hideFooter]="hideFooter" [hideBackgroundIllustration]="hideBackgroundIllustration" > + @if (includeHeaderActions) { + <div slot="header-actions" class="tw-flex tw-items-center tw-gap-2"> + <bit-avatar + size="small" + text="Bob Loblaw" + ></bit-avatar> + </div> + } <ng-container [ngSwitch]="contentLength"> <div *ngSwitchCase="'thin'" class="tw-text-center"> <div class="tw-font-medium">Thin Content</div></div> <div *ngSwitchCase="'long'"> @@ -116,7 +126,7 @@ export default { hideLogo: { control: "boolean" }, hideFooter: { control: "boolean" }, hideBackgroundIllustration: { control: "boolean" }, - + includeHeaderActions: { control: "boolean" }, contentLength: { control: "radio", options: ["normal", "long", "thin"], @@ -138,6 +148,7 @@ export default { hideBackgroundIllustration: false, contentLength: "normal", showSecondary: false, + includeHeaderActions: false, }, } satisfies Meta<StoryArgs>; @@ -188,6 +199,12 @@ export const SecondaryContent: Story = { }, }; +export const WithHeaderActions: Story = { + args: { + includeHeaderActions: true, + }, +}; + export const NoTitle: Story = { args: { title: undefined } }; export const NoSubtitle: Story = { args: { subtitle: undefined } }; From 22e9c6a72f948b9a26b70c4caa9bc636d6a67e43 Mon Sep 17 00:00:00 2001 From: neuronull <9162534+neuronull@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:44:51 -0700 Subject: [PATCH 49/60] Re-apply desktop native debug log level debug builds and fix build workflow (#17908) * Reapply "Desktop Native compile debug builds with debug log level (#17357)" (#17815) This reverts commit 5386b58f2329eaed2acb9178560eeca9a265bb16. * Use release mode if workflow called from upstream * fix bug in build script * revert napi build command to not use --release * forward caller's args to napi * js things * shell thangs * use platform agnostic expansion * Revert "use platform agnostic expansion" This reverts commit 5ee629f822a5bccbc68817bc9b3e846eb85b1639. * powershell expansion --- .github/workflows/build-desktop.yml | 20 ++++++++++++----- apps/desktop/desktop_native/build.js | 6 ++--- apps/desktop/desktop_native/napi/package.json | 2 +- .../desktop_native/napi/scripts/build.js | 22 +++++++++++++++++++ apps/desktop/desktop_native/napi/src/lib.rs | 14 +++++++++--- 5 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/desktop_native/napi/scripts/build.js diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 6978edd8b3c..efb94e44c7a 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -583,7 +583,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$env:MODE" - name: Build run: npm run build @@ -846,7 +848,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$env:MODE" - name: Build run: npm run build @@ -1202,7 +1206,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$MODE" - name: Build application (dev) run: npm run build @@ -1424,7 +1430,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$MODE" - name: Build if: steps.build-cache.outputs.cache-hit != 'true' @@ -1705,7 +1713,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$MODE" - name: Build if: steps.build-cache.outputs.cache-hit != 'true' diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index e267e28a08c..54a6dba8326 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -113,8 +113,8 @@ if (process.platform === "linux") { platformTargets.forEach(([target, _]) => { installTarget(target); - buildNapiModule(target); - buildProxyBin(target); - buildImporterBinaries(target); + buildNapiModule(target, mode === "release"); + buildProxyBin(target, mode === "release"); + buildImporterBinaries(target, mode === "release"); buildProcessIsolation(); }); diff --git a/apps/desktop/desktop_native/napi/package.json b/apps/desktop/desktop_native/napi/package.json index 5401207c252..0717bfd53ea 100644 --- a/apps/desktop/desktop_native/napi/package.json +++ b/apps/desktop/desktop_native/napi/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "description": "", "scripts": { - "build": "napi build --platform --no-js", + "build": "node scripts/build.js", "test": "cargo test" }, "author": "", diff --git a/apps/desktop/desktop_native/napi/scripts/build.js b/apps/desktop/desktop_native/napi/scripts/build.js new file mode 100644 index 00000000000..ad24b99d2fb --- /dev/null +++ b/apps/desktop/desktop_native/napi/scripts/build.js @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { execSync } = require('child_process'); + +const args = process.argv.slice(2); + +const isRelease = args.includes('--release'); + +const argsString = args.join(' '); + +if (isRelease) { + console.log('Building release mode.'); + + execSync(`napi build --platform --no-js ${argsString}`, { stdio: 'inherit'}); + +} else { + console.log('Building debug mode.'); + + execSync(`napi build --platform --no-js ${argsString}`, { + stdio: 'inherit', + env: { ...process.env, RUST_LOG: 'debug' } + }); +} diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 25dfdd08336..fe084349501 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -994,7 +994,7 @@ pub mod logging { }; use tracing::Level; use tracing_subscriber::{ - filter::{EnvFilter, LevelFilter}, + filter::EnvFilter, fmt::format::{DefaultVisitor, Writer}, layer::SubscriberExt, util::SubscriberInitExt, @@ -1082,9 +1082,17 @@ pub mod logging { pub fn init_napi_log(js_log_fn: ThreadsafeFunction<FnArgs<(LogLevel, String)>>) { let _ = JS_LOGGER.0.set(js_log_fn); + // the log level hierarchy is determined by: + // - if RUST_LOG is detected at runtime + // - if RUST_LOG is provided at compile time + // - default to INFO let filter = EnvFilter::builder() - // set the default log level to INFO. - .with_default_directive(LevelFilter::INFO.into()) + .with_default_directive( + option_env!("RUST_LOG") + .unwrap_or("info") + .parse() + .expect("should provide valid log level at compile time."), + ) // parse directives from the RUST_LOG environment variable, // overriding the default directive for matching targets. .from_env_lossy(); From 4576a52fd186e4a11c24edde1a6ee99f7d3d9b09 Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:03:10 -0500 Subject: [PATCH 50/60] fix(token-service) [PM-15333]: Portable App Is Not Portable (#17781) * feat(token-service) [PM-15333]: Update Portable secure storage resolution to use disk. * feat(token-service) [PM-15333]: Move isWindowsPortable evaluation to preload with other platform evaluations. --- apps/desktop/src/app/services/services.module.ts | 10 +++++++++- apps/desktop/src/platform/preload.ts | 2 ++ libs/common/src/auth/services/token.service.ts | 14 +++++++++++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index e4dd144fa20..59021a556e4 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -203,8 +203,16 @@ const safeProviders: SafeProvider[] = [ // We manually override the value of SUPPORTS_SECURE_STORAGE here to avoid // the TokenService having to inject the PlatformUtilsService which introduces a // circular dependency on Desktop only. + // + // For Windows portable builds, we disable secure storage to ensure tokens are + // stored on disk (in bitwarden-appdata) rather than in Windows Credential + // Manager, making them portable across machines. This allows users to move the USB drive + // between computers while maintaining authentication. + // + // Note: Portable mode does not use secure storage for read/write/clear operations, + // preventing any collision with tokens from a regular desktop installation. provide: SUPPORTS_SECURE_STORAGE, - useValue: ELECTRON_SUPPORTS_SECURE_STORAGE, + useValue: ELECTRON_SUPPORTS_SECURE_STORAGE && !ipc.platform.isWindowsPortable, }), safeProvider({ provide: DEFAULT_VAULT_TIMEOUT, diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index a45ac753b3f..5f643242a9c 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -17,6 +17,7 @@ import { isFlatpak, isMacAppStore, isSnapStore, + isWindowsPortable, isWindowsStore, } from "../utils"; @@ -133,6 +134,7 @@ export default { isDev: isDev(), isMacAppStore: isMacAppStore(), isWindowsStore: isWindowsStore(), + isWindowsPortable: isWindowsPortable(), isFlatpak: isFlatpak(), isSnapStore: isSnapStore(), isAppImage: isAppImage(), diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index c02bc85f124..ce272705341 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -445,13 +445,15 @@ export class TokenService implements TokenServiceAbstraction { // we can't determine storage location w/out vaultTimeoutAction and vaultTimeout // but we can simply clear all locations to avoid the need to require those parameters. + // When secure storage is supported, clear the encryption key from secure storage. + // When not supported (e.g., portable builds), tokens are stored on disk and this step is skipped. if (this.platformSupportsSecureStorage) { - // Always clear the access token key when clearing the access token - // The next set of the access token will create a new access token key + // Always clear the access token key when clearing the access token. + // The next set of the access token will create a new access token key. await this.clearAccessTokenKey(userId); } - // Platform doesn't support secure storage, so use state provider implementation + // Clear tokens from disk storage (all platforms) await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null, { shouldUpdate: (previousValue) => previousValue !== null, }); @@ -478,6 +480,9 @@ export class TokenService implements TokenServiceAbstraction { return null; } + // When platformSupportsSecureStorage=true, tokens on disk are encrypted and require + // decryption keys from secure storage. When false (e.g., portable builds), tokens are + // stored on disk. if (this.platformSupportsSecureStorage) { let accessTokenKey: AccessTokenKey; try { @@ -1118,6 +1123,9 @@ export class TokenService implements TokenServiceAbstraction { ) { return TokenStorageLocation.Memory; } else { + // Secure storage (e.g., OS credential manager) is preferred when available. + // Desktop portable builds set platformSupportsSecureStorage=false to store tokens + // on disk for portability across machines. if (useSecureStorage && this.platformSupportsSecureStorage) { return TokenStorageLocation.SecureStorage; } From 7c0337c12dc693e22cc3023961fccced6ec97fc9 Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:14:57 -0500 Subject: [PATCH 51/60] [BRE-1391] Fixing desktop tar.gz to include version (#17933) --- .github/workflows/release-desktop.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 7f87a1e5628..2239cb1268f 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -98,6 +98,14 @@ jobs: working-directory: apps/desktop/artifacts run: mv "Bitwarden-${PKG_VERSION}-universal.pkg" "Bitwarden-${PKG_VERSION}-universal.pkg.archive" + - name: Rename .tar.gz to include version + env: + PKG_VERSION: ${{ steps.version.outputs.version }} + working-directory: apps/desktop/artifacts + run: | + mv "bitwarden_desktop_x64.tar.gz" "bitwarden_${PKG_VERSION}_x64.tar.gz" + mv "bitwarden_desktop_arm64.tar.gz" "bitwarden_${PKG_VERSION}_arm64.tar.gz" + - name: Create Release uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 if: ${{ steps.release_channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' }} From 9f1496b21834d40025c1f5eb64ca637798ba1ba8 Mon Sep 17 00:00:00 2001 From: Github Actions <actions@github.com> Date: Thu, 11 Dec 2025 21:25:36 +0000 Subject: [PATCH 52/60] Bumped Desktop client to 2025.12.1 --- apps/desktop/package.json | 2 +- apps/desktop/src/package-lock.json | 4 ++-- apps/desktop/src/package.json | 2 +- package-lock.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5e85d34cebc..97ab8585a69 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.12.0", + "version": "2025.12.1", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 44fdb5c23b0..9d8eae15791 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.12.0", + "version": "2025.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.12.0", + "version": "2025.12.1", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 4c396304f4a..2ac5d339a95 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.12.0", + "version": "2025.12.1", "author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/package-lock.json b/package-lock.json index dc8694f77b6..3a600667ff7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -278,7 +278,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.12.0", + "version": "2025.12.1", "hasInstallScript": true, "license": "GPL-3.0" }, From d77930428564e98a1b3809c6e64990682bdc6d6e Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:45:32 -0800 Subject: [PATCH 53/60] [PM-25388] - remove reference to android/ios icons (#17763) * remove android/ios icons as they're not in the icon lib * fix tests --- .../src/vault/icon/build-cipher-icon.spec.ts | 43 ++++++++++--------- .../src/vault/icon/build-cipher-icon.ts | 6 ++- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/libs/common/src/vault/icon/build-cipher-icon.spec.ts b/libs/common/src/vault/icon/build-cipher-icon.spec.ts index 90ccaaec3a6..67a1151aa8e 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.spec.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.spec.ts @@ -13,18 +13,19 @@ describe("buildCipherIcon", () => { }, } as any as CipherView; - it.each([true, false])("handles android app URIs for showFavicon setting %s", (showFavicon) => { - setUri("androidapp://test.example"); + // @TODO Uncomment once we have Android and iOS icons https://bitwarden.atlassian.net/browse/PM-29028 + // it.each([true, false])("handles android app URIs for showFavicon setting %s", (showFavicon) => { + // setUri("androidapp://test.example"); - const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon); + // const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon); - expect(iconDetails).toEqual({ - icon: "bwi-android", - image: null, - fallbackImage: "", - imageEnabled: showFavicon, - }); - }); + // expect(iconDetails).toEqual({ + // icon: "bwi-android", + // image: null, + // fallbackImage: "", + // imageEnabled: showFavicon, + // }); + // }); it("does not mark as an android app if the protocol is not androidapp", () => { // This weird URI points to test.androidapp with a default port and path of /.example @@ -40,18 +41,18 @@ describe("buildCipherIcon", () => { }); }); - it.each([true, false])("handles ios app URIs for showFavicon setting %s", (showFavicon) => { - setUri("iosapp://test.example"); + // @TODO Uncomment once we have Android and iOS icons https://bitwarden.atlassian.net/browse/PM-29028 + // it.each([true, false])("handles ios app URIs for showFavicon setting %s", (showFavicon) => { + // setUri("iosapp://test.example"); - const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon); - - expect(iconDetails).toEqual({ - icon: "bwi-apple", - image: null, - fallbackImage: "", - imageEnabled: showFavicon, - }); - }); + // const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon); + // expect(iconDetails).toEqual({ + // icon: "bwi-apple", + // image: null, + // fallbackImage: "", + // imageEnabled: showFavicon, + // }); + // }); it("does not mark as an ios app if the protocol is not iosapp", () => { // This weird URI points to test.iosapp with a default port and path of /.example diff --git a/libs/common/src/vault/icon/build-cipher-icon.ts b/libs/common/src/vault/icon/build-cipher-icon.ts index a081511d792..77787874d8e 100644 --- a/libs/common/src/vault/icon/build-cipher-icon.ts +++ b/libs/common/src/vault/icon/build-cipher-icon.ts @@ -49,10 +49,12 @@ export function buildCipherIcon( let isWebsite = false; if (hostnameUri.indexOf("androidapp://") === 0) { - icon = "bwi-android"; + // @TODO Re-add once we have Android icon https://bitwarden.atlassian.net/browse/PM-29028 + // icon = "bwi-android"; image = null; } else if (hostnameUri.indexOf("iosapp://") === 0) { - icon = "bwi-apple"; + // @TODO Re-add once we have iOS icon https://bitwarden.atlassian.net/browse/PM-29028 + // icon = "bwi-apple"; image = null; } else if ( showFavicon && From 2c4034ec7ce54c6da8537eb05534317ac5ba33bb Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:47:26 -0800 Subject: [PATCH 54/60] update popup router cache when navigating after file upload (#17694) --- .../platform/popup/view-cache/popup-router-cache.service.ts | 4 ++-- .../vault-v2/attachments/attachments-v2.component.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts index abb7c6405c2..6e5218c9f27 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts @@ -120,8 +120,8 @@ export class PopupRouterCacheService { /** * Navigate back in history */ - async back() { - if (!BrowserPopupUtils.inPopup(window)) { + async back(updateCache = false) { + if (!updateCache && !BrowserPopupUtils.inPopup(window)) { this.location.back(); return; } diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts index 295496c701f..29282d293de 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts @@ -51,6 +51,6 @@ export class AttachmentsV2Component { /** Navigate the user back to the edit screen after uploading an attachment */ async navigateBack() { - await this.popupRouterCacheService.back(); + await this.popupRouterCacheService.back(true); } } From 81350d98df070655e395acce37d1137d60aa9e3d Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:48:43 -0800 Subject: [PATCH 55/60] fix alignment in hidden/pw fields (#17877) --- libs/components/src/form-field/form-field.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/components/src/form-field/form-field.component.html b/libs/components/src/form-field/form-field.component.html index a4af25a2492..1ead9f82273 100644 --- a/libs/components/src/form-field/form-field.component.html +++ b/libs/components/src/form-field/form-field.component.html @@ -83,7 +83,7 @@ <ng-container *ngTemplateOutlet="labelContent"></ng-container> </label> <div - class="tw-gap-1 tw-flex tw-min-h-[1.85rem] tw-border-0 tw-border-solid" + class="tw-gap-1 tw-flex tw-min-h-[1.85rem] tw-border-0 tw-border-solid tw-items-center" [ngClass]="{ 'tw-border-secondary-300/50 tw-border-b tw-pb-[2px]': !disableReadOnlyBorder, 'tw-border-transparent tw-pb-[3px]': disableReadOnlyBorder, From be9d0c0291891708a6ee5de28be8a59c6055f814 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu <acoroiu@bitwarden.com> Date: Fri, 12 Dec 2025 15:00:03 +0100 Subject: [PATCH 56/60] Transfer node-forge ownership to KM (#17941) * chore: move node-forge to KM * chore: sort dependencies --- .github/renovate.json5 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 96e16776545..ca57ccf4f86 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -154,7 +154,6 @@ "@types/glob", "@types/lowdb", "@types/node", - "@types/node-forge", "@types/node-ipc", "@yao-pkg/pkg", "anyhow", @@ -192,7 +191,6 @@ "napi", "napi-build", "napi-derive", - "node-forge", "node-ipc", "nx", "oo7", @@ -415,14 +413,16 @@ }, { matchPackageNames: [ + "@types/node-forge", "aes", "big-integer", "cbc", + "linux-keyutils", + "memsec", + "node-forge", "rsa", "russh-cryptovec", "sha2", - "memsec", - "linux-keyutils", ], description: "Key Management owned dependencies", commitMessagePrefix: "[deps] KM:", From 27d82aaf286d6ef862864c21edef988bc57fd34e Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:03:31 -0500 Subject: [PATCH 57/60] feat(accounts): Add creationDate of account to AccountInfo * Add creationDate of account to AccountInfo * Added initialization of creationDate. * Removed extra changes. * Fixed tests to initialize creation date * Added helper method to abstract account initialization in tests. * More test updates. * Linting * Additional test fixes. * Fixed spec reference * Fixed imports * Linting. * Fixed browser test. * Modified tsconfig to reference spec file. * Fixed import. * Removed dependency on os. This is necessary so that the @bitwarden/common/spec lib package can be referenced in tests without node. * Revert "Removed dependency on os. This is necessary so that the @bitwarden/common/spec lib package can be referenced in tests without node." This reverts commit 669f6557b6561f65ff513c14c2b3e8a55bef4035. * Updated stories to hard-code new field. * Removed changes to tsconfig * Revert "Removed changes to tsconfig" This reverts commit b7d916e8dc70be453f7092138416ce2e3c09ed57. --- .../services/account-switcher.service.spec.ts | 16 ++-- .../notification.background.spec.ts | 12 +-- .../browser/main-context-menu-handler.spec.ts | 8 +- .../extension-anon-layout-wrapper.stories.ts | 3 + .../popup/send-v2/send-v2.component.spec.ts | 8 +- .../open-attachments.component.spec.ts | 8 +- .../commands/unlock.command.spec.ts | 8 +- .../fido2-create.component.spec.ts | 8 +- .../desktop-autotype-policy.service.spec.ts | 8 +- .../biometric-message-handler.service.spec.ts | 16 ++-- .../unified-upgrade-dialog.component.spec.ts | 8 +- .../upgrade-nav-button.component.spec.ts | 8 +- .../services/upgrade-payment.service.spec.ts | 45 ++++++----- .../pages/breach-report.component.spec.ts | 8 +- .../user-key-rotation.service.spec.ts | 8 +- .../navigation-switcher.stories.ts | 3 + .../product-switcher.stories.ts | 3 + .../services/vault-banners.service.spec.ts | 15 +++- .../guards/provider-permissions.guard.spec.ts | 8 +- .../secrets/secret.service.spec.ts | 9 ++- .../services/sm-porting-api.service.spec.ts | 9 ++- .../access-policy.service.spec.ts | 9 ++- .../src/auth/guards/auth.guard.spec.ts | 24 +++--- .../src/auth/guards/lock.guard.spec.ts | 24 +++--- ...edirect-to-vault-if-unlocked.guard.spec.ts | 8 +- .../tde-decryption-required.guard.spec.ts | 8 +- .../src/auth/guards/unauth.guard.spec.ts | 8 +- .../login-approval-dialog.component.spec.ts | 8 +- .../default-change-password.service.spec.ts | 9 ++- ...ypted-migrations-scheduler.service.spec.ts | 15 ++-- .../common/login-strategies/login.strategy.ts | 1 + .../services/accounts/lock.services.spec.ts | 26 ++++--- libs/common/spec/fake-account-service.ts | 28 +++++-- .../src/auth/abstractions/account.service.ts | 11 ++- .../src/auth/services/account.service.spec.ts | 76 ++++++++++++++++++- .../src/auth/services/account.service.ts | 5 ++ .../auth-request-answering.service.spec.ts | 11 ++- .../src/auth/services/auth.service.spec.ts | 27 ++++--- ...-enrollment.service.implementation.spec.ts | 8 +- .../services/vault-timeout.service.spec.ts | 14 ++-- ...ult-server-notifications.multiuser.spec.ts | 13 +++- ...fault-server-notifications.service.spec.ts | 21 ++++- .../default-environment.service.spec.ts | 19 +++-- .../fido2/fido2-authenticator.service.spec.ts | 9 ++- .../services/sdk/default-sdk.service.spec.ts | 7 +- .../services/sdk/register-sdk.service.spec.ts | 12 ++- .../src/platform/sync/default-sync.service.ts | 1 + libs/common/src/services/api.service.spec.ts | 8 +- .../tools/extension/extension.service.spec.ts | 14 +++- .../tools/send/services/send.service.spec.ts | 8 +- .../tools/state/user-state-subject.spec.ts | 15 ++-- ...warden-password-protected-importer.spec.ts | 15 ++-- .../master-password-lock.component.spec.ts | 8 +- .../generator-metadata-provider.spec.ts | 13 +++- .../generator-profile-provider.spec.ts | 29 ++++--- ...fault-credential-generator.service.spec.ts | 9 ++- .../send-list-filters.component.spec.ts | 9 ++- .../login-credentials-view.component.spec.ts | 8 +- .../add-edit-folder-dialog.component.spec.ts | 9 +-- tsconfig.base.json | 1 + 60 files changed, 491 insertions(+), 276 deletions(-) diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts index 4bacd453803..f3be535f00e 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts @@ -15,6 +15,7 @@ import { } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { AccountSwitcherService } from "./account-switcher.service"; @@ -71,11 +72,10 @@ describe("AccountSwitcherService", () => { describe("availableAccounts$", () => { it("should return all logged in accounts and an add account option when accounts are less than 5", async () => { - const accountInfo: AccountInfo = { + const accountInfo = mockAccountInfoWith({ name: "Test User 1", email: "test1@email.com", - emailVerified: true, - }; + }); avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc")); accountsSubject.next({ ["1" as UserId]: accountInfo, ["2" as UserId]: accountInfo }); @@ -109,11 +109,10 @@ describe("AccountSwitcherService", () => { const seedAccounts: Record<UserId, AccountInfo> = {}; const seedStatuses: Record<UserId, AuthenticationStatus> = {}; for (let i = 0; i < numberOfAccounts; i++) { - seedAccounts[`${i}` as UserId] = { + seedAccounts[`${i}` as UserId] = mockAccountInfoWith({ email: `test${i}@email.com`, - emailVerified: true, name: "Test User ${i}", - }; + }); seedStatuses[`${i}` as UserId] = AuthenticationStatus.Unlocked; } avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc")); @@ -133,11 +132,10 @@ describe("AccountSwitcherService", () => { ); it("excludes logged out accounts", async () => { - const user1AccountInfo: AccountInfo = { + const user1AccountInfo = mockAccountInfoWith({ name: "Test User 1", email: "", - emailVerified: true, - }; + }); accountsSubject.next({ ["1" as UserId]: user1AccountInfo }); authStatusSubject.next({ ["1" as UserId]: AuthenticationStatus.LoggedOut }); accountsSubject.next({ diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 8df21bc66ef..ab16788ea6f 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -4,7 +4,7 @@ import { BehaviorSubject, firstValueFrom, of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { ExtensionCommand } from "@bitwarden/common/autofill/constants"; @@ -17,6 +17,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { ThemeTypes } from "@bitwarden/common/platform/enums"; import { SelfHostedEnvironment } from "@bitwarden/common/platform/services/default-environment.service"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -80,11 +81,12 @@ describe("NotificationBackground", () => { const organizationService = mock<OrganizationService>(); const userId = "testId" as UserId; - const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({ + const activeAccountSubject = new BehaviorSubject({ id: userId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); beforeEach(() => { diff --git a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts index 1348928b7e9..1738485f289 100644 --- a/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts +++ b/apps/browser/src/autofill/browser/main-context-menu-handler.spec.ts @@ -18,6 +18,7 @@ import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/s import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; @@ -123,9 +124,10 @@ describe("context-menu", () => { autofillSettingsService.enableContextMenu$ = of(true); accountService.activeAccount$ = of({ id: "userId" as UserId, - email: "", - emailVerified: false, - name: undefined, + ...mockAccountInfoWith({ + email: "", + name: undefined, + }), }); }); diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index 57ef285bdf5..8fdae06e28a 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -76,11 +76,14 @@ const decorators = (options: { { provide: AccountService, useValue: { + // We can't use mockAccountInfoWith() here because we can't take a dependency on @bitwarden/common/spec. + // This is because that package relies on jest dependencies that aren't available here. activeAccount$: of({ id: "test-user-id" as UserId, name: "Test User 1", email: "test@email.com", emailVerified: true, + creationDate: "2024-01-01T00:00:00.000Z", }), }, }, diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index 6d79f430a37..6e73d9811f2 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -16,6 +16,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi 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 { mockAccountInfoWith } from "@bitwarden/common/spec"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; @@ -96,9 +97,10 @@ describe("SendV2Component", () => { useValue: { activeAccount$: of({ id: "123", - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }), }, }, diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts index 459b328c44e..e9636e09873 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts @@ -11,6 +11,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; @@ -60,9 +61,10 @@ describe("OpenAttachmentsComponent", () => { const accountService = { activeAccount$: of({ id: mockUserId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }), }; const formStatusChange$ = new BehaviorSubject<"enabled" | "disabled">("enabled"); diff --git a/apps/cli/src/key-management/commands/unlock.command.spec.ts b/apps/cli/src/key-management/commands/unlock.command.spec.ts index 70e9a8fd232..50ef414ec37 100644 --- a/apps/cli/src/key-management/commands/unlock.command.spec.ts +++ b/apps/cli/src/key-management/commands/unlock.command.spec.ts @@ -15,6 +15,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; @@ -48,9 +49,10 @@ describe("UnlockCommand", () => { const mockMasterPassword = "testExample"; const activeAccount: Account = { id: "user-id" as UserId, - email: "user@example.com", - emailVerified: true, - name: "User", + ...mockAccountInfoWith({ + email: "user@example.com", + name: "User", + }), }; const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; const mockSessionKey = new Uint8Array(64) as CsprngArray; diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts index 778215895ee..dbef860aafe 100644 --- a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts @@ -7,6 +7,7 @@ import { AccountService, Account } from "@bitwarden/common/auth/abstractions/acc import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; @@ -40,9 +41,10 @@ describe("Fido2CreateComponent", () => { const activeAccountSubject = new BehaviorSubject<Account | null>({ id: "test-user-id" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); beforeEach(async () => { diff --git a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts index 555e6ceef5b..907da2fe85c 100644 --- a/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts +++ b/apps/desktop/src/autofill/services/desktop-autotype-policy.service.spec.ts @@ -10,6 +10,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { Account, UserId } from "@bitwarden/common/platform/models/domain/account"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { DesktopAutotypeDefaultSettingPolicy } from "./desktop-autotype-policy.service"; @@ -30,9 +31,10 @@ describe("DesktopAutotypeDefaultSettingPolicy", () => { beforeEach(() => { mockAccountSubject = new BehaviorSubject<Account | null>({ id: mockUserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); mockFeatureFlagSubject = new BehaviorSubject<boolean>(true); mockAuthStatusSubject = new BehaviorSubject<AuthenticationStatus>( diff --git a/apps/desktop/src/services/biometric-message-handler.service.spec.ts b/apps/desktop/src/services/biometric-message-handler.service.spec.ts index 49d346bfa3a..3b343fcc0fb 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.spec.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.spec.ts @@ -2,7 +2,7 @@ import { NgZone } from "@angular/core"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, filter, firstValueFrom, of, take, timeout, timer } from "rxjs"; -import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; @@ -10,7 +10,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { FakeAccountService } from "@bitwarden/common/spec"; +import { mockAccountInfoWith, FakeAccountService } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; @@ -23,17 +23,15 @@ import { BiometricMessageHandlerService } from "./biometric-message-handler.serv const SomeUser = "SomeUser" as UserId; const AnotherUser = "SomeOtherUser" as UserId; -const accounts: Record<UserId, AccountInfo> = { - [SomeUser]: { +const accounts = { + [SomeUser]: mockAccountInfoWith({ name: "some user", email: "some.user@example.com", - emailVerified: true, - }, - [AnotherUser]: { + }), + [AnotherUser]: mockAccountInfoWith({ name: "some other user", email: "some.other.user@example.com", - emailVerified: true, - }, + }), }; describe("BiometricMessageHandlerService", () => { diff --git a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts index b28a7b8c4a2..6bc0efb9e96 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -10,6 +10,7 @@ import { PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { DIALOG_DATA, DialogRef } from "@bitwarden/components"; @@ -63,9 +64,10 @@ describe("UnifiedUpgradeDialogComponent", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const defaultDialogData: UnifiedUpgradeDialogParams = { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts index 787936c102e..f5df248cbbf 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts @@ -7,6 +7,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogRef, DialogService } from "@bitwarden/components"; @@ -32,9 +33,10 @@ describe("UpgradeNavButtonComponent", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; beforeEach(async () => { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 9d17d62e4dc..81169d719b6 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -13,6 +13,7 @@ import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { LogService } from "@bitwarden/logging"; @@ -46,11 +47,12 @@ describe("UpgradePaymentService", () => { let sut: UpgradePaymentService; - const mockAccount = { + const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const mockTokenizedPaymentMethod: TokenizedPaymentMethod = { @@ -151,9 +153,10 @@ describe("UpgradePaymentService", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - name: "Test User", - emailVerified: true, + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const paidOrgData = { @@ -203,9 +206,10 @@ describe("UpgradePaymentService", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - name: "Test User", - emailVerified: true, + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const paidOrgData = { @@ -255,9 +259,10 @@ describe("UpgradePaymentService", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - name: "Test User", - emailVerified: true, + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; mockAccountService.activeAccount$ = of(mockAccount); @@ -289,9 +294,10 @@ describe("UpgradePaymentService", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - name: "Test User", - emailVerified: true, + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const expectedCredit = 25.5; @@ -353,9 +359,10 @@ describe("UpgradePaymentService", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - name: "Test User", - emailVerified: true, + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const paidOrgData = { diff --git a/apps/web/src/app/dirt/reports/pages/breach-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/breach-report.component.spec.ts index 143dce3915e..886267e3189 100644 --- a/apps/web/src/app/dirt/reports/pages/breach-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/breach-report.component.spec.ts @@ -10,6 +10,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BreachAccountResponse } from "@bitwarden/common/dirt/models/response/breach-account.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { BreachReportComponent } from "./breach-report.component"; @@ -38,9 +39,10 @@ describe("BreachReportComponent", () => { let accountService: MockProxy<AccountService>; const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({ id: "testId" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); beforeEach(async () => { diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts index f4b50b4a772..c0b734f17cc 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts @@ -30,6 +30,7 @@ import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk- import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; @@ -286,9 +287,10 @@ describe("KeyRotationService", () => { const mockUser = { id: "mockUserId" as UserId, - email: "mockEmail", - emailVerified: true, - name: "mockName", + ...mockAccountInfoWith({ + email: "mockEmail", + name: "mockName", + }), }; const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")]; diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index 88132e56384..ea6e972e431 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -75,11 +75,14 @@ class MockSyncService implements Partial<SyncService> { } class MockAccountService implements Partial<AccountService> { + // We can't use mockAccountInfoWith() here because we can't take a dependency on @bitwarden/common/spec. + // This is because that package relies on jest dependencies that aren't available here. activeAccount$?: Observable<Account> = of({ id: "test-user-id" as UserId, name: "Test User 1", email: "test@email.com", emailVerified: true, + creationDate: "2024-01-01T00:00:00.000Z", }); } diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index 4581f5981e6..d412530a635 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -75,11 +75,14 @@ class MockSyncService implements Partial<SyncService> { } class MockAccountService implements Partial<AccountService> { + // We can't use mockAccountInfoWith() here because we can't take a dependency on @bitwarden/common/spec. + // This is because that package relies on jest dependencies that aren't available here. activeAccount$?: Observable<Account> = of({ id: "test-user-id" as UserId, name: "Test User 1", email: "test@email.com", emailVerified: true, + creationDate: "2024-01-01T00:00:00.000Z", }); } diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts index 6b46cd89956..2ba9dd6fad4 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts @@ -2,14 +2,18 @@ import { TestBed } from "@angular/core/testing"; import { BehaviorSubject, firstValueFrom, take, timeout } from "rxjs"; import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; -import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { DeviceType } from "@bitwarden/common/enums"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { + FakeStateProvider, + mockAccountServiceWith, + mockAccountInfoWith, +} from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; @@ -27,8 +31,11 @@ describe("VaultBannersService", () => { const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); const getEmailVerified = jest.fn().mockResolvedValue(true); const lastSync$ = new BehaviorSubject<Date | null>(null); - const accounts$ = new BehaviorSubject<Record<UserId, AccountInfo>>({ - [userId]: { email: "test@bitwarden.com", emailVerified: true, name: "name" } as AccountInfo, + const accounts$ = new BehaviorSubject({ + [userId]: mockAccountInfoWith({ + email: "test@bitwarden.com", + name: "name", + }), }); const pendingAuthRequests$ = new BehaviorSubject<Array<AuthRequestResponse>>([]); diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts index a0a881dbad7..99d54eedc29 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts @@ -10,6 +10,7 @@ import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { ToastService } from "@bitwarden/components"; import { newGuid } from "@bitwarden/guid"; @@ -41,9 +42,10 @@ describe("Provider Permissions Guard", () => { accountService.activeAccount$ = of({ id: mockUserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); route = mock<ActivatedRouteSnapshot>({ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts index 056f7cfe255..606cb835ff1 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts @@ -6,6 +6,7 @@ import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; @@ -37,9 +38,11 @@ describe("SecretService", () => { let accountService: MockProxy<AccountService> = mock<AccountService>(); const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({ id: "testId" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + emailVerified: true, + }), }); beforeEach(() => { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts index a4f77e6de0b..aa722e31681 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts @@ -7,6 +7,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; @@ -38,9 +39,11 @@ describe("SecretsManagerPortingApiService", () => { let accountService: MockProxy<AccountService>; const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({ id: "testId" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + emailVerified: true, + }), }); beforeEach(() => { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts index 37a0dc06837..903bfd35122 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts @@ -31,7 +31,7 @@ import { PeopleAccessPoliciesRequest } from "./models/requests/people-access-pol import { ProjectServiceAccountsAccessPoliciesRequest } from "./models/requests/project-service-accounts-access-policies.request"; import { ServiceAccountGrantedPoliciesRequest } from "./models/requests/service-account-granted-policies.request"; -import { trackEmissions } from "@bitwarden/common/../spec"; +import { trackEmissions, mockAccountInfoWith } from "@bitwarden/common/../spec"; const SomeCsprngArray = new Uint8Array(64) as CsprngArray; const SomeOrganization = "some organization" as OrganizationId; @@ -52,9 +52,10 @@ describe("AccessPolicyService", () => { let accountService: MockProxy<AccountService>; const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({ id: "testId" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); beforeEach(() => { diff --git a/libs/angular/src/auth/guards/auth.guard.spec.ts b/libs/angular/src/auth/guards/auth.guard.spec.ts index fccfcd58874..335e31ec4d8 100644 --- a/libs/angular/src/auth/guards/auth.guard.spec.ts +++ b/libs/angular/src/auth/guards/auth.guard.spec.ts @@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec"; -import { - Account, - AccountInfo, - AccountService, -} from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -18,6 +14,7 @@ import { KeyConnectorService } from "@bitwarden/common/key-management/key-connec import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { authGuard } from "./auth.guard"; @@ -38,16 +35,13 @@ describe("AuthGuard", () => { const accountService: MockProxy<AccountService> = mock<AccountService>(); const activeAccountSubject = new BehaviorSubject<Account | null>(null); accountService.activeAccount$ = activeAccountSubject; - activeAccountSubject.next( - Object.assign( - { - name: "Test User 1", - email: "test@email.com", - emailVerified: true, - } as AccountInfo, - { id: "test-id" as UserId }, - ), - ); + activeAccountSubject.next({ + id: "test-id" as UserId, + ...mockAccountInfoWith({ + name: "Test User 1", + email: "test@email.com", + }), + }); if (featureFlag) { configService.getFeatureFlag.mockResolvedValue(true); diff --git a/libs/angular/src/auth/guards/lock.guard.spec.ts b/libs/angular/src/auth/guards/lock.guard.spec.ts index da89ee786b7..af36df06097 100644 --- a/libs/angular/src/auth/guards/lock.guard.spec.ts +++ b/libs/angular/src/auth/guards/lock.guard.spec.ts @@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec"; -import { - Account, - AccountInfo, - AccountService, -} from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -20,6 +16,7 @@ import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; @@ -68,16 +65,13 @@ describe("lockGuard", () => { const accountService: MockProxy<AccountService> = mock<AccountService>(); const activeAccountSubject = new BehaviorSubject<Account | null>(null); accountService.activeAccount$ = activeAccountSubject; - activeAccountSubject.next( - Object.assign( - { - name: "Test User 1", - email: "test@email.com", - emailVerified: true, - } as AccountInfo, - { id: "test-id" as UserId }, - ), - ); + activeAccountSubject.next({ + id: "test-id" as UserId, + ...mockAccountInfoWith({ + name: "Test User 1", + email: "test@email.com", + }), + }); const testBed = TestBed.configureTestingModule({ imports: [ diff --git a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts index 004499beede..6dc91fbb925 100644 --- a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts +++ b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts @@ -7,6 +7,7 @@ import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.g import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.guard"; @@ -14,9 +15,10 @@ import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked. describe("redirectToVaultIfUnlockedGuard", () => { const activeUser: Account = { id: "userId" as UserId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }; const setup = (activeUser: Account | null, authStatus: AuthenticationStatus | null) => { diff --git a/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts b/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts index 4408452a2a2..17df6d1d76b 100644 --- a/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts +++ b/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts @@ -9,6 +9,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; @@ -17,9 +18,10 @@ import { tdeDecryptionRequiredGuard } from "./tde-decryption-required.guard"; describe("tdeDecryptionRequiredGuard", () => { const activeUser: Account = { id: "fake_user_id" as UserId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }; const setup = ( diff --git a/libs/angular/src/auth/guards/unauth.guard.spec.ts b/libs/angular/src/auth/guards/unauth.guard.spec.ts index c696b849558..284f595f81a 100644 --- a/libs/angular/src/auth/guards/unauth.guard.spec.ts +++ b/libs/angular/src/auth/guards/unauth.guard.spec.ts @@ -10,6 +10,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; @@ -18,9 +19,10 @@ import { unauthGuardFn } from "./unauth.guard"; describe("UnauthGuard", () => { const activeUser: Account = { id: "fake_user_id" as UserId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }; const setup = ( diff --git a/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts b/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts index b21264eb7c8..4dc7522c1b8 100644 --- a/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts +++ b/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts @@ -11,6 +11,7 @@ import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/d import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -48,10 +49,11 @@ describe("LoginApprovalDialogComponent", () => { validationService = mock<ValidationService>(); accountService.activeAccount$ = of({ - email: testEmail, id: "test-user-id" as UserId, - emailVerified: true, - name: null, + ...mockAccountInfoWith({ + email: testEmail, + name: null, + }), }); await TestBed.configureTestingModule({ diff --git a/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts b/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts index d14e33c1fdc..5dfc5ffa245 100644 --- a/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts +++ b/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts @@ -8,6 +8,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -26,9 +27,11 @@ describe("DefaultChangePasswordService", () => { const user: Account = { id: userId, - email: "email", - emailVerified: false, - name: "name", + ...mockAccountInfoWith({ + email: "email", + name: "name", + emailVerified: false, + }), }; const passwordInputResult: PasswordInputResult = { diff --git a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts index 76cfbc0bfdd..610ec5923eb 100644 --- a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts +++ b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts @@ -2,14 +2,13 @@ import { Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of } from "rxjs"; -import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { FakeAccountService } from "@bitwarden/common/spec"; +import { mockAccountInfoWith, FakeAccountService } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogService, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -22,17 +21,15 @@ import { PromptMigrationPasswordComponent } from "./prompt-migration-password.co const SomeUser = "SomeUser" as UserId; const AnotherUser = "SomeOtherUser" as UserId; -const accounts: Record<UserId, AccountInfo> = { - [SomeUser]: { +const accounts = { + [SomeUser]: mockAccountInfoWith({ name: "some user", email: "some.user@example.com", - emailVerified: true, - }, - [AnotherUser]: { + }), + [AnotherUser]: mockAccountInfoWith({ name: "some other user", email: "some.other.user@example.com", - emailVerified: true, - }, + }), }; describe("DefaultEncryptedMigrationsSchedulerService", () => { diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index ae375c8b2f5..acb32969f08 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -189,6 +189,7 @@ export abstract class LoginStrategy { name: accountInformation.name, email: accountInformation.email ?? "", emailVerified: accountInformation.email_verified ?? false, + creationDate: undefined, // We don't get a creation date in the token. See https://bitwarden.atlassian.net/browse/PM-29551 for consolidation plans. }); // User env must be seeded from currently set env before switching to the account diff --git a/libs/auth/src/common/services/accounts/lock.services.spec.ts b/libs/auth/src/common/services/accounts/lock.services.spec.ts index e22a6f71581..41e3768d80b 100644 --- a/libs/auth/src/common/services/accounts/lock.services.spec.ts +++ b/libs/auth/src/common/services/accounts/lock.services.spec.ts @@ -8,7 +8,7 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key- import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { SystemService } from "@bitwarden/common/platform/abstractions/system.service"; -import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { mockAccountServiceWith, mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -79,17 +79,21 @@ describe("DefaultLockService", () => { ); it("locks the active account last", async () => { - await accountService.addAccount(mockUser2, { - name: "name2", - email: "email2@example.com", - emailVerified: false, - }); + await accountService.addAccount( + mockUser2, + mockAccountInfoWith({ + name: "name2", + email: "email2@example.com", + }), + ); - await accountService.addAccount(mockUser3, { - name: "name3", - email: "name3@example.com", - emailVerified: false, - }); + await accountService.addAccount( + mockUser3, + mockAccountInfoWith({ + name: "name3", + email: "name3@example.com", + }), + ); const lockSpy = jest.spyOn(sut, "lock").mockResolvedValue(undefined); diff --git a/libs/common/spec/fake-account-service.ts b/libs/common/spec/fake-account-service.ts index 389975dc2e1..ed8b7796966 100644 --- a/libs/common/spec/fake-account-service.ts +++ b/libs/common/spec/fake-account-service.ts @@ -6,19 +6,26 @@ import { ReplaySubject, combineLatest, map, Observable } from "rxjs"; import { Account, AccountInfo, AccountService } from "../src/auth/abstractions/account.service"; import { UserId } from "../src/types/guid"; +/** + * Creates a mock AccountInfo object with sensible defaults that can be overridden. + * Use this when you need just an AccountInfo object in tests. + */ +export function mockAccountInfoWith(info: Partial<AccountInfo> = {}): AccountInfo { + return { + name: "name", + email: "email", + emailVerified: true, + creationDate: "2024-01-01T00:00:00.000Z", + ...info, + }; +} + export function mockAccountServiceWith( userId: UserId, info: Partial<AccountInfo> = {}, activity: Record<UserId, Date> = {}, ): FakeAccountService { - const fullInfo: AccountInfo = { - ...info, - ...{ - name: "name", - email: "email", - emailVerified: true, - }, - }; + const fullInfo = mockAccountInfoWith(info); const fullActivity = { [userId]: new Date(), ...activity }; @@ -104,6 +111,10 @@ export class FakeAccountService implements AccountService { await this.mock.setAccountEmailVerified(userId, emailVerified); } + async setAccountCreationDate(userId: UserId, creationDate: string): Promise<void> { + await this.mock.setAccountCreationDate(userId, creationDate); + } + async switchAccount(userId: UserId): Promise<void> { const next = userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] }; @@ -127,4 +138,5 @@ const loggedOutInfo: AccountInfo = { name: undefined, email: "", emailVerified: false, + creationDate: undefined, }; diff --git a/libs/common/src/auth/abstractions/account.service.ts b/libs/common/src/auth/abstractions/account.service.ts index 8b0280feb01..78822f3ebd5 100644 --- a/libs/common/src/auth/abstractions/account.service.ts +++ b/libs/common/src/auth/abstractions/account.service.ts @@ -2,14 +2,11 @@ import { Observable } from "rxjs"; import { UserId } from "../../types/guid"; -/** - * Holds information about an account for use in the AccountService - * if more information is added, be sure to update the equality method. - */ export type AccountInfo = { email: string; emailVerified: boolean; name: string | undefined; + creationDate: string | undefined; }; export type Account = { id: UserId } & AccountInfo; @@ -75,6 +72,12 @@ export abstract class AccountService { * @param emailVerified */ abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>; + /** + * updates the `accounts$` observable with the creation date for the account. + * @param userId + * @param creationDate + */ + abstract setAccountCreationDate(userId: UserId, creationDate: string): Promise<void>; /** * updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account. * @param userId diff --git a/libs/common/src/auth/services/account.service.spec.ts b/libs/common/src/auth/services/account.service.spec.ts index 3e3c878eaac..f517b61ffb6 100644 --- a/libs/common/src/auth/services/account.service.spec.ts +++ b/libs/common/src/auth/services/account.service.spec.ts @@ -6,6 +6,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { firstValueFrom } from "rxjs"; +import { mockAccountInfoWith } from "../../../spec/fake-account-service"; import { FakeGlobalState } from "../../../spec/fake-state"; import { FakeGlobalStateProvider, @@ -27,7 +28,7 @@ import { } from "./account.service"; describe("accountInfoEqual", () => { - const accountInfo: AccountInfo = { name: "name", email: "email", emailVerified: true }; + const accountInfo = mockAccountInfoWith(); it("compares nulls", () => { expect(accountInfoEqual(null, null)).toBe(true); @@ -64,6 +65,23 @@ describe("accountInfoEqual", () => { expect(accountInfoEqual(accountInfo, same)).toBe(true); expect(accountInfoEqual(accountInfo, different)).toBe(false); }); + + it("compares creationDate", () => { + const same = { ...accountInfo }; + const different = { ...accountInfo, creationDate: "2024-12-31T00:00:00.000Z" }; + + expect(accountInfoEqual(accountInfo, same)).toBe(true); + expect(accountInfoEqual(accountInfo, different)).toBe(false); + }); + + it("compares undefined creationDate", () => { + const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined }); + const same = { ...accountWithoutCreationDate }; + const different = { ...accountWithoutCreationDate, creationDate: "2024-01-01T00:00:00.000Z" }; + + expect(accountInfoEqual(accountWithoutCreationDate, same)).toBe(true); + expect(accountInfoEqual(accountWithoutCreationDate, different)).toBe(false); + }); }); describe("accountService", () => { @@ -76,7 +94,10 @@ describe("accountService", () => { let activeAccountIdState: FakeGlobalState<UserId>; let accountActivityState: FakeGlobalState<Record<UserId, Date>>; const userId = Utils.newGuid() as UserId; - const userInfo = { email: "email", name: "name", emailVerified: true }; + const userInfo = mockAccountInfoWith({ + email: "email", + name: "name", + }); beforeEach(() => { messagingService = mock(); @@ -253,6 +274,56 @@ describe("accountService", () => { }); }); + describe("setCreationDate", () => { + const initialState = { [userId]: userInfo }; + beforeEach(() => { + accountsState.stateSubject.next(initialState); + }); + + it("should update the account with a new creation date", async () => { + const newCreationDate = "2024-12-31T00:00:00.000Z"; + await sut.setAccountCreationDate(userId, newCreationDate); + const currentState = await firstValueFrom(accountsState.state$); + + expect(currentState).toEqual({ + [userId]: { ...userInfo, creationDate: newCreationDate }, + }); + }); + + it("should not update if the creation date is the same", async () => { + await sut.setAccountCreationDate(userId, userInfo.creationDate); + const currentState = await firstValueFrom(accountsState.state$); + + expect(currentState).toEqual(initialState); + }); + + it("should update from undefined to a defined creation date", async () => { + const accountWithoutCreationDate = mockAccountInfoWith({ + ...userInfo, + creationDate: undefined, + }); + accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate }); + + const newCreationDate = "2024-06-15T12:30:00.000Z"; + await sut.setAccountCreationDate(userId, newCreationDate); + const currentState = await firstValueFrom(accountsState.state$); + + expect(currentState).toEqual({ + [userId]: { ...accountWithoutCreationDate, creationDate: newCreationDate }, + }); + }); + + it("should update to a different creation date string format", async () => { + const newCreationDate = "2023-03-15T08:45:30.123Z"; + await sut.setAccountCreationDate(userId, newCreationDate); + const currentState = await firstValueFrom(accountsState.state$); + + expect(currentState).toEqual({ + [userId]: { ...userInfo, creationDate: newCreationDate }, + }); + }); + }); + describe("setAccountVerifyNewDeviceLogin", () => { const initialState = true; beforeEach(() => { @@ -294,6 +365,7 @@ describe("accountService", () => { email: "", emailVerified: false, name: undefined, + creationDate: undefined, }, }); }); diff --git a/libs/common/src/auth/services/account.service.ts b/libs/common/src/auth/services/account.service.ts index fb4b590ce77..1b028d1eba9 100644 --- a/libs/common/src/auth/services/account.service.ts +++ b/libs/common/src/auth/services/account.service.ts @@ -62,6 +62,7 @@ const LOGGED_OUT_INFO: AccountInfo = { email: "", emailVerified: false, name: undefined, + creationDate: undefined, }; /** @@ -167,6 +168,10 @@ export class AccountServiceImplementation implements InternalAccountService { await this.setAccountInfo(userId, { emailVerified }); } + async setAccountCreationDate(userId: UserId, creationDate: string): Promise<void> { + await this.setAccountInfo(userId, { creationDate }); + } + async clean(userId: UserId) { await this.setAccountInfo(userId, LOGGED_OUT_INFO); await this.removeAccountActivity(userId); diff --git a/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts b/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts index 0b12e1cb661..a44dde04f5f 100644 --- a/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts +++ b/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts @@ -15,6 +15,7 @@ import { SystemNotificationEvent, SystemNotificationsService, } from "@bitwarden/common/platform/system-notifications/system-notifications.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/user-core"; import { AuthRequestAnsweringService } from "./auth-request-answering.service"; @@ -48,14 +49,16 @@ describe("AuthRequestAnsweringService", () => { // Common defaults authService.activeAccountStatus$ = of(AuthenticationStatus.Locked); - accountService.activeAccount$ = of({ - id: userId, + const accountInfo = mockAccountInfoWith({ email: "user@example.com", - emailVerified: true, name: "User", }); + accountService.activeAccount$ = of({ + id: userId, + ...accountInfo, + }); accountService.accounts$ = of({ - [userId]: { email: "user@example.com", emailVerified: true, name: "User" }, + [userId]: accountInfo, }); (masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue( of(ForceSetPasswordReason.None), diff --git a/libs/common/src/auth/services/auth.service.spec.ts b/libs/common/src/auth/services/auth.service.spec.ts index 5dcb8c372e5..c7ff55e6bb1 100644 --- a/libs/common/src/auth/services/auth.service.spec.ts +++ b/libs/common/src/auth/services/auth.service.spec.ts @@ -10,6 +10,7 @@ import { makeStaticByteArray, mockAccountServiceWith, trackEmissions, + mockAccountInfoWith, } from "../../../spec"; import { ApiService } from "../../abstractions/api.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; @@ -58,9 +59,10 @@ describe("AuthService", () => { const accountInfo = { status: AuthenticationStatus.Unlocked, id: userId, - email: "email", - emailVerified: false, - name: "name", + ...mockAccountInfoWith({ + email: "email", + name: "name", + }), }; beforeEach(() => { @@ -112,9 +114,10 @@ describe("AuthService", () => { const accountInfo2 = { status: AuthenticationStatus.Unlocked, id: Utils.newGuid() as UserId, - email: "email2", - emailVerified: false, - name: "name2", + ...mockAccountInfoWith({ + email: "email2", + name: "name2", + }), }; const emissions = trackEmissions(sut.activeAccountStatus$); @@ -131,11 +134,13 @@ describe("AuthService", () => { it("requests auth status for all known users", async () => { const userId2 = Utils.newGuid() as UserId; - await accountService.addAccount(userId2, { - email: "email2", - emailVerified: false, - name: "name2", - }); + await accountService.addAccount( + userId2, + mockAccountInfoWith({ + email: "email2", + name: "name2", + }), + ); const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked)); sut.authStatusFor$ = mockFn; diff --git a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts index 7e6e0d53f57..693992d4c4a 100644 --- a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts +++ b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts @@ -8,12 +8,13 @@ import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; +import { mockAccountInfoWith } from "../../../spec/fake-account-service"; import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response"; import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { UserId } from "../../types/guid"; -import { Account, AccountInfo, AccountService } from "../abstractions/account.service"; +import { Account, AccountService } from "../abstractions/account.service"; import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation"; @@ -96,11 +97,10 @@ describe("PasswordResetEnrollmentServiceImplementation", () => { const encryptedKey = { encryptedString: "encryptedString" }; organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any); - const user1AccountInfo: AccountInfo = { + const user1AccountInfo = mockAccountInfoWith({ name: "Test User 1", email: "test1@email.com", - emailVerified: true, - }; + }); activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId })); keyService.userKey$.mockReturnValue(of({ key: "key" } as any)); diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts index 51eec18f173..8f7f93f368c 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts @@ -7,7 +7,7 @@ import { BehaviorSubject, from, of } from "rxjs"; // eslint-disable-next-line no-restricted-imports import { LockService, LogoutService } from "@bitwarden/auth/common"; -import { FakeAccountService, mockAccountServiceWith } from "../../../../spec"; +import { FakeAccountService, mockAccountServiceWith, mockAccountInfoWith } from "../../../../spec"; import { AccountInfo } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; @@ -109,19 +109,19 @@ describe("VaultTimeoutService", () => { if (globalSetups?.userId) { accountService.activeAccountSubject.next({ id: globalSetups.userId as UserId, - email: null, - emailVerified: false, - name: null, + ...mockAccountInfoWith({ + email: null, + name: null, + }), }); } accountService.accounts$ = of( Object.entries(accounts).reduce( (agg, [id]) => { - agg[id] = { + agg[id] = mockAccountInfoWith({ email: "", - emailVerified: true, name: "", - }; + }); return agg; }, {} as Record<string, AccountInfo>, diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts index cd1bf97150c..46178f62a07 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts @@ -7,6 +7,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { mockAccountInfoWith } from "../../../../spec"; import { AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; @@ -163,9 +164,10 @@ describe("DefaultServerNotificationsService (multi-user)", () => { } else { activeUserAccount$.next({ id: userId, - email: "email", - name: "Test Name", - emailVerified: true, + ...mockAccountInfoWith({ + email: "email", + name: "Test Name", + }), }); } } @@ -174,7 +176,10 @@ describe("DefaultServerNotificationsService (multi-user)", () => { const currentAccounts = (userAccounts$.getValue() as Record<string, any>) ?? {}; userAccounts$.next({ ...currentAccounts, - [userId]: { email: "email", name: "Test Name", emailVerified: true }, + [userId]: mockAccountInfoWith({ + email: "email", + name: "Test Name", + }), } as any); } diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts index 4a9b0809ac9..9c84981b7f9 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts @@ -8,7 +8,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; -import { awaitAsync } from "../../../../spec"; +import { awaitAsync, mockAccountInfoWith } from "../../../../spec"; import { Matrix } from "../../../../spec/matrix"; import { AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; @@ -139,11 +139,18 @@ describe("NotificationsService", () => { activeAccount.next(null); accounts.next({} as any); } else { - activeAccount.next({ id: userId, email: "email", name: "Test Name", emailVerified: true }); + const accountInfo = mockAccountInfoWith({ + email: "email", + name: "Test Name", + }); + activeAccount.next({ + id: userId, + ...accountInfo, + }); const current = (accounts.getValue() as Record<string, any>) ?? {}; accounts.next({ ...current, - [userId]: { email: "email", name: "Test Name", emailVerified: true }, + [userId]: accountInfo, } as any); } } @@ -349,7 +356,13 @@ describe("NotificationsService", () => { describe("processNotification", () => { beforeEach(async () => { appIdService.getAppId.mockResolvedValue("test-app-id"); - activeAccount.next({ id: mockUser1, email: "email", name: "Test Name", emailVerified: true }); + activeAccount.next({ + id: mockUser1, + ...mockAccountInfoWith({ + email: "email", + name: "Test Name", + }), + }); }); describe("NotificationType.LogOut", () => { diff --git a/libs/common/src/platform/services/default-environment.service.spec.ts b/libs/common/src/platform/services/default-environment.service.spec.ts index 553f80f83b8..9e8a41616a3 100644 --- a/libs/common/src/platform/services/default-environment.service.spec.ts +++ b/libs/common/src/platform/services/default-environment.service.spec.ts @@ -1,6 +1,6 @@ import { firstValueFrom } from "rxjs"; -import { FakeStateProvider, awaitAsync } from "../../../spec"; +import { FakeStateProvider, awaitAsync, mockAccountInfoWith } from "../../../spec"; import { FakeAccountService } from "../../../spec/fake-account-service"; import { UserId } from "../../types/guid"; import { CloudRegion, Region } from "../abstractions/environment.service"; @@ -28,16 +28,14 @@ describe("EnvironmentService", () => { beforeEach(async () => { accountService = new FakeAccountService({ - [testUser]: { + [testUser]: mockAccountInfoWith({ name: "name", email: "email", - emailVerified: false, - }, - [alternateTestUser]: { + }), + [alternateTestUser]: mockAccountInfoWith({ name: "name", email: "email", - emailVerified: false, - }, + }), }); stateProvider = new FakeStateProvider(accountService); @@ -47,9 +45,10 @@ describe("EnvironmentService", () => { const switchUser = async (userId: UserId) => { accountService.activeAccountSubject.next({ id: userId, - email: "test@example.com", - name: `Test Name ${userId}`, - emailVerified: false, + ...mockAccountInfoWith({ + email: "test@example.com", + name: `Test Name ${userId}`, + }), }); await awaitAsync(); }; diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index fef64399b40..9c50bd1ab65 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -3,7 +3,7 @@ import { TextEncoder } from "util"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; -import { mockAccountServiceWith } from "../../../../spec"; +import { mockAccountServiceWith, mockAccountInfoWith } from "../../../../spec"; import { Account } from "../../../auth/abstractions/account.service"; import { CipherId, UserId } from "../../../types/guid"; import { CipherService, EncryptionContext } from "../../../vault/abstractions/cipher.service"; @@ -40,9 +40,10 @@ describe("FidoAuthenticatorService", () => { const userId = "testId" as UserId; const activeAccountSubject = new BehaviorSubject<Account | null>({ id: userId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); let cipherService!: MockProxy<CipherService>; diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts index 1286ea7b7f9..fb9c1fae77e 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts @@ -12,9 +12,9 @@ import { FakeAccountService, FakeStateProvider, mockAccountServiceWith, + mockAccountInfoWith, } from "../../../../spec"; import { ApiService } from "../../../abstractions/api.service"; -import { AccountInfo } from "../../../auth/abstractions/account.service"; import { EncryptedString } from "../../../key-management/crypto/models/enc-string"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; @@ -92,7 +92,10 @@ describe("DefaultSdkService", () => { .calledWith(userId) .mockReturnValue(new BehaviorSubject(mock<Environment>())); accountService.accounts$ = of({ - [userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo, + [userId]: mockAccountInfoWith({ + email: "email", + name: "name", + }), }); kdfConfigService.getKdfConfig$ .calledWith(userId) diff --git a/libs/common/src/platform/services/sdk/register-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/register-sdk.service.spec.ts index 0a05ac8dbf4..1f4d086f729 100644 --- a/libs/common/src/platform/services/sdk/register-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/register-sdk.service.spec.ts @@ -8,9 +8,9 @@ import { FakeAccountService, FakeStateProvider, mockAccountServiceWith, + mockAccountInfoWith, } from "../../../../spec"; import { ApiService } from "../../../abstractions/api.service"; -import { AccountInfo } from "../../../auth/abstractions/account.service"; import { UserId } from "../../../types/guid"; import { ConfigService } from "../../abstractions/config/config.service"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; @@ -76,7 +76,10 @@ describe("DefaultRegisterSdkService", () => { .calledWith(userId) .mockReturnValue(new BehaviorSubject(mock<Environment>())); accountService.accounts$ = of({ - [userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo, + [userId]: mockAccountInfoWith({ + email: "email", + name: "name", + }), }); }); @@ -125,7 +128,10 @@ describe("DefaultRegisterSdkService", () => { it("destroys the internal SDK client when the account is removed (logout)", async () => { const accounts$ = new BehaviorSubject({ - [userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo, + [userId]: mockAccountInfoWith({ + email: "email", + name: "name", + }), }); accountService.accounts$ = accounts$; diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 910702bddd0..8d2ccaffa18 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -272,6 +272,7 @@ export class DefaultSyncService extends CoreSyncService { await this.tokenService.setSecurityStamp(response.securityStamp, response.id); await this.accountService.setAccountEmailVerified(response.id, response.emailVerified); await this.accountService.setAccountVerifyNewDeviceLogin(response.id, response.verifyDevices); + await this.accountService.setAccountCreationDate(response.id, response.creationDate); await this.billingAccountProfileStateService.setHasPremium( response.premiumPersonally, diff --git a/libs/common/src/services/api.service.spec.ts b/libs/common/src/services/api.service.spec.ts index 1fb8f86697f..9ab84ecb16b 100644 --- a/libs/common/src/services/api.service.spec.ts +++ b/libs/common/src/services/api.service.spec.ts @@ -6,6 +6,7 @@ import { ObservedValueOf, of } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; import { UserId } from "@bitwarden/user-core"; +import { mockAccountInfoWith } from "../../spec"; import { AccountService } from "../auth/abstractions/account.service"; import { TokenService } from "../auth/abstractions/token.service"; import { DeviceType } from "../enums"; @@ -55,9 +56,10 @@ describe("ApiService", () => { accountService.activeAccount$ = of({ id: testActiveUser, - email: "user1@example.com", - emailVerified: true, - name: "Test Name", + ...mockAccountInfoWith({ + email: "user1@example.com", + name: "Test Name", + }), } satisfies ObservedValueOf<AccountService["activeAccount$"]>); httpOperations = mock(); diff --git a/libs/common/src/tools/extension/extension.service.spec.ts b/libs/common/src/tools/extension/extension.service.spec.ts index 9959488feca..c0dec8728fe 100644 --- a/libs/common/src/tools/extension/extension.service.spec.ts +++ b/libs/common/src/tools/extension/extension.service.spec.ts @@ -1,7 +1,12 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom } from "rxjs"; -import { FakeAccountService, FakeStateProvider, awaitAsync } from "../../../spec"; +import { + FakeAccountService, + FakeStateProvider, + awaitAsync, + mockAccountInfoWith, +} from "../../../spec"; import { Account } from "../../auth/abstractions/account.service"; import { EXTENSION_DISK, UserKeyDefinition } from "../../platform/state"; import { UserId } from "../../types/guid"; @@ -21,9 +26,10 @@ import { SimpleLogin } from "./vendor/simplelogin"; const SomeUser = "some user" as UserId; const SomeAccount = { id: SomeUser, - email: "someone@example.com", - emailVerified: true, - name: "Someone", + ...mockAccountInfoWith({ + email: "someone@example.com", + name: "Someone", + }), }; const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount); diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 96fb2f43c88..397ae905e31 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -11,6 +11,7 @@ import { FakeStateProvider, awaitAsync, mockAccountServiceWith, + mockAccountInfoWith, } from "../../../../spec"; import { KeyGenerationService } from "../../../key-management/crypto"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; @@ -71,9 +72,10 @@ describe("SendService", () => { accountService.activeAccountSubject.next({ id: mockUserId, - email: "email", - emailVerified: false, - name: "name", + ...mockAccountInfoWith({ + email: "email", + name: "name", + }), }); // Initial encrypted state diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts index a6d452d37fd..b88c358b6ab 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -6,6 +6,7 @@ import { awaitAsync, FakeAccountService, FakeStateProvider, + mockAccountInfoWith, ObservableTracker, } from "../../../spec"; import { Account } from "../../auth/abstractions/account.service"; @@ -23,17 +24,19 @@ import { UserStateSubject } from "./user-state-subject"; const SomeUser = "some user" as UserId; const SomeAccount = { id: SomeUser, - email: "someone@example.com", - emailVerified: true, - name: "Someone", + ...mockAccountInfoWith({ + email: "someone@example.com", + name: "Someone", + }), }; const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount); const SomeOtherAccount = { id: "some other user" as UserId, - email: "someone@example.com", - emailVerified: true, - name: "Someone", + ...mockAccountInfoWith({ + email: "someone@example.com", + name: "Someone", + }), }; type TestType = { foo: string }; diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts index 6e98b21977d..fdf92cac751 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts @@ -6,6 +6,7 @@ import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { emptyGuid, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey, UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -41,9 +42,10 @@ describe("BitwardenPasswordProtectedImporter", () => { accountService.activeAccount$ = of({ id: emptyGuid as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); const mockOrgId = emptyGuid as OrganizationId; @@ -96,9 +98,10 @@ describe("BitwardenPasswordProtectedImporter", () => { beforeEach(() => { accountService.activeAccount$ = of({ id: emptyGuid as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); importer = new BitwardenPasswordProtectedImporter( keyService, diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts index d40cc98df11..71287e7684c 100644 --- a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -11,6 +11,7 @@ import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/ma import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserKey } from "@bitwarden/common/types/key"; import { AsyncActionsModule, @@ -39,9 +40,10 @@ describe("MasterPasswordLockComponent", () => { const mockMasterPassword = "testExample"; const activeAccount: Account = { id: "user-id" as UserId, - email: "user@example.com", - emailVerified: true, - name: "User", + ...mockAccountInfoWith({ + email: "user@example.com", + name: "User", + }), }; const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; diff --git a/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts b/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts index 376b46cd6e8..39ff74ad901 100644 --- a/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts +++ b/libs/tools/generator/core/src/providers/generator-metadata-provider.spec.ts @@ -25,7 +25,11 @@ import { deepFreeze } from "@bitwarden/common/tools/util"; import { UserId } from "@bitwarden/common/types/guid"; import { BitwardenClient } from "@bitwarden/sdk-internal"; -import { FakeAccountService, FakeStateProvider } from "../../../../../common/spec"; +import { + FakeAccountService, + FakeStateProvider, + mockAccountInfoWith, +} from "../../../../../common/spec"; import { Algorithm, AlgorithmsByType, CredentialAlgorithm, Type, Types } from "../metadata"; import catchall from "../metadata/email/catchall"; import plusAddress from "../metadata/email/plus-address"; @@ -40,9 +44,10 @@ import { GeneratorMetadataProvider } from "./generator-metadata-provider"; const SomeUser = "some user" as UserId; const SomeAccount = { id: SomeUser, - email: "someone@example.com", - emailVerified: true, - name: "Someone", + ...mockAccountInfoWith({ + email: "someone@example.com", + name: "Someone", + }), }; const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount); diff --git a/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts index 924849b1c22..088bf543fee 100644 --- a/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts +++ b/libs/tools/generator/core/src/providers/generator-profile-provider.spec.ts @@ -15,7 +15,12 @@ import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/stat import { StateConstraints } from "@bitwarden/common/tools/types"; import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; -import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec"; +import { + FakeStateProvider, + FakeAccountService, + awaitAsync, + mockAccountInfoWith, +} from "../../../../../common/spec"; import { CoreProfileMetadata, ProfileContext } from "../metadata/profile-metadata"; import { GeneratorConstraints } from "../types"; @@ -31,21 +36,25 @@ const UnverifiedEmailUser = "UnverifiedEmailUser" as UserId; const accounts: Record<UserId, Account> = { [SomeUser]: { id: SomeUser, - name: "some user", - email: "some.user@example.com", - emailVerified: true, + ...mockAccountInfoWith({ + name: "some user", + email: "some.user@example.com", + }), }, [AnotherUser]: { id: AnotherUser, - name: "some other user", - email: "some.other.user@example.com", - emailVerified: true, + ...mockAccountInfoWith({ + name: "some other user", + email: "some.other.user@example.com", + }), }, [UnverifiedEmailUser]: { id: UnverifiedEmailUser, - name: "a user with an unverfied email", - email: "unverified@example.com", - emailVerified: false, + ...mockAccountInfoWith({ + name: "a user with an unverfied email", + email: "unverified@example.com", + emailVerified: false, + }), }, }; const accountService = new FakeAccountService(accounts); diff --git a/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts index 81e7ae6ac63..e459bb47f47 100644 --- a/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/default-credential-generator.service.spec.ts @@ -8,7 +8,7 @@ import { Vendor } from "@bitwarden/common/tools/extension/vendor/data"; import { SemanticLogger, ifEnabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; import { UserId } from "@bitwarden/common/types/guid"; -import { awaitAsync } from "../../../../../common/spec"; +import { awaitAsync, mockAccountInfoWith } from "../../../../../common/spec"; import { Algorithm, CredentialAlgorithm, @@ -56,9 +56,10 @@ describe("DefaultCredentialGeneratorService", () => { // Use a hard-coded value for mockAccount account = { id: "test-account-id" as UserId, - emailVerified: true, - email: "test@example.com", - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; system = { diff --git a/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.spec.ts b/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.spec.ts index b832ac36caf..ca77c94898b 100644 --- a/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.spec.ts +++ b/libs/tools/send/send-ui/src/send-list-filters/send-list-filters.component.spec.ts @@ -8,6 +8,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { ChipSelectComponent } from "@bitwarden/components"; @@ -31,9 +32,11 @@ describe("SendListFiltersComponent", () => { accountService.activeAccount$ = of({ id: userId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + emailVerified: true, + }), }); billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.spec.ts b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.spec.ts index 42144e646d4..9ff8f7c83da 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.spec.ts +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.spec.ts @@ -11,6 +11,7 @@ import { EventType } from "@bitwarden/common/enums"; 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"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -34,9 +35,10 @@ describe("LoginCredentialsViewComponent", () => { const hasPremiumFromAnySource$ = new BehaviorSubject<boolean>(true); const mockAccount = { id: "test-user-id" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), type: 0, status: 0, kdf: 0, diff --git a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts index 68b0d9dfcf5..9bf53826333 100644 --- a/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts +++ b/libs/vault/src/components/add-edit-folder-dialog/add-edit-folder-dialog.component.spec.ts @@ -2,9 +2,10 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { BehaviorSubject } from "rxjs"; -import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -47,11 +48,7 @@ describe("AddEditFolderDialogComponent", () => { showToast.mockClear(); const userId = "" as UserId; - const accountInfo: AccountInfo = { - email: "", - emailVerified: true, - name: undefined, - }; + const accountInfo = mockAccountInfoWith(); await TestBed.configureTestingModule({ imports: [AddEditFolderDialogComponent, NoopAnimationsModule], diff --git a/tsconfig.base.json b/tsconfig.base.json index 2d105d4263d..ae4b9f5f601 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -29,6 +29,7 @@ "@bitwarden/browser/*": ["./apps/browser/src/*"], "@bitwarden/cli/*": ["./apps/cli/src/*"], "@bitwarden/client-type": ["libs/client-type/src/index.ts"], + "@bitwarden/common/spec": ["./libs/common/spec"], "@bitwarden/common/*": ["./libs/common/src/*"], "@bitwarden/components": ["./libs/components/src"], "@bitwarden/core-test-utils": ["libs/core-test-utils/src/index.ts"], From 944d324985d9b78059e6215f3cbdeaf19215e37f Mon Sep 17 00:00:00 2001 From: adudek-bw <adudek@bitwarden.com> Date: Fri, 12 Dec 2025 12:38:35 -0500 Subject: [PATCH 58/60] [PM-27081] Fix chromium direct import for linux (#17894) * Fix chromium direct import for linux --- .../chromium_importer/src/chromium/platform/linux.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs index f542e23129a..6fb6e6134c7 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs @@ -18,7 +18,7 @@ use crate::{ pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Chrome", - data_dir: &[".config/google-chrome"], + data_dir: &[".config/google-chrome", "snap/chromium/common/chromium"], }, BrowserConfig { name: "Chromium", From 14dd732b522eff9cc69d771f413af259aca51053 Mon Sep 17 00:00:00 2001 From: Alex Dragovich <46065570+itsadrago@users.noreply.github.com> Date: Fri, 12 Dec 2025 09:43:34 -0800 Subject: [PATCH 59/60] [PM-23258] changing verbiage from import data to import items (#17123) * [PM-23258] changing verbiage from import data to import items * [PM-23258] Removing vault and data from import and export titles, navs, and buttons * [PM-23258] more verbiage changes * [PM-23258] reverting unnecessary change * [PM-23258] removing unused text from messages json files * [PM-23258] small text changes from design * [PM-23258] including secrets manager changes --- apps/browser/src/_locales/en/messages.json | 18 +++++++----------- .../export/export-browser-v2.component.html | 4 ++-- .../import/import-browser-v2.component.html | 4 ++-- .../settings/vault-settings-v2.component.html | 6 +++--- .../tools/export/export-desktop.component.html | 4 ++-- .../tools/import/import-desktop.component.html | 4 ++-- apps/desktop/src/locales/en/messages.json | 15 +++++++-------- apps/desktop/src/main/menu/menu.file.ts | 12 ++++++------ .../layouts/organization-layout.component.html | 4 ++-- .../organization-settings-routing.module.ts | 4 ++-- .../src/app/layouts/user-layout.component.html | 4 ++-- apps/web/src/app/oss-routing.module.ts | 4 ++-- .../app/tools/import/import-web.component.html | 2 +- .../app/tools/import/org-import.component.html | 2 +- .../vault-export/export-web.component.html | 2 +- .../org-vault-export.component.html | 2 +- apps/web/src/locales/en/messages.json | 15 +++------------ .../layout/navigation.component.html | 4 ++-- .../settings/porting/sm-export.component.html | 2 +- .../settings/porting/sm-export.component.ts | 2 +- .../settings/porting/sm-import.component.html | 2 +- .../settings/settings-routing.module.ts | 4 ++-- .../dialog/file-password-prompt.component.html | 2 +- .../src/components/export.component.ts | 2 +- 24 files changed, 55 insertions(+), 69 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 09ea964823c..36d69fb09f5 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -436,8 +436,8 @@ "sync": { "message": "Sync" }, - "syncVaultNow": { - "message": "Sync vault now" + "syncNow": { + "message": "Sync now" }, "lastSync": { "message": "Last sync:" @@ -455,9 +455,6 @@ "bitWebVaultApp": { "message": "Bitwarden web app" }, - "importItems": { - "message": "Import items" - }, "select": { "message": "Select" }, @@ -1325,8 +1322,11 @@ "exportFrom": { "message": "Export from" }, - "exportVault": { - "message": "Export vault" + "export": { + "message": "Export" + }, + "import": { + "message": "Import" }, "fileFormat": { "message": "File format" @@ -4215,10 +4215,6 @@ "ignore": { "message": "Ignore" }, - "importData": { - "message": "Import data", - "description": "Used for the header of the import dialog, the import button and within the file-password-prompt" - }, "importError": { "message": "Import error" }, diff --git a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html index d6bf3a3a253..5473bbe620e 100644 --- a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html +++ b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html @@ -1,5 +1,5 @@ <popup-page> - <popup-header slot="header" [pageTitle]="'exportVault' | i18n" showBackButton> + <popup-header slot="header" [pageTitle]="'export' | i18n" showBackButton> <ng-container slot="end"> <app-pop-out></app-pop-out> </ng-container> @@ -21,7 +21,7 @@ bitFormButton buttonType="primary" > - {{ "exportVault" | i18n }} + {{ "export" | i18n }} </button> <button bitButton type="button" buttonType="secondary" [popupBackAction]> {{ "cancel" | i18n }} diff --git a/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.html b/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.html index 5458b46535a..db58e3f0227 100644 --- a/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.html +++ b/apps/browser/src/tools/popup/settings/import/import-browser-v2.component.html @@ -1,5 +1,5 @@ <popup-page> - <popup-header slot="header" [pageTitle]="'importData' | i18n" showBackButton> + <popup-header slot="header" [pageTitle]="'import' | i18n" showBackButton> <ng-container slot="end"> <app-pop-out></app-pop-out> </ng-container> @@ -22,7 +22,7 @@ bitFormButton buttonType="primary" > - {{ "importData" | i18n }} + {{ "import" | i18n }} </button> </popup-footer> </popup-page> diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html index 225640137e8..c042af8cbac 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html @@ -15,7 +15,7 @@ <bit-item> <button type="button" bit-item-content (click)="import()"> <div class="tw-flex tw-items-center tw-justify-center tw-gap-2"> - <p>{{ "importItems" | i18n }}</p> + <p>{{ "import" | i18n }}</p> <span *ngIf="emptyVaultImportBadge$ | async" bitBadge @@ -30,7 +30,7 @@ </bit-item> <bit-item> <a bit-item-content routerLink="/export"> - {{ "exportVault" | i18n }} + {{ "export" | i18n }} <i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i> </a> </bit-item> @@ -64,7 +64,7 @@ </bit-item> <bit-item> <button type="button" bit-item-content (click)="sync()"> - {{ "syncVaultNow" | i18n }} + {{ "syncNow" | i18n }} <span slot="secondary">{{ lastSync }}</span> <i slot="end" class="bwi bwi-refresh" aria-hidden="true"></i> </button> diff --git a/apps/desktop/src/app/tools/export/export-desktop.component.html b/apps/desktop/src/app/tools/export/export-desktop.component.html index 9aa59c5a636..a969b86b950 100644 --- a/apps/desktop/src/app/tools/export/export-desktop.component.html +++ b/apps/desktop/src/app/tools/export/export-desktop.component.html @@ -1,5 +1,5 @@ <bit-dialog #dialog dialogSize="large"> - <span bitDialogTitle>{{ "exportVault" | i18n }}</span> + <span bitDialogTitle>{{ "export" | i18n }}</span> <ng-container bitDialogContent> <tools-export (formLoading)="this.loading = $event" @@ -17,7 +17,7 @@ bitFormButton buttonType="primary" > - {{ "exportVault" | i18n }} + {{ "export" | i18n }} </button> <button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose> {{ "cancel" | i18n }} diff --git a/apps/desktop/src/app/tools/import/import-desktop.component.html b/apps/desktop/src/app/tools/import/import-desktop.component.html index 3ee2384691b..b5011f4243e 100644 --- a/apps/desktop/src/app/tools/import/import-desktop.component.html +++ b/apps/desktop/src/app/tools/import/import-desktop.component.html @@ -1,5 +1,5 @@ <bit-dialog #dialog dialogSize="large" background="alt"> - <span bitDialogTitle>{{ "importData" | i18n }}</span> + <span bitDialogTitle>{{ "import" | i18n }}</span> <ng-container bitDialogContent> <div class="tw-relative"> <tools-import @@ -27,7 +27,7 @@ bitFormButton buttonType="primary" > - {{ "importData" | i18n }} + {{ "import" | i18n }} </button> <button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose> {{ "cancel" | i18n }} diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 48e346d9c68..3659c75bfca 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -1198,8 +1198,8 @@ "followUs": { "message": "Follow us" }, - "syncVault": { - "message": "Sync vault" + "syncNow": { + "message": "Sync now" }, "changeMasterPass": { "message": "Change master password" @@ -1775,8 +1775,11 @@ "exportFrom": { "message": "Export from" }, - "exportVault": { - "message": "Export vault" + "export": { + "message": "Export" + }, + "import": { + "message": "Import" }, "fileFormat": { "message": "File format" @@ -3492,10 +3495,6 @@ "aliasDomain": { "message": "Alias domain" }, - "importData": { - "message": "Import data", - "description": "Used for the desktop menu item and the header of the import dialog" - }, "importError": { "message": "Import error" }, diff --git a/apps/desktop/src/main/menu/menu.file.ts b/apps/desktop/src/main/menu/menu.file.ts index a8cdb347a77..eb5f5a9d747 100644 --- a/apps/desktop/src/main/menu/menu.file.ts +++ b/apps/desktop/src/main/menu/menu.file.ts @@ -146,8 +146,8 @@ export class FileMenu extends FirstMenu implements IMenubarMenu { private get syncVault(): MenuItemConstructorOptions { return { - id: "syncVault", - label: this.localize("syncVault"), + id: "syncNow", + label: this.localize("syncNow"), click: () => this.sendMessage("syncVault"), enabled: this.hasAuthenticatedAccounts, }; @@ -155,8 +155,8 @@ export class FileMenu extends FirstMenu implements IMenubarMenu { private get importVault(): MenuItemConstructorOptions { return { - id: "importVault", - label: this.localize("importData"), + id: "import", + label: this.localize("import"), click: () => this.sendMessage("importVault"), enabled: !this._isLocked, }; @@ -164,8 +164,8 @@ export class FileMenu extends FirstMenu implements IMenubarMenu { private get exportVault(): MenuItemConstructorOptions { return { - id: "exportVault", - label: this.localize("exportVault"), + id: "export", + label: this.localize("export"), click: () => this.sendMessage("exportVault"), enabled: !this._isLocked, }; diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index 59bc03babd4..85bfe0bce0a 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -104,12 +104,12 @@ *ngIf="organization.use2fa && organization.isOwner" ></bit-nav-item> <bit-nav-item - [text]="'importData' | i18n" + [text]="'import' | i18n" route="settings/tools/import" *ngIf="organization.canAccessImport" ></bit-nav-item> <bit-nav-item - [text]="'exportVault' | i18n" + [text]="'export' | i18n" route="settings/tools/export" *ngIf="canAccessExport$ | async" ></bit-nav-item> diff --git a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts index a644086628c..61d7d0e04ac 100644 --- a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts @@ -57,7 +57,7 @@ const routes: Routes = [ ), canActivate: [organizationPermissionsGuard((org) => org.canAccessImport)], data: { - titleId: "importData", + titleId: "import", }, }, { @@ -68,7 +68,7 @@ const routes: Routes = [ ), canActivate: [organizationPermissionsGuard((org) => org.canAccessExport)], data: { - titleId: "exportVault", + titleId: "export", }, }, ], diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html index 9f474062120..27955a2b0ca 100644 --- a/apps/web/src/app/layouts/user-layout.component.html +++ b/apps/web/src/app/layouts/user-layout.component.html @@ -6,8 +6,8 @@ <bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="sends"></bit-nav-item> <bit-nav-group icon="bwi-wrench" [text]="'tools' | i18n" route="tools"> <bit-nav-item [text]="'generator' | i18n" route="tools/generator"></bit-nav-item> - <bit-nav-item [text]="'importData' | i18n" route="tools/import"></bit-nav-item> - <bit-nav-item [text]="'exportVault' | i18n" route="tools/export"></bit-nav-item> + <bit-nav-item [text]="'import' | i18n" route="tools/import"></bit-nav-item> + <bit-nav-item [text]="'export' | i18n" route="tools/export"></bit-nav-item> </bit-nav-group> <bit-nav-item icon="bwi-sliders" [text]="'reports' | i18n" route="reports"></bit-nav-item> <bit-nav-group icon="bwi-cog" [text]="'settings' | i18n" route="settings"> diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index e3c9da635f9..b97cbcac72a 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -748,7 +748,7 @@ const routes: Routes = [ loadComponent: () => import("./tools/import/import-web.component").then((mod) => mod.ImportWebComponent), data: { - titleId: "importData", + titleId: "import", } satisfies RouteDataProperties, }, { @@ -758,7 +758,7 @@ const routes: Routes = [ (mod) => mod.ExportWebComponent, ), data: { - titleId: "exportVault", + titleId: "export", } satisfies RouteDataProperties, }, { diff --git a/apps/web/src/app/tools/import/import-web.component.html b/apps/web/src/app/tools/import/import-web.component.html index 2db158a14e2..d6f7e0db0cd 100644 --- a/apps/web/src/app/tools/import/import-web.component.html +++ b/apps/web/src/app/tools/import/import-web.component.html @@ -15,6 +15,6 @@ bitFormButton buttonType="primary" > - {{ "importData" | i18n }} + {{ "import" | i18n }} </button> </bit-container> diff --git a/apps/web/src/app/tools/import/org-import.component.html b/apps/web/src/app/tools/import/org-import.component.html index 25efa9ec0c7..00e4a7690a2 100644 --- a/apps/web/src/app/tools/import/org-import.component.html +++ b/apps/web/src/app/tools/import/org-import.component.html @@ -16,6 +16,6 @@ bitFormButton buttonType="primary" > - {{ "importData" | i18n }} + {{ "import" | i18n }} </button> </bit-container> diff --git a/apps/web/src/app/tools/vault-export/export-web.component.html b/apps/web/src/app/tools/vault-export/export-web.component.html index e3d0ca75d25..1ff34f4c988 100644 --- a/apps/web/src/app/tools/vault-export/export-web.component.html +++ b/apps/web/src/app/tools/vault-export/export-web.component.html @@ -15,6 +15,6 @@ bitFormButton buttonType="primary" > - {{ "confirmFormat" | i18n }} + {{ "export" | i18n }} </button> </bit-container> diff --git a/apps/web/src/app/tools/vault-export/org-vault-export.component.html b/apps/web/src/app/tools/vault-export/org-vault-export.component.html index 01975272e76..e781a839896 100644 --- a/apps/web/src/app/tools/vault-export/org-vault-export.component.html +++ b/apps/web/src/app/tools/vault-export/org-vault-export.component.html @@ -16,6 +16,6 @@ bitFormButton buttonType="primary" > - {{ "confirmFormat" | i18n }} + {{ "export" | i18n }} </button> </bit-container> diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3b0554547c5..0f8b0c1b466 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1975,12 +1975,6 @@ "exportFrom": { "message": "Export from" }, - "exportVault": { - "message": "Export vault" - }, - "exportSecrets": { - "message": "Export secrets" - }, "fileFormat": { "message": "File format" }, @@ -1993,9 +1987,6 @@ "confirmMasterPassword": { "message": "Confirm master password" }, - "confirmFormat": { - "message": "Confirm format" - }, "filePassword": { "message": "File password" }, @@ -2306,6 +2297,9 @@ "tools": { "message": "Tools" }, + "import": { + "message": "Import" + }, "importData": { "message": "Import data" }, @@ -8757,9 +8751,6 @@ "server": { "message": "Server" }, - "exportData": { - "message": "Export data" - }, "exportingOrganizationSecretDataTitle": { "message": "Exporting Organization Secret Data" }, diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html index 0ea8caef4d6..ac70e1920ee 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/layout/navigation.component.html @@ -52,12 +52,12 @@ [relativeTo]="route.parent" > <bit-nav-item - [text]="'importData' | i18n" + [text]="'import' | i18n" route="settings/import" [relativeTo]="route.parent" ></bit-nav-item> <bit-nav-item - [text]="'exportData' | i18n" + [text]="'export' | i18n" route="settings/export" [relativeTo]="route.parent" ></bit-nav-item> diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html index 9e1f2e01591..113c51327b2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html @@ -17,6 +17,6 @@ </bit-form-field> <button bitButton bitFormButton type="submit" buttonType="primary"> - {{ "exportData" | i18n }} + {{ "export" | i18n }} </button> </form> diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts index e2b66d9ffa6..5e6f81d99d6 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts @@ -124,7 +124,7 @@ export class SecretsManagerExportComponent implements OnInit, OnDestroy { const ref = openUserVerificationPrompt(this.dialogService, { data: { confirmDescription: "exportSecretsWarningDesc", - confirmButtonText: "exportSecrets", + confirmButtonText: "export", modalTitle: "confirmSecretsExport", }, }); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html index 353d8d8c8ed..3a663dbcbe9 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html @@ -36,6 +36,6 @@ <bit-hint>{{ "acceptedFormats" | i18n }} Bitwarden (json)</bit-hint> </bit-form-field> <button bitButton bitFormButton type="submit" buttonType="primary"> - {{ "importData" | i18n }} + {{ "import" | i18n }} </button> </form> diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts index ddc9964060e..31029d134fa 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts @@ -12,7 +12,7 @@ const routes: Routes = [ component: SecretsManagerImportComponent, canActivate: [organizationPermissionsGuard((org) => org.isAdmin)], data: { - titleId: "importData", + titleId: "import", }, }, { @@ -20,7 +20,7 @@ const routes: Routes = [ component: SecretsManagerExportComponent, canActivate: [organizationPermissionsGuard((org) => org.isAdmin)], data: { - titleId: "exportData", + titleId: "export", }, }, ]; diff --git a/libs/importer/src/components/dialog/file-password-prompt.component.html b/libs/importer/src/components/dialog/file-password-prompt.component.html index d663ec0f4d3..1c0bcdca31d 100644 --- a/libs/importer/src/components/dialog/file-password-prompt.component.html +++ b/libs/importer/src/components/dialog/file-password-prompt.component.html @@ -21,7 +21,7 @@ <ng-container bitDialogFooter> <button bitButton buttonType="primary" type="submit"> - <span>{{ "importData" | i18n }}</span> + <span>{{ "import" | i18n }}</span> </button> <button bitButton bitDialogClose buttonType="secondary" type="button"> <span>{{ "cancel" | i18n }}</span> diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index e81217e54c2..232fb40aeb2 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -620,7 +620,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { title: "confirmVaultExport", bodyText: confirmDescription, confirmButtonOptions: { - text: "exportVault", + text: "continue", type: "primary", }, }); From 4e913df0ffe79e97f5e025320980c1104a55da10 Mon Sep 17 00:00:00 2001 From: Alex <55413326+AlexRubik@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:07:02 -0500 Subject: [PATCH 60/60] make checkbox selection updates immutable (#17939) --- .../all-applications.component.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index 3a9159ad68c..95453ffa41a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -143,16 +143,14 @@ export class AllApplicationsComponent implements OnInit { onCheckboxChange = (applicationName: string, event: Event) => { const isChecked = (event.target as HTMLInputElement).checked; - if (isChecked) { - this.selectedUrls.update((selectedUrls) => { - selectedUrls.add(applicationName); - return selectedUrls; - }); - } else { - this.selectedUrls.update((selectedUrls) => { - selectedUrls.delete(applicationName); - return selectedUrls; - }); - } + this.selectedUrls.update((selectedUrls) => { + const nextSelected = new Set(selectedUrls); + if (isChecked) { + nextSelected.add(applicationName); + } else { + nextSelected.delete(applicationName); + } + return nextSelected; + }); }; }