diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 997812735de..ca57ccf4f86 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -154,7 +154,6 @@ "@types/glob", "@types/lowdb", "@types/node", - "@types/node-forge", "@types/node-ipc", "@yao-pkg/pkg", "anyhow", @@ -192,12 +191,10 @@ "napi", "napi-build", "napi-derive", - "node-forge", "node-ipc", "nx", "oo7", "oslog", - "parse5", "pin-project", "pkg", "postcss", @@ -215,6 +212,8 @@ "simplelog", "style-loader", "sysinfo", + "tokio", + "tokio-util", "tracing", "tracing-subscriber", "ts-node", @@ -261,6 +260,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. @@ -409,14 +413,16 @@ }, { matchPackageNames: [ + "@types/node-forge", "aes", "big-integer", "cbc", + "linux-keyutils", + "memsec", + "node-forge", "rsa", "russh-cryptovec", "sha2", - "memsec", - "linux-keyutils", ], description: "Key Management owned dependencies", commitMessagePrefix: "[deps] KM:", diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index c99d2183d71..efb94e44c7a 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' @@ -587,7 +583,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$env:MODE" - name: Build run: npm run build @@ -850,7 +848,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$env:MODE" - name: Build run: npm run build @@ -1206,7 +1206,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$MODE" - name: Build application (dev) run: npm run build @@ -1428,7 +1430,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$MODE" - name: Build if: steps.build-cache.outputs.cache-hit != 'true' @@ -1709,7 +1713,9 @@ jobs: - name: Build Native Module if: steps.cache.outputs.cache-hit != 'true' working-directory: apps/desktop/desktop_native - run: node build.js cross-platform + env: + MODE: ${{ github.event_name == 'workflow_call' && '--release' || '' }} + run: node build.js cross-platform "$MODE" - name: Build if: steps.build-cache.outputs.cache-hit != 'true' diff --git a/.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/release-desktop.yml b/.github/workflows/release-desktop.yml index 7f87a1e5628..2239cb1268f 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -98,6 +98,14 @@ jobs: working-directory: apps/desktop/artifacts run: mv "Bitwarden-${PKG_VERSION}-universal.pkg" "Bitwarden-${PKG_VERSION}-universal.pkg.archive" + - name: Rename .tar.gz to include version + env: + PKG_VERSION: ${{ steps.version.outputs.version }} + working-directory: apps/desktop/artifacts + run: | + mv "bitwarden_desktop_x64.tar.gz" "bitwarden_${PKG_VERSION}_x64.tar.gz" + mv "bitwarden_desktop_arm64.tar.gz" "bitwarden_${PKG_VERSION}_arm64.tar.gz" + - name: Create Release uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 if: ${{ steps.release_channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' }} 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..36d69fb09f5 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -436,8 +436,8 @@ "sync": { "message": "Sync" }, - "syncVaultNow": { - "message": "Sync vault now" + "syncNow": { + "message": "Sync now" }, "lastSync": { "message": "Last sync:" @@ -455,9 +455,6 @@ "bitWebVaultApp": { "message": "Bitwarden web app" }, - "importItems": { - "message": "Import items" - }, "select": { "message": "Select" }, @@ -1325,8 +1322,11 @@ "exportFrom": { "message": "Export from" }, - "exportVault": { - "message": "Export vault" + "export": { + "message": "Export" + }, + "import": { + "message": "Import" }, "fileFormat": { "message": "File format" @@ -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" }, @@ -4215,10 +4215,6 @@ "ignore": { "message": "Ignore" }, - "importData": { - "message": "Import data", - "description": "Used for the header of the import dialog, the import button and within the file-password-prompt" - }, "importError": { "message": "Import error" }, @@ -5888,6 +5884,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 +5972,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/platform/popup/view-cache/popup-router-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts index abb7c6405c2..6e5218c9f27 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-router-cache.service.ts @@ -120,8 +120,8 @@ export class PopupRouterCacheService { /** * Navigate back in history */ - async back() { - if (!BrowserPopupUtils.inPopup(window)) { + async back(updateCache = false) { + if (!updateCache && !BrowserPopupUtils.inPopup(window)) { this.location.back(); return; } diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 48f06147cdf..eb64c076192 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -43,7 +43,11 @@ import { TwoFactorAuthGuard, } from "@bitwarden/auth/angular"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components"; -import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; +import { + LockComponent, + ConfirmKeyConnectorDomainComponent, + RemovePasswordComponent, +} from "@bitwarden/key-management-ui"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; import { AuthExtensionRoute } from "../auth/popup/constants/auth-extension-route.constant"; @@ -59,7 +63,6 @@ import { NotificationsSettingsComponent } from "../autofill/popup/settings/notif import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component"; import { PhishingWarning } from "../dirt/phishing-detection/popup/phishing-warning.component"; import { ProtectedByComponent } from "../dirt/phishing-detection/popup/protected-by-component"; -import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import BrowserPopupUtils from "../platform/browser/browser-popup-utils"; import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service"; import { RouteCacheOptions } from "../platform/services/popup-view-cache-background.service"; @@ -188,9 +191,22 @@ const routes: Routes = [ }, { path: "remove-password", - component: RemovePasswordComponent, + component: ExtensionAnonLayoutWrapperComponent, canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, + children: [ + { + path: "", + component: RemovePasswordComponent, + data: { + pageTitle: { + key: "verifyYourOrganization", + }, + showBackButton: false, + pageIcon: LockIcon, + } satisfies ExtensionAnonLayoutWrapperData, + }, + ], }, { path: "view-cipher", @@ -646,7 +662,7 @@ const routes: Routes = [ component: ConfirmKeyConnectorDomainComponent, data: { pageTitle: { - key: "confirmKeyConnectorDomain", + key: "verifyYourOrganization", }, showBackButton: true, pageIcon: DomainIcon, diff --git a/apps/browser/src/popup/app.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/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html index dcd0496ed30..7a1815b86ed 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component.html @@ -1,31 +1,21 @@ - -
- -
- - - - - -
- +
+ + @if (showAcctSwitcher && hasLoggedInAccount) { + + } +
diff --git a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts index 7a30e15582c..8fdae06e28a 100644 --- a/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts +++ b/apps/browser/src/popup/components/extension-anon-layout-wrapper/extension-anon-layout-wrapper.stories.ts @@ -76,11 +76,14 @@ const decorators = (options: { { provide: AccountService, useValue: { + // We can't use mockAccountInfoWith() here because we can't take a dependency on @bitwarden/common/spec. + // This is because that package relies on jest dependencies that aren't available here. activeAccount$: of({ id: "test-user-id" as UserId, name: "Test User 1", email: "test@email.com", emailVerified: true, + creationDate: "2024-01-01T00:00:00.000Z", }), }, }, @@ -238,6 +241,11 @@ export const DefaultContentExample: Story = { }, ], }), + parameters: { + chromatic: { + viewports: [380, 1280], + }, + }, }; // Dynamic Content Example 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/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index 6d79f430a37..6e73d9811f2 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -16,6 +16,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; @@ -96,9 +97,10 @@ describe("SendV2Component", () => { useValue: { activeAccount$: of({ id: "123", - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }), }, }, diff --git a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html index d6bf3a3a253..5473bbe620e 100644 --- a/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html +++ b/apps/browser/src/tools/popup/settings/export/export-browser-v2.component.html @@ -1,5 +1,5 @@ - + @@ -21,7 +21,7 @@ bitFormButton buttonType="primary" > - {{ "exportVault" | i18n }} + {{ "export" | i18n }} diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts index 295496c701f..29282d293de 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/attachments-v2.component.ts @@ -51,6 +51,6 @@ export class AttachmentsV2Component { /** Navigate the user back to the edit screen after uploading an attachment */ async navigateBack() { - await this.popupRouterCacheService.back(); + await this.popupRouterCacheService.back(true); } } diff --git a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts index 459b328c44e..e9636e09873 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/attachments/open-attachments/open-attachments.component.spec.ts @@ -11,6 +11,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { CipherId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; @@ -60,9 +61,10 @@ describe("OpenAttachmentsComponent", () => { const accountService = { activeAccount$: of({ id: mockUserId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }), }; const formStatusChange$ = new BehaviorSubject<"enabled" | "disabled">("enabled"); diff --git a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html index 225640137e8..c042af8cbac 100644 --- a/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html +++ b/apps/browser/src/vault/popup/settings/vault-settings-v2.component.html @@ -15,7 +15,7 @@ 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/key-management/commands/unlock.command.spec.ts b/apps/cli/src/key-management/commands/unlock.command.spec.ts index 70e9a8fd232..50ef414ec37 100644 --- a/apps/cli/src/key-management/commands/unlock.command.spec.ts +++ b/apps/cli/src/key-management/commands/unlock.command.spec.ts @@ -15,6 +15,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { KeyService } from "@bitwarden/key-management"; @@ -48,9 +49,10 @@ describe("UnlockCommand", () => { const mockMasterPassword = "testExample"; const activeAccount: Account = { id: "user-id" as UserId, - email: "user@example.com", - emailVerified: true, - name: "User", + ...mockAccountInfoWith({ + email: "user@example.com", + name: "User", + }), }; const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; const mockSessionKey = new Uint8Array(64) as CsprngArray; diff --git a/apps/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 2499950b05b..d68588c9e20 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", @@ -615,7 +591,7 @@ dependencies = [ "hex", "oo7", "pbkdf2", - "rand 0.9.1", + "rand 0.9.2", "rusqlite", "security-framework", "serde", @@ -624,7 +600,7 @@ dependencies = [ "tokio", "tracing", "verifysign", - "windows 0.61.1", + "windows", ] [[package]] @@ -710,9 +686,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", ] @@ -771,16 +747,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" @@ -868,7 +834,7 @@ dependencies = [ "memsec", "oo7", "pin-project", - "rand 0.9.1", + "rand 0.9.2", "scopeguard", "secmem-proc", "security-framework", @@ -878,13 +844,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", @@ -1410,17 +1376,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" @@ -1500,14 +1460,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]] @@ -1664,6 +1624,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" @@ -1686,7 +1656,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]] @@ -1842,15 +1812,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" @@ -1890,32 +1851,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", @@ -1924,40 +1886,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" @@ -1971,6 +1919,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" @@ -2174,15 +2128,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" @@ -2191,9 +2136,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", @@ -2209,7 +2154,7 @@ dependencies = [ "num", "num-bigint-dig", "pbkdf2", - "rand 0.9.1", + "rand 0.9.2", "serde", "sha2", "subtle", @@ -2549,7 +2494,7 @@ dependencies = [ name = "process_isolation" version = "0.0.0" dependencies = [ - "ctor 0.5.0", + "ctor", "desktop_core", "libc", "tracing", @@ -2592,9 +2537,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", @@ -2661,19 +2606,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]] @@ -2749,10 +2682,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" @@ -2799,6 +2732,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" @@ -2871,8 +2810,8 @@ dependencies = [ "libc", "rustix 1.0.7", "rustix-linux-procfs", - "thiserror 2.0.12", - "windows 0.61.1", + "thiserror 2.0.17", + "windows", ] [[package]] @@ -3069,12 +3008,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]] @@ -3198,7 +3137,7 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows 0.61.1", + "windows", ] [[package]] @@ -3240,11 +3179,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]] @@ -3260,9 +3199,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", @@ -3290,11 +3229,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", @@ -3304,14 +3242,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", @@ -3320,9 +3258,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", @@ -3681,6 +3619,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" @@ -3746,6 +3695,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" @@ -3853,16 +3847,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" @@ -3870,7 +3854,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", @@ -3882,19 +3866,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]] @@ -3903,8 +3875,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", @@ -3916,21 +3888,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" @@ -3942,17 +3903,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" @@ -3982,7 +3932,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", ] @@ -3997,15 +3947,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" @@ -4263,8 +4204,8 @@ name = "windows_plugin_authenticator" version = "0.0.0" dependencies = [ "hex", - "windows 0.61.1", - "windows-core 0.61.0", + "windows", + "windows-core", ] [[package]] @@ -4435,9 +4376,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", @@ -4453,14 +4394,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", @@ -4469,9 +4411,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..54a6dba8326 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 @@ -113,8 +113,8 @@ if (process.platform === "linux") { platformTargets.forEach(([target, _]) => { installTarget(target); - buildNapiModule(target); - buildProxyBin(target); - buildImporterBinaries(target); + buildNapiModule(target, mode === "release"); + buildProxyBin(target, mode === "release"); + buildImporterBinaries(target, mode === "release"); buildProcessIsolation(); }); diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs index c57345c3bd1..ae810013b63 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs @@ -18,7 +18,7 @@ use crate::{ pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Chrome", - data_dir: &[".config/google-chrome"], + data_dir: &[".config/google-chrome", "snap/chromium/common/chromium"], }, BrowserConfig { name: "Chromium", diff --git a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs index 1beb3b61086..74de6198ae5 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. @@ -37,9 +37,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()) { @@ -48,7 +48,7 @@ pub fn get_supported_importers( NativeImporterMetadata { id: id.to_string(), loaders, - instructions: "chromium", + instructions: "chromium".to_string(), }, ); } @@ -80,12 +80,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() } @@ -108,7 +105,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())); } } @@ -148,7 +145,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())); } } @@ -184,7 +181,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 1e693da61d7..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,13 +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(masBuild: boolean): Record - export function getAvailableProfiles(browser: string, masBuild: boolean): Promise> - export function importLogins(browser: string, profileId: string, masBuild: boolean): Promise> - export function requestBrowserAccess(browser: string, masBuild: boolean): 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..0717bfd53ea 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": "node scripts/build.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/scripts/build.js b/apps/desktop/desktop_native/napi/scripts/build.js new file mode 100644 index 00000000000..ad24b99d2fb --- /dev/null +++ b/apps/desktop/desktop_native/napi/scripts/build.js @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { execSync } = require('child_process'); + +const args = process.argv.slice(2); + +const isRelease = args.includes('--release'); + +const argsString = args.join(' '); + +if (isRelease) { + console.log('Building release mode.'); + + execSync(`napi build --platform --no-js ${argsString}`, { stdio: 'inherit'}); + +} else { + console.log('Building debug mode.'); + + execSync(`napi build --platform --no-js ${argsString}`, { + stdio: 'inherit', + env: { ...process.env, RUST_LOG: 'debug' } + }); +} diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 34910c9c370..1d9b6b79da8 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,19 +988,20 @@ 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::{ - filter::{EnvFilter, LevelFilter}, + filter::EnvFilter, fmt::format::{DefaultVisitor, Writer}, layer::SubscriberExt, util::SubscriberInitExt, Layer, }; - struct JsLogger(OnceLock>); + struct JsLogger(OnceLock>>); static JS_LOGGER: JsLogger = JsLogger(OnceLock::new()); #[napi] @@ -1071,18 +1073,26 @@ 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); + // the log level hierarchy is determined by: + // - if RUST_LOG is detected at runtime + // - if RUST_LOG is provided at compile time + // - default to INFO let filter = EnvFilter::builder() - // set the default log level to INFO. - .with_default_directive(LevelFilter::INFO.into()) + .with_default_directive( + option_env!("RUST_LOG") + .unwrap_or("info") + .parse() + .expect("should provide valid log level at compile time."), + ) // parse directives from the RUST_LOG environment variable, // overriding the default directive for matching targets. .from_env_lossy(); @@ -1140,8 +1150,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 { @@ -1237,7 +1247,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 7af77a6c688..35f55e30a8c 100644 --- a/apps/desktop/desktop_native/objc/Cargo.toml +++ b/apps/desktop/desktop_native/objc/Cargo.toml @@ -14,8 +14,8 @@ tokio = { workspace = true, features = ["sync"] } 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/package.json b/apps/desktop/package.json index 5e85d34cebc..97ab8585a69 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.12.0", + "version": "2025.12.1", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/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..59021a556e4 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), @@ -201,8 +203,16 @@ const safeProviders: SafeProvider[] = [ // We manually override the value of SUPPORTS_SECURE_STORAGE here to avoid // the TokenService having to inject the PlatformUtilsService which introduces a // circular dependency on Desktop only. + // + // For Windows portable builds, we disable secure storage to ensure tokens are + // stored on disk (in bitwarden-appdata) rather than in Windows Credential + // Manager, making them portable across machines. This allows users to move the USB drive + // between computers while maintaining authentication. + // + // Note: Portable mode does not use secure storage for read/write/clear operations, + // preventing any collision with tokens from a regular desktop installation. provide: SUPPORTS_SECURE_STORAGE, - useValue: ELECTRON_SUPPORTS_SECURE_STORAGE, + useValue: ELECTRON_SUPPORTS_SECURE_STORAGE && !ipc.platform.isWindowsPortable, }), safeProvider({ provide: DEFAULT_VAULT_TIMEOUT, @@ -499,7 +509,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/export/export-desktop.component.html b/apps/desktop/src/app/tools/export/export-desktop.component.html index 9aa59c5a636..a969b86b950 100644 --- a/apps/desktop/src/app/tools/export/export-desktop.component.html +++ b/apps/desktop/src/app/tools/export/export-desktop.component.html @@ -1,5 +1,5 @@ - {{ "exportVault" | i18n }} + {{ "export" | i18n }} - {{ "exportVault" | i18n }} + {{ "export" | i18n }} - -
- - 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 7a6be14211f..e4b0d176141 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" }, @@ -1195,8 +1198,8 @@ "followUs": { "message": "Follow us" }, - "syncVault": { - "message": "Sync vault" + "syncNow": { + "message": "Sync now" }, "changeMasterPass": { "message": "Change master password" @@ -1772,8 +1775,11 @@ "exportFrom": { "message": "Export from" }, - "exportVault": { - "message": "Export vault" + "export": { + "message": "Export" + }, + "import": { + "message": "Import" }, "fileFormat": { "message": "File format" @@ -2634,9 +2640,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" }, @@ -3492,10 +3495,6 @@ "aliasDomain": { "message": "Alias domain" }, - "importData": { - "message": "Import data", - "description": "Used for the desktop menu item and the header of the import dialog" - }, "importError": { "message": "Import error" }, @@ -4334,6 +4333,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" }, @@ -4395,5 +4433,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/menu/menu.file.ts b/apps/desktop/src/main/menu/menu.file.ts index a8cdb347a77..eb5f5a9d747 100644 --- a/apps/desktop/src/main/menu/menu.file.ts +++ b/apps/desktop/src/main/menu/menu.file.ts @@ -146,8 +146,8 @@ export class FileMenu extends FirstMenu implements IMenubarMenu { private get syncVault(): MenuItemConstructorOptions { return { - id: "syncVault", - label: this.localize("syncVault"), + id: "syncNow", + label: this.localize("syncNow"), click: () => this.sendMessage("syncVault"), enabled: this.hasAuthenticatedAccounts, }; @@ -155,8 +155,8 @@ export class FileMenu extends FirstMenu implements IMenubarMenu { private get importVault(): MenuItemConstructorOptions { return { - id: "importVault", - label: this.localize("importData"), + id: "import", + label: this.localize("import"), click: () => this.sendMessage("importVault"), enabled: !this._isLocked, }; @@ -164,8 +164,8 @@ export class FileMenu extends FirstMenu implements IMenubarMenu { private get exportVault(): MenuItemConstructorOptions { return { - id: "exportVault", - label: this.localize("exportVault"), + id: "export", + label: this.localize("export"), click: () => this.sendMessage("exportVault"), enabled: !this._isLocked, }; diff --git a/apps/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/package-lock.json b/apps/desktop/src/package-lock.json index 44fdb5c23b0..9d8eae15791 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.12.0", + "version": "2025.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.12.0", + "version": "2025.12.1", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index 4c396304f4a..2ac5d339a95 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.12.0", + "version": "2025.12.1", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", 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..5f643242a9c 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -17,6 +17,7 @@ import { isFlatpak, isMacAppStore, isSnapStore, + isWindowsPortable, isWindowsStore, } from "../utils"; @@ -108,8 +109,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 }); }, }; @@ -128,6 +134,7 @@ export default { isDev: isDev(), isMacAppStore: isMacAppStore(), isWindowsStore: isWindowsStore(), + isWindowsPortable: isWindowsPortable(), isFlatpak: isFlatpak(), isSnapStore: isSnapStore(), isAppImage: isAppImage(), diff --git a/apps/desktop/src/platform/services/electron-log.main.service.ts b/apps/desktop/src/platform/services/electron-log.main.service.ts index 947f4449271..e148f7a45c8 100644 --- a/apps/desktop/src/platform/services/electron-log.main.service.ts +++ b/apps/desktop/src/platform/services/electron-log.main.service.ts @@ -22,7 +22,7 @@ export class ElectronLogMainService extends BaseLogService { return; } - log.transports.file.level = "info"; + log.transports.file.level = isDev() ? "debug" : "info"; if (this.logDir != null) { log.transports.file.resolvePathFn = () => path.join(this.logDir, "app.log"); } 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/desktop/src/services/biometric-message-handler.service.spec.ts b/apps/desktop/src/services/biometric-message-handler.service.spec.ts index 49d346bfa3a..3b343fcc0fb 100644 --- a/apps/desktop/src/services/biometric-message-handler.service.spec.ts +++ b/apps/desktop/src/services/biometric-message-handler.service.spec.ts @@ -2,7 +2,7 @@ import { NgZone } from "@angular/core"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, filter, firstValueFrom, of, take, timeout, timer } from "rxjs"; -import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; @@ -10,7 +10,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { FakeAccountService } from "@bitwarden/common/spec"; +import { mockAccountInfoWith, FakeAccountService } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; @@ -23,17 +23,15 @@ import { BiometricMessageHandlerService } from "./biometric-message-handler.serv const SomeUser = "SomeUser" as UserId; const AnotherUser = "SomeOtherUser" as UserId; -const accounts: Record = { - [SomeUser]: { +const accounts = { + [SomeUser]: mockAccountInfoWith({ name: "some user", email: "some.user@example.com", - emailVerified: true, - }, - [AnotherUser]: { + }), + [AnotherUser]: mockAccountInfoWith({ name: "some other user", email: "some.other.user@example.com", - emailVerified: true, - }, + }), }; describe("BiometricMessageHandlerService", () => { diff --git a/apps/web/src/app/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/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index 59bc03babd4..85bfe0bce0a 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -104,12 +104,12 @@ *ngIf="organization.use2fa && organization.isOwner" > 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/admin-console/organizations/settings/organization-settings-routing.module.ts b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts index a644086628c..61d7d0e04ac 100644 --- a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts @@ -57,7 +57,7 @@ const routes: Routes = [ ), canActivate: [organizationPermissionsGuard((org) => org.canAccessImport)], data: { - titleId: "importData", + titleId: "import", }, }, { @@ -68,7 +68,7 @@ const routes: Routes = [ ), canActivate: [organizationPermissionsGuard((org) => org.canAccessExport)], data: { - titleId: "exportVault", + titleId: "export", }, }, ], diff --git a/apps/web/src/app/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/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts index b28a7b8c4a2..6bc0efb9e96 100644 --- a/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/unified-upgrade-dialog/unified-upgrade-dialog.component.spec.ts @@ -10,6 +10,7 @@ import { PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierIds, } from "@bitwarden/common/billing/types/subscription-pricing-tier"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { DIALOG_DATA, DialogRef } from "@bitwarden/components"; @@ -63,9 +64,10 @@ describe("UnifiedUpgradeDialogComponent", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const defaultDialogData: UnifiedUpgradeDialogParams = { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts index 787936c102e..f5df248cbbf 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.spec.ts @@ -7,6 +7,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogRef, DialogService } from "@bitwarden/components"; @@ -32,9 +33,10 @@ describe("UpgradeNavButtonComponent", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; beforeEach(async () => { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 9d17d62e4dc..81169d719b6 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -13,6 +13,7 @@ import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SyncService } from "@bitwarden/common/platform/sync"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { LogService } from "@bitwarden/logging"; @@ -46,11 +47,12 @@ describe("UpgradePaymentService", () => { let sut: UpgradePaymentService; - const mockAccount = { + const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const mockTokenizedPaymentMethod: TokenizedPaymentMethod = { @@ -151,9 +153,10 @@ describe("UpgradePaymentService", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - name: "Test User", - emailVerified: true, + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const paidOrgData = { @@ -203,9 +206,10 @@ describe("UpgradePaymentService", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - name: "Test User", - emailVerified: true, + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const paidOrgData = { @@ -255,9 +259,10 @@ describe("UpgradePaymentService", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - name: "Test User", - emailVerified: true, + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; mockAccountService.activeAccount$ = of(mockAccount); @@ -289,9 +294,10 @@ describe("UpgradePaymentService", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - name: "Test User", - emailVerified: true, + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const expectedCredit = 25.5; @@ -353,9 +359,10 @@ describe("UpgradePaymentService", () => { const mockAccount: Account = { id: "user-id" as UserId, - email: "test@example.com", - name: "Test User", - emailVerified: true, + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }; const paidOrgData = { diff --git a/apps/web/src/app/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/dirt/reports/pages/breach-report.component.spec.ts b/apps/web/src/app/dirt/reports/pages/breach-report.component.spec.ts index 143dce3915e..886267e3189 100644 --- a/apps/web/src/app/dirt/reports/pages/breach-report.component.spec.ts +++ b/apps/web/src/app/dirt/reports/pages/breach-report.component.spec.ts @@ -10,6 +10,7 @@ import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BreachAccountResponse } from "@bitwarden/common/dirt/models/response/breach-account.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { BreachReportComponent } from "./breach-report.component"; @@ -38,9 +39,10 @@ describe("BreachReportComponent", () => { let accountService: MockProxy; const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({ id: "testId" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); beforeEach(async () => { diff --git a/apps/web/src/app/key-management/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/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts index f4b50b4a772..c0b734f17cc 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts @@ -30,6 +30,7 @@ import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk- import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; @@ -286,9 +287,10 @@ describe("KeyRotationService", () => { const mockUser = { id: "mockUserId" as UserId, - email: "mockEmail", - emailVerified: true, - name: "mockName", + ...mockAccountInfoWith({ + email: "mockEmail", + name: "mockName", + }), }; const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")]; diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index 88132e56384..ea6e972e431 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -75,11 +75,14 @@ class MockSyncService implements Partial { } class MockAccountService implements Partial { + // We can't use mockAccountInfoWith() here because we can't take a dependency on @bitwarden/common/spec. + // This is because that package relies on jest dependencies that aren't available here. activeAccount$?: Observable = of({ id: "test-user-id" as UserId, name: "Test User 1", email: "test@email.com", emailVerified: true, + creationDate: "2024-01-01T00:00:00.000Z", }); } diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index 4581f5981e6..d412530a635 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -75,11 +75,14 @@ class MockSyncService implements Partial { } class MockAccountService implements Partial { + // We can't use mockAccountInfoWith() here because we can't take a dependency on @bitwarden/common/spec. + // This is because that package relies on jest dependencies that aren't available here. activeAccount$?: Observable = of({ id: "test-user-id" as UserId, name: "Test User 1", email: "test@email.com", emailVerified: true, + creationDate: "2024-01-01T00:00:00.000Z", }); } diff --git a/apps/web/src/app/layouts/user-layout.component.html b/apps/web/src/app/layouts/user-layout.component.html index 9f474062120..27955a2b0ca 100644 --- a/apps/web/src/app/layouts/user-layout.component.html +++ b/apps/web/src/app/layouts/user-layout.component.html @@ -6,8 +6,8 @@ - - + + diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index b40b9143991..b97cbcac72a 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, @@ -742,7 +748,7 @@ const routes: Routes = [ loadComponent: () => import("./tools/import/import-web.component").then((mod) => mod.ImportWebComponent), data: { - titleId: "importData", + titleId: "import", } satisfies RouteDataProperties, }, { @@ -752,7 +758,7 @@ const routes: Routes = [ (mod) => mod.ExportWebComponent, ), data: { - titleId: "exportVault", + titleId: "export", } satisfies RouteDataProperties, }, { 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/import/import-web.component.html b/apps/web/src/app/tools/import/import-web.component.html index 2db158a14e2..d6f7e0db0cd 100644 --- a/apps/web/src/app/tools/import/import-web.component.html +++ b/apps/web/src/app/tools/import/import-web.component.html @@ -15,6 +15,6 @@ bitFormButton buttonType="primary" > - {{ "importData" | i18n }} + {{ "import" | i18n }} diff --git a/apps/web/src/app/tools/import/org-import.component.html b/apps/web/src/app/tools/import/org-import.component.html index 25efa9ec0c7..00e4a7690a2 100644 --- a/apps/web/src/app/tools/import/org-import.component.html +++ b/apps/web/src/app/tools/import/org-import.component.html @@ -16,6 +16,6 @@ bitFormButton buttonType="primary" > - {{ "importData" | i18n }} + {{ "import" | i18n }} 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" }} + diff --git a/apps/web/src/app/tools/vault-export/org-vault-export.component.html b/apps/web/src/app/tools/vault-export/org-vault-export.component.html index 01975272e76..e781a839896 100644 --- a/apps/web/src/app/tools/vault-export/org-vault-export.component.html +++ b/apps/web/src/app/tools/vault-export/org-vault-export.component.html @@ -16,6 +16,6 @@ bitFormButton buttonType="primary" > - {{ "confirmFormat" | i18n }} + {{ "export" | i18n }} diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts index 6b46cd89956..2ba9dd6fad4 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts @@ -2,14 +2,18 @@ import { TestBed } from "@angular/core/testing"; import { BehaviorSubject, firstValueFrom, take, timeout } from "rxjs"; import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; -import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { DeviceType } from "@bitwarden/common/enums"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { StateProvider } from "@bitwarden/common/platform/state"; -import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { + FakeStateProvider, + mockAccountServiceWith, + mockAccountInfoWith, +} from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; @@ -27,8 +31,11 @@ describe("VaultBannersService", () => { const fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); const getEmailVerified = jest.fn().mockResolvedValue(true); const lastSync$ = new BehaviorSubject(null); - const accounts$ = new BehaviorSubject>({ - [userId]: { email: "test@bitwarden.com", emailVerified: true, name: "name" } as AccountInfo, + const accounts$ = new BehaviorSubject({ + [userId]: mockAccountInfoWith({ + email: "test@bitwarden.com", + name: "name", + }), }); const pendingAuthRequests$ = new BehaviorSubject>([]); diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index b0685a028df..78a8889bc8f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -84,7 +84,7 @@ import { CipherViewLikeUtils, } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; -import { DialogRef, DialogService, ToastService, BannerComponent } from "@bitwarden/components"; +import { DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { CipherListView } from "@bitwarden/sdk-internal"; import { AddEditFolderDialogComponent, @@ -97,6 +97,8 @@ import { DecryptionFailureDialogComponent, DefaultCipherFormConfigService, PasswordRepromptService, + VaultItemsTransferService, + DefaultVaultItemsTransferService, } from "@bitwarden/vault"; import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; @@ -177,12 +179,12 @@ type EmptyStateMap = Record; VaultItemsModule, SharedModule, OrganizationWarningsModule, - BannerComponent, ], providers: [ RoutedVaultFilterService, RoutedVaultFilterBridgeService, DefaultCipherFormConfigService, + { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }, ], }) export class VaultComponent implements OnInit, OnDestroy { @@ -349,6 +351,7 @@ export class VaultComponent implements OnInit, OnDestr private premiumUpgradePromptService: PremiumUpgradePromptService, private autoConfirmService: AutomaticUserConfirmationService, private configService: ConfigService, + private vaultItemTransferService: VaultItemsTransferService, ) {} async ngOnInit() { @@ -644,6 +647,8 @@ export class VaultComponent implements OnInit, OnDestr void this.unifiedUpgradePromptService.displayUpgradePromptConditionally(); this.setupAutoConfirm(); + + void this.vaultItemTransferService.enforceOrganizationDataOwnership(activeUserId); } ngOnDestroy() { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4be70b102d1..0f8b0c1b466 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1975,12 +1975,6 @@ "exportFrom": { "message": "Export from" }, - "exportVault": { - "message": "Export vault" - }, - "exportSecrets": { - "message": "Export secrets" - }, "fileFormat": { "message": "File format" }, @@ -1993,9 +1987,6 @@ "confirmMasterPassword": { "message": "Confirm master password" }, - "confirmFormat": { - "message": "Confirm format" - }, "filePassword": { "message": "File password" }, @@ -2306,6 +2297,9 @@ "tools": { "message": "Tools" }, + "import": { + "message": "Import" + }, "importData": { "message": "Import data" }, @@ -5185,6 +5179,9 @@ "oldAttachmentsNeedFixDesc": { "message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key." }, + "itemsTransferred": { + "message": "Items transferred" + }, "yourAccountsFingerprint": { "message": "Your account's fingerprint phrase", "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." @@ -6812,8 +6809,8 @@ "vaultTimeoutRangeError": { "message": "Vault timeout is not within allowed range." }, - "disablePersonalVaultExport": { - "message": "Remove individual vault export" + "disableExport": { + "message": "Remove export" }, "disablePersonalVaultExportDescription": { "message": "Do not allow members to export data from their individual vault." @@ -7133,9 +7130,6 @@ "invalidVerificationCode": { "message": "Invalid verification code" }, - "removeMasterPasswordForOrganizationUserKeyConnector": { - "message": "A master password is no longer required for members of the following organization. Please confirm the domain below with your organization administrator." - }, "keyConnectorDomain": { "message": "Key Connector domain" }, @@ -8757,9 +8751,6 @@ "server": { "message": "Server" }, - "exportData": { - "message": "Export data" - }, "exportingOrganizationSecretDataTitle": { "message": "Exporting Organization Secret Data" }, @@ -9487,6 +9478,9 @@ "ssoLoginIsRequired": { "message": "SSO login is required" }, + "emailRequiredForSsoLogin": { + "message": "Email is required for SSO" + }, "selectedRegionFlag": { "message": "Selected region flag" }, @@ -12241,6 +12235,45 @@ } } }, + "removeMasterPasswordForOrgUserKeyConnector":{ + "message": "Your organization is no longer using master passwords to log into Bitwarden. To continue, verify the organization and domain." + }, + "continueWithLogIn": { + "message": "Continue with log in" + }, + "doNotContinue": { + "message": "Do not continue" + }, + "domain": { + "message": "Domain" + }, + "keyConnectorDomainTooltip": { + "message": "This domain will store your account encryption keys, so make sure you trust it. If you're not sure, check with your admin." + }, + "verifyYourOrganization": { + "message": "Verify your organization to log in" + }, + "organizationVerified":{ + "message": "Organization verified" + }, + "domainVerified":{ + "message": "Domain verified" + }, + "leaveOrganizationContent": { + "message": "If you don't verify your organization, your access to the organization will be revoked." + }, + "leaveNow": { + "message": "Leave now" + }, + "verifyYourDomainToLogin": { + "message": "Verify your domain to log in" + }, + "verifyYourDomainDescription": { + "message": "To continue with log in, verify this domain." + }, + "confirmKeyConnectorOrganizationUserDescription": { + "message": "To continue with log in, verify the organization and domain." + }, "confirmNoSelectedCriticalApplicationsTitle": { "message": "No critical applications are selected" }, @@ -12250,6 +12283,54 @@ "userVerificationFailed": { "message": "User verification failed." }, + "recoveryDeleteCiphersTitle": { + "message": "Delete unrecoverable vault items" + }, + "recoveryDeleteCiphersDesc": { + "message": "Some of your vault items could not be recovered. Do you want to delete these unrecoverable items from your vault?" + }, + "recoveryDeleteFoldersTitle": { + "message": "Delete unrecoverable folders" + }, + "recoveryDeleteFoldersDesc": { + "message": "Some of your folders could not be recovered. Do you want to delete these unrecoverable folders from your vault?" + }, + "recoveryReplacePrivateKeyTitle": { + "message": "Replace encryption key" + }, + "recoveryReplacePrivateKeyDesc": { + "message": "Your public-key encryption key pair could not be recovered. Do you want to replace your encryption key with a new key pair? This will require you to set up existing emergency-access and organization memberships again." + }, + "recoveryStepSyncTitle": { + "message": "Synchronizing data" + }, + "recoveryStepPrivateKeyTitle": { + "message": "Verifying encryption key integrity" + }, + "recoveryStepUserInfoTitle": { + "message": "Verifying user information" + }, + "recoveryStepCipherTitle": { + "message": "Verifying vault item integrity" + }, + "recoveryStepFoldersTitle": { + "message": "Verifying folder integrity" + }, + "dataRecoveryTitle": { + "message": "Data Recovery and Diagnostics" + }, + "dataRecoveryDescription": { + "message": "Use the data recovery tool to diagnose and repair issues with your account. After running diagnostics you have the option to save diagnostic logs for support and the option to repair any detected issues." + }, + "runDiagnostics": { + "message": "Run Diagnostics" + }, + "repairIssues": { + "message": "Repair Issues" + }, + "saveDiagnosticLogs": { + "message": "Save Diagnostic Logs" + }, "sessionTimeoutSettingsManagedByOrganization": { "message": "This setting is managed by your organization." }, @@ -12287,5 +12368,53 @@ }, "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { "message": "Set an unlock method to change your timeout action" + }, + "leaveConfirmationDialogTitle": { + "message": "Are you sure you want to leave?" + }, + "leaveConfirmationDialogContentOne": { + "message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features." + }, + "leaveConfirmationDialogContentTwo": { + "message": "Contact your admin to regain access." + }, + "leaveConfirmationDialogConfirmButton": { + "message": "Leave $ORGANIZATION$", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "howToManageMyVault": { + "message": "How do I manage my vault?" + }, + "transferItemsToOrganizationTitle": { + "message": "Transfer items to $ORGANIZATION$", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "transferItemsToOrganizationContent": { + "message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "acceptTransfer": { + "message": "Accept transfer" + }, + "declineAndLeave": { + "message": "Decline and leave" + }, + "whyAmISeeingThis": { + "message": "Why am I seeing this?" } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts index 0f0fc5f358d..5a9b36912c9 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/policies/policy-edit-definitions/disable-personal-vault-export.component.ts @@ -8,7 +8,7 @@ import { import { SharedModule } from "@bitwarden/web-vault/app/shared"; export class DisablePersonalVaultExportPolicy extends BasePolicyEditDefinition { - name = "disablePersonalVaultExport"; + name = "disableExport"; description = "disablePersonalVaultExportDescription"; type = PolicyType.DisablePersonalVaultExport; component = DisablePersonalVaultExportPolicyComponent; diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts index a0a881dbad7..99d54eedc29 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/guards/provider-permissions.guard.spec.ts @@ -10,6 +10,7 @@ import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { ToastService } from "@bitwarden/components"; import { newGuid } from "@bitwarden/guid"; @@ -41,9 +42,10 @@ describe("Provider Permissions Guard", () => { accountService.activeAccount$ = of({ id: mockUserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); route = mock({ diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.html index e1f99122b22..6965dbe8198 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/verify-recover-delete-provider.component.html @@ -1,5 +1,5 @@

{{ "deleteProvider" | i18n }}

-{{ "deleteProviderWarning" | i18n }} +{{ "deleteProviderWarning" | i18n }}

{{ name }}

diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts index 3a9159ad68c..95453ffa41a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/all-applications/all-applications.component.ts @@ -143,16 +143,14 @@ export class AllApplicationsComponent implements OnInit { onCheckboxChange = (applicationName: string, event: Event) => { const isChecked = (event.target as HTMLInputElement).checked; - if (isChecked) { - this.selectedUrls.update((selectedUrls) => { - selectedUrls.add(applicationName); - return selectedUrls; - }); - } else { - this.selectedUrls.update((selectedUrls) => { - selectedUrls.delete(applicationName); - return selectedUrls; - }); - } + this.selectedUrls.update((selectedUrls) => { + const nextSelected = new Set(selectedUrls); + if (isChecked) { + nextSelected.add(applicationName); + } else { + nextSelected.delete(applicationName); + } + return nextSelected; + }); }; } diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html index 76a03e0c525..0494f77bd46 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/app-table-row-scrollable.component.html @@ -45,7 +45,11 @@ tabindex="0" [attr.aria-label]="'viewItem' | i18n" > - + diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts index 056f7cfe255..606cb835ff1 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/secrets/secret.service.spec.ts @@ -6,6 +6,7 @@ import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; @@ -37,9 +38,11 @@ describe("SecretService", () => { let accountService: MockProxy = mock(); const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({ id: "testId" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + emailVerified: true, + }), }); beforeEach(() => { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html index 9e1f2e01591..113c51327b2 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.html @@ -17,6 +17,6 @@ diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts index e2b66d9ffa6..5e6f81d99d6 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-export.component.ts @@ -124,7 +124,7 @@ export class SecretsManagerExportComponent implements OnInit, OnDestroy { const ref = openUserVerificationPrompt(this.dialogService, { data: { confirmDescription: "exportSecretsWarningDesc", - confirmButtonText: "exportSecrets", + confirmButtonText: "export", modalTitle: "confirmSecretsExport", }, }); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html index 353d8d8c8ed..3a663dbcbe9 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/porting/sm-import.component.html @@ -36,6 +36,6 @@ {{ "acceptedFormats" | i18n }} Bitwarden (json) diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts index a4f77e6de0b..aa722e31681 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/services/sm-porting-api.service.spec.ts @@ -7,6 +7,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { OrgKey } from "@bitwarden/common/types/key"; @@ -38,9 +39,11 @@ describe("SecretsManagerPortingApiService", () => { let accountService: MockProxy; const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({ id: "testId" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + emailVerified: true, + }), }); beforeEach(() => { diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts index ddc9964060e..31029d134fa 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/settings/settings-routing.module.ts @@ -12,7 +12,7 @@ const routes: Routes = [ component: SecretsManagerImportComponent, canActivate: [organizationPermissionsGuard((org) => org.isAdmin)], data: { - titleId: "importData", + titleId: "import", }, }, { @@ -20,7 +20,7 @@ const routes: Routes = [ component: SecretsManagerExportComponent, canActivate: [organizationPermissionsGuard((org) => org.isAdmin)], data: { - titleId: "exportData", + titleId: "export", }, }, ]; diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts index 37a0dc06837..903bfd35122 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/shared/access-policies/access-policy.service.spec.ts @@ -31,7 +31,7 @@ import { PeopleAccessPoliciesRequest } from "./models/requests/people-access-pol import { ProjectServiceAccountsAccessPoliciesRequest } from "./models/requests/project-service-accounts-access-policies.request"; import { ServiceAccountGrantedPoliciesRequest } from "./models/requests/service-account-granted-policies.request"; -import { trackEmissions } from "@bitwarden/common/../spec"; +import { trackEmissions, mockAccountInfoWith } from "@bitwarden/common/../spec"; const SomeCsprngArray = new Uint8Array(64) as CsprngArray; const SomeOrganization = "some organization" as OrganizationId; @@ -52,9 +52,10 @@ describe("AccessPolicyService", () => { let accountService: MockProxy; const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({ id: "testId" as UserId, - email: "test@example.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@example.com", + name: "Test User", + }), }); beforeEach(() => { diff --git a/libs/angular/src/auth/device-management/default-device-management-component.service.ts b/libs/angular/src/auth/device-management/default-device-management-component.service.ts index 5089ba259a5..169ee6ce7b6 100644 --- a/libs/angular/src/auth/device-management/default-device-management-component.service.ts +++ b/libs/angular/src/auth/device-management/default-device-management-component.service.ts @@ -3,9 +3,7 @@ import { DeviceManagementComponentServiceAbstraction } from "./device-management /** * Default implementation of the device management component service */ -export class DefaultDeviceManagementComponentService - implements DeviceManagementComponentServiceAbstraction -{ +export class DefaultDeviceManagementComponentService implements DeviceManagementComponentServiceAbstraction { /** * Show header information in web client */ diff --git a/libs/angular/src/auth/guards/auth.guard.spec.ts b/libs/angular/src/auth/guards/auth.guard.spec.ts index fccfcd58874..335e31ec4d8 100644 --- a/libs/angular/src/auth/guards/auth.guard.spec.ts +++ b/libs/angular/src/auth/guards/auth.guard.spec.ts @@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec"; -import { - Account, - AccountInfo, - AccountService, -} from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -18,6 +14,7 @@ import { KeyConnectorService } from "@bitwarden/common/key-management/key-connec import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { authGuard } from "./auth.guard"; @@ -38,16 +35,13 @@ describe("AuthGuard", () => { const accountService: MockProxy = mock(); const activeAccountSubject = new BehaviorSubject(null); accountService.activeAccount$ = activeAccountSubject; - activeAccountSubject.next( - Object.assign( - { - name: "Test User 1", - email: "test@email.com", - emailVerified: true, - } as AccountInfo, - { id: "test-id" as UserId }, - ), - ); + activeAccountSubject.next({ + id: "test-id" as UserId, + ...mockAccountInfoWith({ + name: "Test User 1", + email: "test@email.com", + }), + }); if (featureFlag) { configService.getFeatureFlag.mockResolvedValue(true); diff --git a/libs/angular/src/auth/guards/lock.guard.spec.ts b/libs/angular/src/auth/guards/lock.guard.spec.ts index da89ee786b7..af36df06097 100644 --- a/libs/angular/src/auth/guards/lock.guard.spec.ts +++ b/libs/angular/src/auth/guards/lock.guard.spec.ts @@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec"; -import { - Account, - AccountInfo, - AccountService, -} from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -20,6 +16,7 @@ import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; @@ -68,16 +65,13 @@ describe("lockGuard", () => { const accountService: MockProxy = mock(); const activeAccountSubject = new BehaviorSubject(null); accountService.activeAccount$ = activeAccountSubject; - activeAccountSubject.next( - Object.assign( - { - name: "Test User 1", - email: "test@email.com", - emailVerified: true, - } as AccountInfo, - { id: "test-id" as UserId }, - ), - ); + activeAccountSubject.next({ + id: "test-id" as UserId, + ...mockAccountInfoWith({ + name: "Test User 1", + email: "test@email.com", + }), + }); const testBed = TestBed.configureTestingModule({ imports: [ diff --git a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts index 004499beede..6dc91fbb925 100644 --- a/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts +++ b/libs/angular/src/auth/guards/redirect-to-vault-if-unlocked/redirect-to-vault-if-unlocked.guard.spec.ts @@ -7,6 +7,7 @@ import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.g import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.guard"; @@ -14,9 +15,10 @@ import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked. describe("redirectToVaultIfUnlockedGuard", () => { const activeUser: Account = { id: "userId" as UserId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }; const setup = (activeUser: Account | null, authStatus: AuthenticationStatus | null) => { diff --git a/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts b/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts index 4408452a2a2..17df6d1d76b 100644 --- a/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts +++ b/libs/angular/src/auth/guards/tde-decryption-required.guard.spec.ts @@ -9,6 +9,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; @@ -17,9 +18,10 @@ import { tdeDecryptionRequiredGuard } from "./tde-decryption-required.guard"; describe("tdeDecryptionRequiredGuard", () => { const activeUser: Account = { id: "fake_user_id" as UserId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }; const setup = ( diff --git a/libs/angular/src/auth/guards/unauth.guard.spec.ts b/libs/angular/src/auth/guards/unauth.guard.spec.ts index c696b849558..284f595f81a 100644 --- a/libs/angular/src/auth/guards/unauth.guard.spec.ts +++ b/libs/angular/src/auth/guards/unauth.guard.spec.ts @@ -10,6 +10,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { KeyService } from "@bitwarden/key-management"; @@ -18,9 +19,10 @@ import { unauthGuardFn } from "./unauth.guard"; describe("UnauthGuard", () => { const activeUser: Account = { id: "fake_user_id" as UserId, - email: "test@email.com", - emailVerified: true, - name: "Test User", + ...mockAccountInfoWith({ + email: "test@email.com", + name: "Test User", + }), }; const setup = ( diff --git a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts index 5fefd3c3abb..4a9a37fd0de 100644 --- a/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts +++ b/libs/angular/src/auth/login-approval/default-login-approval-dialog-component.service.ts @@ -3,9 +3,7 @@ import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval /** * Default implementation of the LoginApprovalDialogComponentServiceAbstraction. */ -export class DefaultLoginApprovalDialogComponentService - implements LoginApprovalDialogComponentServiceAbstraction -{ +export class DefaultLoginApprovalDialogComponentService implements LoginApprovalDialogComponentServiceAbstraction { /** * No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method. * @returns diff --git a/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts b/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts index b21264eb7c8..4dc7522c1b8 100644 --- a/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts +++ b/libs/angular/src/auth/login-approval/login-approval-dialog.component.spec.ts @@ -11,6 +11,7 @@ import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/d import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -48,10 +49,11 @@ describe("LoginApprovalDialogComponent", () => { validationService = mock(); accountService.activeAccount$ = of({ - email: testEmail, id: "test-user-id" as UserId, - emailVerified: true, - name: null, + ...mockAccountInfoWith({ + email: testEmail, + name: null, + }), }); await TestBed.configureTestingModule({ diff --git a/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts b/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts index d14e33c1fdc..5dfc5ffa245 100644 --- a/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts +++ b/libs/angular/src/auth/password-management/change-password/default-change-password.service.spec.ts @@ -8,6 +8,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -26,9 +27,11 @@ describe("DefaultChangePasswordService", () => { const user: Account = { id: userId, - email: "email", - emailVerified: false, - name: "name", + ...mockAccountInfoWith({ + email: "email", + name: "name", + emailVerified: false, + }), }; const passwordInputResult: PasswordInputResult = { diff --git a/libs/angular/src/components/callout.component.html b/libs/angular/src/components/callout.component.html deleted file mode 100644 index 7e352fa0ced..00000000000 --- a/libs/angular/src/components/callout.component.html +++ /dev/null @@ -1,26 +0,0 @@ - -
- {{ enforcedPolicyMessage }} -
    -
  • - {{ "policyInEffectMinComplexity" | i18n: getPasswordScoreAlertDisplay() }} -
  • -
  • - {{ "policyInEffectMinLength" | i18n: enforcedPolicyOptions?.minLength.toString() }} -
  • -
  • - {{ "policyInEffectUppercase" | i18n }} -
  • -
  • - {{ "policyInEffectLowercase" | i18n }} -
  • -
  • - {{ "policyInEffectNumbers" | i18n }} -
  • -
  • - {{ "policyInEffectSpecial" | i18n: "!@#$%^&*" }} -
  • -
-
- -
diff --git a/libs/angular/src/components/callout.component.ts b/libs/angular/src/components/callout.component.ts deleted file mode 100644 index 9630b761076..00000000000 --- a/libs/angular/src/components/callout.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input, OnInit } from "@angular/core"; - -import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CalloutTypes } from "@bitwarden/components"; - -/** - * @deprecated use the CL's `CalloutComponent` instead - */ -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@Component({ - selector: "app-callout", - templateUrl: "callout.component.html", - standalone: false, -}) -export class DeprecatedCalloutComponent implements OnInit { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() type: CalloutTypes = "info"; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() icon: string; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() title: string; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() enforcedPolicyOptions: MasterPasswordPolicyOptions; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() enforcedPolicyMessage: string; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() useAlertRole = false; - - calloutStyle: string; - - constructor(private i18nService: I18nService) {} - - ngOnInit() { - this.calloutStyle = this.type; - - if (this.enforcedPolicyMessage === undefined) { - this.enforcedPolicyMessage = this.i18nService.t("masterPasswordPolicyInEffect"); - } - } - - getPasswordScoreAlertDisplay() { - if (this.enforcedPolicyOptions == null) { - return ""; - } - - let str: string; - switch (this.enforcedPolicyOptions.minComplexity) { - case 4: - str = this.i18nService.t("strong"); - break; - case 3: - str = this.i18nService.t("good"); - break; - default: - str = this.i18nService.t("weak"); - break; - } - return str + " (" + this.enforcedPolicyOptions.minComplexity + ")"; - } -} diff --git a/libs/angular/src/jslib.module.ts b/libs/angular/src/jslib.module.ts index 446530a1111..8d222a4aaf9 100644 --- a/libs/angular/src/jslib.module.ts +++ b/libs/angular/src/jslib.module.ts @@ -26,7 +26,6 @@ import { import { TwoFactorIconComponent } from "./auth/components/two-factor-icon.component"; import { NotPremiumDirective } from "./billing/directives/not-premium.directive"; -import { DeprecatedCalloutComponent } from "./components/callout.component"; import { A11yInvalidDirective } from "./directives/a11y-invalid.directive"; import { ApiActionDirective } from "./directives/api-action.directive"; import { BoxRowDirective } from "./directives/box-row.directive"; @@ -86,7 +85,6 @@ import { IconComponent } from "./vault/components/icon.component"; A11yInvalidDirective, ApiActionDirective, BoxRowDirective, - DeprecatedCalloutComponent, CopyTextDirective, CreditCardNumberPipe, EllipsisPipe, @@ -115,7 +113,6 @@ import { IconComponent } from "./vault/components/icon.component"; AutofocusDirective, ToastModule, BoxRowDirective, - DeprecatedCalloutComponent, CopyTextDirective, CreditCardNumberPipe, EllipsisPipe, diff --git a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts index 76cfbc0bfdd..610ec5923eb 100644 --- a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts +++ b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.spec.ts @@ -2,14 +2,13 @@ import { Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { of } from "rxjs"; -import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { FakeAccountService } from "@bitwarden/common/spec"; +import { mockAccountInfoWith, FakeAccountService } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogService, ToastService } from "@bitwarden/components"; import { LogService } from "@bitwarden/logging"; @@ -22,17 +21,15 @@ import { PromptMigrationPasswordComponent } from "./prompt-migration-password.co const SomeUser = "SomeUser" as UserId; const AnotherUser = "SomeOtherUser" as UserId; -const accounts: Record = { - [SomeUser]: { +const accounts = { + [SomeUser]: mockAccountInfoWith({ name: "some user", email: "some.user@example.com", - emailVerified: true, - }, - [AnotherUser]: { + }), + [AnotherUser]: mockAccountInfoWith({ name: "some other user", email: "some.other.user@example.com", - emailVerified: true, - }, + }), }; describe("DefaultEncryptedMigrationsSchedulerService", () => { diff --git a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts index 1c50919d1cb..dd248a582d3 100644 --- a/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts +++ b/libs/angular/src/key-management/encrypted-migration/encrypted-migrations-scheduler.service.ts @@ -38,16 +38,14 @@ export const ENCRYPTED_MIGRATION_DISMISSED = new UserKeyDefinition( }, ); const DISMISS_TIME_HOURS = 24; -const VAULT_ROUTE = "/vault"; +const VAULT_ROUTES = ["/vault", "/tabs/vault", "/tabs/current"]; /** * This services schedules encrypted migrations for users on clients that are interactive (non-cli), and handles manual interaction, * if it is required by showing a UI prompt. It is only one means of triggering migrations, in case the user stays unlocked for a while, * or regularly logs in without a master-password, when the migrations do require a master-password to run. */ -export class DefaultEncryptedMigrationsSchedulerService - implements EncryptedMigrationsSchedulerService -{ +export class DefaultEncryptedMigrationsSchedulerService implements EncryptedMigrationsSchedulerService { isMigrating = false; url$: Observable; @@ -87,7 +85,7 @@ export class DefaultEncryptedMigrationsSchedulerService ]).pipe( filter( ([authStatus, _date, url]) => - authStatus === AuthenticationStatus.Unlocked && url === VAULT_ROUTE, + authStatus === AuthenticationStatus.Unlocked && VAULT_ROUTES.includes(url), ), concatMap(() => this.runMigrationsIfNeeded(userId)), ), diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6164c4e05d3..816e09fd45d 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -184,7 +184,9 @@ import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction"; import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service"; import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction"; +import { KeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector-api.service"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { DefaultKeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/services/default-key-connector-api.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction"; import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service"; @@ -950,7 +952,7 @@ const safeProviders: SafeProvider[] = [ deps: [ FolderServiceAbstraction, CipherServiceAbstraction, - PinServiceAbstraction, + KeyGenerationService, KeyService, EncryptService, CryptoFunctionServiceAbstraction, @@ -970,7 +972,7 @@ const safeProviders: SafeProvider[] = [ deps: [ CipherServiceAbstraction, VaultExportApiService, - PinServiceAbstraction, + KeyGenerationService, KeyService, EncryptService, CryptoFunctionServiceAbstraction, @@ -1355,16 +1357,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: PinServiceAbstraction, useClass: PinService, - deps: [ - AccountServiceAbstraction, - EncryptService, - KdfConfigService, - KeyGenerationService, - LogService, - KeyService, - SdkService, - PinStateServiceAbstraction, - ], + deps: [EncryptService, LogService, KeyService, SdkService, PinStateServiceAbstraction], }), safeProvider({ provide: WebAuthnLoginPrfKeyServiceAbstraction, @@ -1835,6 +1828,11 @@ const safeProviders: SafeProvider[] = [ useClass: IpcSessionRepository, deps: [StateProvider], }), + safeProvider({ + provide: KeyConnectorApiService, + useClass: DefaultKeyConnectorApiService, + deps: [ApiServiceAbstraction], + }), safeProvider({ provide: PremiumInterestStateService, useClass: NoopPremiumInterestStateService, diff --git a/libs/angular/src/vault/components/icon.component.html b/libs/angular/src/vault/components/icon.component.html index 0f14de64e21..5806c728e95 100644 --- a/libs/angular/src/vault/components/icon.component.html +++ b/libs/angular/src/vault/components/icon.component.html @@ -1,9 +1,5 @@ -