diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 997812735de..96e16776545 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -197,7 +197,6 @@ "nx", "oo7", "oslog", - "parse5", "pin-project", "pkg", "postcss", @@ -215,6 +214,8 @@ "simplelog", "style-loader", "sysinfo", + "tokio", + "tokio-util", "tracing", "tracing-subscriber", "ts-node", @@ -261,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. 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/.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 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" diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index a5c204ffc99..09ea964823c 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." }, @@ -3249,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" }, @@ -5888,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" }, @@ -5937,5 +5976,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/browser/src/auth/popup/account-switching/current-account.component.html b/apps/browser/src/auth/popup/account-switching/current-account.component.html index 2e2440f6258..7ab55f36753 100644 --- a/apps/browser/src/auth/popup/account-switching/current-account.component.html +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.html @@ -2,7 +2,7 @@ - - - } - 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/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index 4abd9cd4803..26138d57954 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -22,7 +22,7 @@ export type NavButton = { templateUrl: "popup-tab-navigation.component.html", imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule], host: { - class: "tw-block tw-h-full tw-w-full tw-flex tw-flex-col", + class: "tw-block tw-size-full tw-flex tw-flex-col", }, }) export class PopupTabNavigationComponent { diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 4df9c897c22..12e1288e806 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"; @@ -189,9 +192,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", @@ -647,7 +663,7 @@ const routes: Routes = [ component: ConfirmKeyConnectorDomainComponent, data: { pageTitle: { - key: "confirmKeyConnectorDomain", + key: "verifyYourOrganization", }, showBackButton: true, pageIcon: DomainIcon, diff --git a/apps/browser/src/popup/app.component.html b/apps/browser/src/popup/app.component.html index 3d81354ca35..3a5c8021e17 100644 --- a/apps/browser/src/popup/app.component.html +++ b/apps/browser/src/popup/app.component.html @@ -13,8 +13,11 @@ } @else { -
- + +
+
+ +
+
- } 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/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/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/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; 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/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/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 3d528d49d67..5978659f21e 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" @@ -114,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" @@ -138,14 +123,14 @@ dependencies = [ [[package]] name = "ashpd" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +checksum = "da0986d5b4f0802160191ad75f8d33ada000558757db3defb70299ca95d9fcbd" dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand 0.9.1", + "rand 0.9.2", "serde", "serde_repr", "tokio", @@ -347,23 +332,8 @@ dependencies = [ "mockall", "serial_test", "tracing", - "windows 0.61.1", - "windows-core 0.61.0", -] - -[[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", + "windows", + "windows-core", ] [[package]] @@ -457,7 +427,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "windows 0.61.1", + "windows", ] [[package]] @@ -501,6 +471,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" @@ -509,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" @@ -556,9 +532,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.46" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -614,7 +590,7 @@ dependencies = [ "hex", "oo7", "pbkdf2", - "rand 0.9.1", + "rand 0.9.2", "rusqlite", "security-framework", "serde", @@ -623,7 +599,7 @@ dependencies = [ "tokio", "tracing", "verifysign", - "windows 0.61.1", + "windows", ] [[package]] @@ -709,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", ] @@ -770,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" @@ -867,7 +833,7 @@ dependencies = [ "memsec", "oo7", "pin-project", - "rand 0.9.1", + "rand 0.9.2", "scopeguard", "secmem-proc", "security-framework", @@ -877,13 +843,13 @@ dependencies = [ "sha2", "ssh-key", "sysinfo", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", "typenum", "widestring", - "windows 0.61.1", + "windows", "windows-future", "zbus", "zbus_polkit", @@ -1409,17 +1375,11 @@ 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.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "goblin" @@ -1499,14 +1459,14 @@ dependencies = [ [[package]] name = "homedir" -version = "0.3.4" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2" +checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527" dependencies = [ "cfg-if", - "nix 0.29.0", + "nix", "widestring", - "windows 0.57.0", + "windows", ] [[package]] @@ -1663,6 +1623,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" @@ -1685,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]] @@ -1841,15 +1811,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" @@ -1889,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", @@ -1923,40 +1885,26 @@ 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", ] -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nix" version = "0.30.1" @@ -1970,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" @@ -2173,15 +2127,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" @@ -2190,9 +2135,9 @@ 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", @@ -2208,7 +2153,7 @@ dependencies = [ "num", "num-bigint-dig", "pbkdf2", - "rand 0.9.1", + "rand 0.9.2", "serde", "sha2", "subtle", @@ -2548,7 +2493,7 @@ dependencies = [ name = "process_isolation" version = "0.0.0" dependencies = [ - "ctor 0.5.0", + "ctor", "desktop_core", "libc", "tracing", @@ -2591,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", @@ -2660,19 +2605,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", -] - -[[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", + "thiserror 2.0.17", ] [[package]] @@ -2748,10 +2681,10 @@ dependencies = [ ] [[package]] -name = "rustc-demangle" -version = "0.1.24" +name = "rustc-hash" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" @@ -2798,6 +2731,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" @@ -2870,8 +2809,8 @@ dependencies = [ "libc", "rustix 1.0.7", "rustix-linux-procfs", - "thiserror 2.0.12", - "windows 0.61.1", + "thiserror 2.0.17", + "windows", ] [[package]] @@ -3068,12 +3007,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]] @@ -3197,7 +3136,7 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows 0.61.1", + "windows", ] [[package]] @@ -3239,11 +3178,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]] @@ -3259,9 +3198,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", @@ -3289,11 +3228,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", @@ -3303,14 +3241,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", @@ -3319,9 +3257,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", @@ -3680,6 +3618,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" @@ -3745,6 +3694,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" @@ -3852,16 +3846,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" -dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.1" @@ -3869,7 +3853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" dependencies = [ "windows-collections", - "windows-core 0.61.0", + "windows-core", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -3881,19 +3865,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.0", -] - -[[package]] -name = "windows-core" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" -dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -3902,8 +3874,8 @@ version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" dependencies = [ - "windows-implement 0.60.0", - "windows-interface 0.59.1", + "windows-implement", + "windows-interface", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -3915,21 +3887,10 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" dependencies = [ - "windows-core 0.61.0", + "windows-core", "windows-link 0.1.3", ] -[[package]] -name = "windows-implement" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-implement" version = "0.60.0" @@ -3941,17 +3902,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-interface" version = "0.59.1" @@ -3981,7 +3931,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.0", + "windows-core", "windows-link 0.1.3", ] @@ -3996,15 +3946,6 @@ dependencies = [ "windows-strings 0.5.1", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -4262,8 +4203,8 @@ name = "windows_plugin_authenticator" version = "0.0.0" dependencies = [ "hex", - "windows 0.61.1", - "windows-core 0.61.0", + "windows", + "windows-core", ] [[package]] @@ -4434,9 +4375,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", @@ -4452,14 +4393,15 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix 0.30.1", + "nix", "ordered-stream", "serde", "serde_repr", "tokio", "tracing", "uds_windows", - "windows-sys 0.60.2", + "uuid", + "windows-sys 0.61.2", "winnow", "zbus_macros", "zbus_names", @@ -4468,9 +4410,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 f8ee329ed5e..26f791fd660 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -21,13 +21,13 @@ 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" +ashpd = "=0.12.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" @@ -37,18 +37,18 @@ ed25519 = "=2.2.3" embed_plist = "=1.2.2" futures = "=0.3.31" hex = "=0.4.3" -homedir = "=0.3.4" +homedir = "=0.3.6" 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" -oo7 = "=0.4.3" +napi = "=3.3.0" +napi-build = "=2.2.3" +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" @@ -61,9 +61,9 @@ 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" -tokio = "=1.45.0" -tokio-util = "=0.7.13" +thiserror = "=2.0.17" +tokio = "=1.48.0" +tokio-util = "=0.7.17" tracing = "=0.1.41" tracing-subscriber = { version = "=0.3.20", features = [ "fmt", @@ -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" 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/desktop_native/objc/Cargo.toml b/apps/desktop/desktop_native/objc/Cargo.toml index 5ef791fb586..dd808537c28 100644 --- a/apps/desktop/desktop_native/objc/Cargo.toml +++ b/apps/desktop/desktop_native/objc/Cargo.toml @@ -14,8 +14,8 @@ tokio = { workspace = true } tracing = { workspace = true } [target.'cfg(target_os = "macos")'.build-dependencies] -cc = "=1.2.46" -glob = "=0.3.2" +cc = "=1.2.49" +glob = "=0.3.3" [lints] workspace = true diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index 9ad1ffb3ec0..1f4a56de18a 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -19,7 +19,7 @@ "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.19.1", + "@types/node": "22.19.2", "typescript": "5.4.2" } }, @@ -117,9 +117,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", - "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "version": "22.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz", + "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "license": "MIT", "peer": true, "dependencies": { diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 21a6ba3626a..83e9f01afed 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -24,7 +24,7 @@ "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.19.1", + "@types/node": "22.19.2", "typescript": "5.4.2" }, "_moduleAliases": { 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/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/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 5e20b2fa921..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"; @@ -102,6 +103,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, @@ -166,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), @@ -499,7 +501,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/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; 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/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/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/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 { return await ipc.keyManagement.biometric.authenticateWithBiometrics(); } @@ -31,6 +37,10 @@ export class RendererBiometricsService extends DesktopBiometricsService { } async getBiometricsStatusForUser(id: UserId): Promise { + if ((await firstValueFrom(this.tokenService.hasAccessToken$(id))) === false) { + return BiometricsStatus.NotEnabledInConnectedDesktopApp; + } + return await ipc.keyManagement.biometric.getBiometricsStatusForUser(id); } 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 @@ -
-
-

{{ "removeMasterPassword" | i18n }}

-

{{ "removeMasterPasswordForOrganizationUserKeyConnector" | i18n }}

-

{{ "organizationName" | i18n }}:

-

{{ organization.name }}

-

{{ "keyConnectorDomain" | i18n }}:

-

{{ organization.keyConnectorUrl }}

-
- - -
-
-
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 4c3833e28c3..48e346d9c68 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" }, @@ -2634,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" }, @@ -4334,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" }, @@ -4383,5 +4422,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/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/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 @@
{{ "sshkeyApprovalTitle" | i18n }}
- + @if (params.isAgentForwarding) { + {{ 'agentForwardingWarningText' | i18n }} - + + } {{params.applicationName}} {{ "sshkeyApprovalMessageInfix" | i18n }} {{params.cipherName}} 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/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/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/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 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; - 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 { - 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 { + 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 { + 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>, + ): Promise { + 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, + }; + } } 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/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/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/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 @@
- {{ "deleteAccountWarning" | i18n }} + {{ "deleteAccountWarning" | i18n }}

{{ email }}

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: "$" }} - + {{ "freeForOneYear" | i18n }} @@ -52,7 +47,7 @@ {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }} {{ 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() { 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/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/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 @@ -
- - {{ "loading" | i18n }} -
- -
-

{{ "removeMasterPasswordForOrganizationUserKeyConnector" | i18n }}

-

{{ "organizationName" | i18n }}:

-

{{ organization.name }}

-

{{ "keyConnectorDomain" | i18n }}:

-

{{ organization.keyConnectorUrl }}

- - - -
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 b40b9143991..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"; @@ -78,8 +78,8 @@ 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"; import { UserLayoutComponent } from "./layouts/user-layout.component"; import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-landing/request-sm-access.component"; @@ -544,9 +544,9 @@ const routes: Routes = [ canActivate: [authGuard], data: { pageTitle: { - key: "removeMasterPassword", + key: "verifyYourOrganization", }, - titleId: "removeMasterPassword", + titleId: "verifyYourOrganization", pageIcon: LockIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, @@ -556,9 +556,9 @@ const routes: Routes = [ canActivate: [], data: { pageTitle: { - key: "confirmKeyConnectorDomain", + key: "verifyYourOrganization", }, - titleId: "confirmKeyConnectorDomain", + titleId: "verifyYourOrganization", pageIcon: DomainIcon, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, @@ -696,6 +696,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/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/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 @@
-
+
{{ "name" | i18n }} - {{ "deletionDate" | i18n }} + + {{ "deletionDate" | i18n }} + {{ "options" | i18n }} @@ -148,8 +150,14 @@
- - {{ s.deletionDate | date: "medium" }} + + + {{ s.deletionDate | date: "medium" }} +