diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 763b48ab1d9..d23cfa58283 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -90,7 +90,9 @@ apps/web/src/app/core @bitwarden/team-platform-dev apps/web/src/app/shared @bitwarden/team-platform-dev apps/web/src/translation-constants.ts @bitwarden/team-platform-dev # Workflows -.github/workflows/brew-bump-desktop.yml @bitwarden/team-platform-dev +# Any changes here should also be reflected in Renovate configuration +.github/workflows/automatic-issue-responses.yml @bitwarden/team-platform-dev +.github/workflows/automatic-pull-request-responses.yml @bitwarden/team-platform-dev .github/workflows/build-browser-target.yml @bitwarden/team-platform-dev .github/workflows/build-browser.yml @bitwarden/team-platform-dev .github/workflows/build-cli-target.yml @bitwarden/team-platform-dev @@ -100,10 +102,13 @@ apps/web/src/translation-constants.ts @bitwarden/team-platform-dev .github/workflows/build-web-target.yml @bitwarden/team-platform-dev .github/workflows/build-web.yml @bitwarden/team-platform-dev .github/workflows/chromatic.yml @bitwarden/team-platform-dev +.github/workflows/crowdin-pull.yml @bitwarden/team-platform-dev +.github/workflows/enforce-labels.yml @bitwarden/team-platform-dev .github/workflows/lint.yml @bitwarden/team-platform-dev .github/workflows/locales-lint.yml @bitwarden/team-platform-dev .github/workflows/repository-management.yml @bitwarden/team-platform-dev .github/workflows/scan.yml @bitwarden/team-platform-dev +.github/workflows/stale-bot.yml @bitwarden/team-platform-dev .github/workflows/test.yml @bitwarden/team-platform-dev .github/workflows/version-auto-bump.yml @bitwarden/team-platform-dev # ESLint custom rules @@ -152,6 +157,7 @@ apps/desktop/src/locales/en/messages.json apps/web/src/locales/en/messages.json ## BRE team owns these workflows ## +# Any changes here should also be reflected in Renovate configuration ## .github/workflows/brew-bump-desktop.yml @bitwarden/dept-bre .github/workflows/deploy-web.yml @bitwarden/dept-bre .github/workflows/publish-cli.yml @bitwarden/dept-bre @@ -159,13 +165,11 @@ apps/web/src/locales/en/messages.json .github/workflows/publish-web.yml @bitwarden/dept-bre .github/workflows/retrieve-current-desktop-rollout.yml @bitwarden/dept-bre .github/workflows/staged-rollout-desktop.yml @bitwarden/dept-bre - -## Shared ownership workflows ## -.github/workflows/release-browser.yml -.github/workflows/release-cli.yml -.github/workflows/release-desktop-beta.yml -.github/workflows/release-desktop.yml -.github/workflows/release-web.yml +.github/workflows/release-browser.yml @bitwarden/dept-bre +.github/workflows/release-cli.yml @bitwarden/dept-bre +.github/workflows/release-desktop-beta.yml @bitwarden/dept-bre +.github/workflows/release-desktop.yml @bitwarden/dept-bre +.github/workflows/release-web.yml @bitwarden/dept-bre ## Docker files have shared ownership ## **/Dockerfile diff --git a/.github/renovate.json5 b/.github/renovate.json5 index b9de0084c25..b898ffc8629 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,28 +1,65 @@ { $schema: "https://docs.renovatebot.com/renovate-schema.json", - extends: ["github>bitwarden/renovate-config"], // Extends our base config for pinned dependencies + extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies enabledManagers: ["cargo", "github-actions", "npm"], packageRules: [ { + // Group all build/test/lint workflows for GitHub Actions together for Platform + // Since they are code owners we don't need to assign a review team in Renovate + // Any changes here should also be reflected in CODEOWNERS groupName: "github-action minor", matchManagers: ["github-actions"], - matchUpdateTypes: ["minor"], - }, - { - matchManagers: ["cargo"], + matchFileNames: [ + "./github/workflows/automatic-issue-responses.yml", + "./github/workflows/automatic-pull-request-responses.yml", + "./github/workflows/build-browser.yml", + "./github/workflows/build-cli.yml", + "./github/workflows/build-desktop.yml", + "./github/workflows/build-web.yml", + "./github/workflows/chromatic.yml", + "./github/workflows/crowdin-pull.yml", + "./github/workflows/enforce-labels.yml", + "./github/workflows/lint.yml", + "./github/workflows/locales-lint.yml", + "./github/workflows/repository-management.yml", + "./github/workflows/scan.yml", + "./github/workflows/stale-bot.yml", + "./github/workflows/test.yml", + "./github/workflows/version-auto-bump.yml", + ], commitMessagePrefix: "[deps] Platform:", }, { - groupName: "napi", - matchPackageNames: ["napi", "napi-build", "napi-derive"], + // Group all release-related workflows for GitHub Actions together for BRE + // Since they are code owners we don't need to assign a review team in Renovate + // Any changes here should also be reflected in CODEOWNERS + groupName: "github-action minor", + matchManagers: ["github-actions"], + matchFileNames: [ + "./github/workflows/brew-bump-desktop.yml", + "./github/workflows/deploy-web.yml", + "./github/workflows/publish-cli.yml", + "./github/workflows/publish-desktop.yml", + "./github/workflows/publish-web.yml", + "./github/workflows/retrieve-current-desktop-rollout.yml", + "./github/workflows/staged-rollout-desktop.yml", + "./github/workflows/release-cli.yml", + "./github/workflows/release-desktop-beta.yml", + "./github/workflows/release-desktop.yml", + "./github/workflows/release-web.yml", + ], + commitMessagePrefix: "[deps] BRE:", }, { + // Disable major and minor updates for TypeScript and Zone.js because they are managed by Angular matchPackageNames: ["typescript", "zone.js"], matchUpdateTypes: ["major", "minor"], description: "Determined by Angular", enabled: false, }, { + // Disable major updates for core Angular dependencies because they are managed through ng update + // when we decide to upgrade. matchSourceUrls: [ "https://github.com/angular-eslint/angular-eslint", "https://github.com/angular/angular-cli", @@ -35,19 +72,27 @@ enabled: false, }, { + // Renovate should manage patch updates for TypeScript and Zone.js, despite ignoring major and minor matchPackageNames: ["typescript", "zone.js"], matchUpdateTypes: "patch", }, { + // We want to update all the Jest-related packages together, to reduce PR noise groupName: "jest", matchPackageNames: ["@types/jest", "jest", "ts-jest", "jest-preset-angular"], - matchUpdateTypes: "major", }, { + // We need to group all napi-related packages together to avoid build errors caused by version incompatibilities + groupName: "napi", + matchPackageNames: ["napi", "napi-build", "napi-derive"], + }, + { + // We need to group all macOS/iOS binding-related packages together to avoid build errors caused by version incompatibilities groupName: "macOS/iOS bindings", matchPackageNames: ["core-foundation", "security-framework", "security-framework-sys"], }, { + // We need to group all zbus-related packages together to avoid build errors caused by version incompatibilities groupName: "zbus", matchPackageNames: ["zbus", "zbus_polkit"], }, diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c41fcfa3afd..0b039315b30 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,28 +11,10 @@ on: types: [opened, synchronize] jobs: - check-test-secrets: - name: Check for test secrets - runs-on: ubuntu-22.04 - outputs: - available: ${{ steps.check-test-secrets.outputs.available }} - permissions: - contents: read - - steps: - - name: Check - id: check-test-secrets - run: | - if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then - echo "available=true" >> $GITHUB_OUTPUT; - else - echo "available=false" >> $GITHUB_OUTPUT; - fi testing: name: Run tests runs-on: ubuntu-22.04 - needs: check-test-secrets permissions: checks: write contents: read @@ -77,7 +59,7 @@ jobs: - name: Report test results uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1 - if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }} + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results path: "junit.xml" @@ -89,7 +71,6 @@ jobs: - name: Upload results to codecov.io uses: codecov/test-results-action@4e79e65778be1cecd5df25e14af1eafb6df80ea9 # v1.0.2 - if: ${{ needs.check-test-secrets.outputs.available == 'true' }} env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index b6c44405ffa..5d31edd3097 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -669,8 +669,8 @@ "browserNotSupportClipboard": { "message": "Your web browser does not support easy clipboard copying. Copy it manually instead." }, - "verifyIdentity": { - "message": "Verify identity" + "verifyYourIdentity": { + "message": "Verify your identity" }, "weDontRecognizeThisDevice": { "message": "We don't recognize this device. Enter the code sent to your email to verify your identity." @@ -1088,6 +1088,38 @@ }, "description": "Shown to user after login is updated." }, + "saveAsNewLoginAction": { + "message": "Save as new login", + "description": "Button text for saving login details as a new entry." + }, + "updateLoginAction": { + "message": "Update login", + "description": "Button text for updating an existing login entry." + }, + "saveLoginPrompt": { + "message": "Save login?", + "description": "Prompt asking the user if they want to save their login details." + }, + "updateLoginPrompt": { + "message": "Update existing login?", + "description": "Prompt asking the user if they want to update an existing login entry." + }, + "loginSaveSuccess": { + "message": "Login saved", + "description": "Message displayed when login details are successfully saved." + }, + "loginUpdateSuccess": { + "message": "Login updated", + "description": "Message displayed when login details are successfully updated." + }, + "saveFailure": { + "message": "Error saving", + "description": "Error message shown when the system fails to save login details." + }, + "saveFailureDetails": { + "message": "Oh no! We couldn't save this. Try entering the details manually.", + "description": "Detailed error message shown when saving login details fails." + }, "enableChangedPasswordNotification": { "message": "Ask to update existing login" }, @@ -5131,5 +5163,8 @@ }, "updateDesktopAppOrDisableFingerprintDialogMessage": { "message": "To use biometric unlock, please update your desktop application, or disable fingerprint unlock in the desktop settings." + }, + "changeAtRiskPassword": { + "message": "Change at-risk password" } } diff --git a/apps/browser/src/auth/popup/home.component.ts b/apps/browser/src/auth/popup/home.component.ts index dbdcf7d5aa8..0c4510204d1 100644 --- a/apps/browser/src/auth/popup/home.component.ts +++ b/apps/browser/src/auth/popup/home.component.ts @@ -81,8 +81,10 @@ export class HomeComponent implements OnInit, OnDestroy { tap(async (flag) => { // If the flag is turned ON, we must force a reload to ensure the correct UI is shown if (flag) { + const qParams = await firstValueFrom(this.route.queryParams); + const uniqueQueryParams = { - ...this.route.queryParams, + ...qParams, // adding a unique timestamp to the query params to force a reload t: new Date().getTime().toString(), }; diff --git a/apps/browser/src/autofill/content/notification-bar.ts b/apps/browser/src/autofill/content/notification-bar.ts index 58480cd6d83..ae489ea956b 100644 --- a/apps/browser/src/autofill/content/notification-bar.ts +++ b/apps/browser/src/autofill/content/notification-bar.ts @@ -845,6 +845,7 @@ async function loadNotificationBar() { theme: typeData.theme, removeIndividualVault: typeData.removeIndividualVault, importType: typeData.importType, + launchTimestamp: typeData.launchTimestamp, }; const notificationBarUrl = "notification/bar.html"; @@ -873,11 +874,32 @@ async function loadNotificationBar() { const barPageUrl: string = chrome.runtime.getURL(barPage); function getIframeStyle(useComponentBar: boolean): string { - return ( - (useComponentBar - ? "height: calc(276px + 25px); width: 450px; right: 0;" - : "height: 42px; width: 100%;") + " border: 0; min-height: initial;" - ); + const isNotificationFresh = + notificationBarInitData.launchTimestamp && + Date.now() - notificationBarInitData.launchTimestamp < 500; + + const baseStyle = useComponentBar + ? isNotificationFresh + ? "height: calc(276px + 25px); width: 450px; right: 0; transform:translateX(100%); opacity:0;" + : "height: calc(276px + 25px); width: 450px; right: 0; transform:translateX(0%); opacity:1;" + : "height: 42px; width: 100%;"; + + const transitionStyle = + isNotificationFresh && useComponentBar + ? "transition: transform 0.15s ease-in, opacity 0.15s ease; transform:translateX(0%); opacity:1; transition-delay: 0.25s;" + : ""; + + notificationBarIframe.style.cssText = baseStyle + " border: 0; min-height: initial;"; + + if (isNotificationFresh && useComponentBar) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + notificationBarIframe.style.cssText += transitionStyle; + }); + }); + } + + return baseStyle + " border: 0; min-height: initial;"; } notificationBarIframe = document.createElement("iframe"); diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index a31f1b4fdbc..c3f29e1332f 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -53,30 +53,28 @@ function getI18n() { return { appName: chrome.i18n.getMessage("appName"), close: chrome.i18n.getMessage("close"), - never: chrome.i18n.getMessage("never"), folder: chrome.i18n.getMessage("folder"), + loginSaveSuccess: chrome.i18n.getMessage("loginSaveSuccess"), loginSaveSuccessDetails: chrome.i18n.getMessage("loginSaveSuccessDetails"), + loginUpdateSuccess: chrome.i18n.getMessage("loginUpdateSuccess"), loginUpdateSuccessDetails: chrome.i18n.getMessage("loginUpdatedSuccessDetails"), - notificationAddSave: chrome.i18n.getMessage("notificationAddSave"), + newItem: chrome.i18n.getMessage("newItem"), + never: chrome.i18n.getMessage("never"), notificationAddDesc: chrome.i18n.getMessage("notificationAddDesc"), - notificationEdit: chrome.i18n.getMessage("edit"), - notificationChangeSave: chrome.i18n.getMessage("notificationChangeSave"), + notificationAddSave: chrome.i18n.getMessage("notificationAddSave"), notificationChangeDesc: chrome.i18n.getMessage("notificationChangeDesc"), + notificationChangeSave: chrome.i18n.getMessage("notificationChangeSave"), + notificationEdit: chrome.i18n.getMessage("edit"), notificationUnlock: chrome.i18n.getMessage("notificationUnlock"), notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"), - - // @TODO move values to message catalog - saveAction: "Save", - saveAsNewLoginAction: "Save as new login", - updateLoginAction: "Update login", - saveLoginPrompt: "Save login?", - updateLoginPrompt: "Update existing login?", - loginSaveSuccess: "Login saved", - loginUpdateSuccess: "Login updated", - saveFailure: "Error saving", - saveFailureDetails: "Oh no! We couldn't save this. Try entering the details as a New item", - newItem: "New item", - view: "View", + saveAction: chrome.i18n.getMessage("notificationAddSave"), + saveAsNewLoginAction: chrome.i18n.getMessage("saveAsNewLoginAction"), + saveFailure: chrome.i18n.getMessage("saveFailure"), + saveFailureDetails: chrome.i18n.getMessage("saveFailureDetails"), + saveLoginPrompt: chrome.i18n.getMessage("saveLoginPrompt"), + updateLoginAction: chrome.i18n.getMessage("updateLoginAction"), + updateLoginPrompt: chrome.i18n.getMessage("updateLoginPrompt"), + view: chrome.i18n.getMessage("view"), }; } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 7251dea0580..8716b5ebf99 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1330,6 +1330,8 @@ export default class MainBackground { this.syncServiceListener?.listener$().subscribe(); await this.autoSubmitLoginBackground.init(); + this.configService.broadcastConfigChangesTo(this.encryptService, this.bulkEncryptService); + if ( BrowserApi.isManifestVersion(2) && (await this.configService.getFeatureFlag(FeatureFlag.PM4154_BulkEncryptionService)) diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 76894b23d0a..8f5d754b554 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -171,7 +171,7 @@ const routes: Routes = [ data: { elevation: 1, pageTitle: { - key: "verifyIdentity", + key: "verifyYourIdentity", }, showBackButton: true, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, @@ -248,7 +248,7 @@ const routes: Routes = [ data: { pageIcon: DeviceVerificationIcon, pageTitle: { - key: "verifyIdentity", + key: "verifyYourIdentity", }, pageSubtitle: { key: "weDontRecognizeThisDevice", diff --git a/apps/browser/src/popup/services/init.service.ts b/apps/browser/src/popup/services/init.service.ts index fe6fba85a4b..f0c382431b4 100644 --- a/apps/browser/src/popup/services/init.service.ts +++ b/apps/browser/src/popup/services/init.service.ts @@ -3,6 +3,8 @@ import { inject, Inject, Injectable } from "@angular/core"; import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -27,6 +29,8 @@ export class InitService { private themingService: AbstractThemingService, private sdkLoadService: SdkLoadService, private viewCacheService: PopupViewCacheService, + private encryptService: EncryptService, + private configService: ConfigService, @Inject(DOCUMENT) private document: Document, ) {} @@ -58,6 +62,8 @@ export class InitService { this.logService.info("Force redraw is on"); } + this.configService.broadcastConfigChangesTo(this.encryptService); + this.setupVaultPopupHeartbeat(); }; } diff --git a/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift index 58d95f959be..d4ce360c32a 100644 --- a/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift +++ b/apps/browser/src/safari/safari/SafariWebExtensionHandler.swift @@ -164,7 +164,15 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { break } - guard let accessControl = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .userPresence], nil) else { + var flags: SecAccessControlCreateFlags = [.privateKeyUsage]; + // https://developer.apple.com/documentation/security/secaccesscontrolcreateflags/biometryany + if #available(macOS 10.13.4, *) { + flags.insert(.biometryAny) + } else { + flags.insert(.touchIDAny) + } + + guard let accessControl = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, nil) else { let messageId = message?["messageId"] as? Int response.userInfo = [ SFExtensionMessageKey: [ diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html index 9e29f1f1294..ccff7313258 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.html @@ -3,13 +3,14 @@ slot="header" [backAction]="close" showBackButton - [pageTitle]="title" + [pageTitle]="titleKey | i18n" > @@ -19,6 +20,7 @@ buttonType="primary" (click)="selectValue()" data-testid="select-button" + [disabled]="!(selectButtonText && generatedValue)" > {{ selectButtonText }} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts index 3255593a424..9c94f8fc63f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts @@ -1,15 +1,18 @@ import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; import { Component, EventEmitter, Input, Output } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock, MockProxy } from "jest-mock-extended"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { AlgorithmInfo } from "@bitwarden/generator-core"; import { CipherFormGeneratorComponent } from "@bitwarden/vault"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; import { + GeneratorDialogAction, GeneratorDialogParams, GeneratorDialogResult, VaultGeneratorDialogComponent, @@ -21,8 +24,9 @@ import { standalone: true, }) class MockCipherFormGenerator { - @Input() type: "password" | "username"; - @Input() uri: string; + @Input() type: "password" | "username" = "password"; + @Output() algorithmSelected: EventEmitter = new EventEmitter(); + @Input() uri: string = ""; @Output() valueGenerated = new EventEmitter(); } @@ -53,34 +57,87 @@ describe("VaultGeneratorDialogComponent", () => { fixture = TestBed.createComponent(VaultGeneratorDialogComponent); component = fixture.componentInstance; - }); - - it("should create", () => { fixture.detectChanges(); - expect(component).toBeTruthy(); }); - it("should use the appropriate text based on generator type", () => { - expect(component["title"]).toBe("passwordGenerator"); - expect(component["selectButtonText"]).toBe("useThisPassword"); - - dialogData.type = "username"; - - fixture = TestBed.createComponent(VaultGeneratorDialogComponent); - component = fixture.componentInstance; - - expect(component["title"]).toBe("usernameGenerator"); - expect(component["selectButtonText"]).toBe("useThisUsername"); + it("should show password generator title", () => { + const header = fixture.debugElement.query(By.css("popup-header")).componentInstance; + expect(header.pageTitle).toBe("passwordGenerator"); }); - it("should close the dialog with the generated value when the user selects it", () => { - component["generatedValue"] = "generated-value"; + it("should pass type to cipher form generator", () => { + const generator = fixture.debugElement.query( + By.css("vault-cipher-form-generator"), + ).componentInstance; + expect(generator.type).toBe("password"); + }); - fixture.nativeElement.querySelector("button[data-testid='select-button']").click(); + it("should enable select button when value is generated", () => { + component.onAlgorithmSelected({ useGeneratedValue: "Test" } as any); + component.onValueGenerated("test-password"); + fixture.detectChanges(); + + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(false); + }); + + it("should disable the button if no value has been generated", () => { + const generator = fixture.debugElement.query( + By.css("vault-cipher-form-generator"), + ).componentInstance; + + generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any); + fixture.detectChanges(); + + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); + }); + + it("should disable the button if no algorithm is selected", () => { + const generator = fixture.debugElement.query( + By.css("vault-cipher-form-generator"), + ).componentInstance; + + generator.valueGenerated.emit("test-password"); + fixture.detectChanges(); + + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); + }); + + it("should update button text when algorithm is selected", () => { + component.onAlgorithmSelected({ useGeneratedValue: "Use This Password" } as any); + fixture.detectChanges(); + + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.textContent.trim()).toBe("Use This Password"); + }); + + it("should close with generated value when selected", () => { + component.onAlgorithmSelected({ useGeneratedValue: "Test" } as any); + component.onValueGenerated("test-password"); + fixture.detectChanges(); + + fixture.debugElement.query(By.css("[data-testid='select-button']")).nativeElement.click(); expect(mockDialogRef.close).toHaveBeenCalledWith({ - action: "selected", - generatedValue: "generated-value", + action: GeneratorDialogAction.Selected, + generatedValue: "test-password", + }); + }); + + it("should close with canceled action when dismissed", () => { + fixture.debugElement.query(By.css("popup-header")).componentInstance.backAction(); + expect(mockDialogRef.close).toHaveBeenCalledWith({ + action: GeneratorDialogAction.Canceled, }); }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts index 9e6750004d8..0eeb2e95a29 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.ts @@ -7,6 +7,8 @@ import { Component, Inject } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonModule, DialogService } from "@bitwarden/components"; +import { AlgorithmInfo } from "@bitwarden/generator-core"; +import { I18nPipe } from "@bitwarden/ui-common"; import { CipherFormGeneratorComponent } from "@bitwarden/vault"; import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; @@ -39,13 +41,12 @@ export enum GeneratorDialogAction { CommonModule, CipherFormGeneratorComponent, ButtonModule, + I18nPipe, ], }) export class VaultGeneratorDialogComponent { - protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator"); - protected selectButtonText = this.i18nService.t( - this.isPassword ? "useThisPassword" : "useThisUsername", - ); + protected selectButtonText: string | undefined; + protected titleKey = this.isPassword ? "passwordGenerator" : "usernameGenerator"; /** * Whether the dialog is generating a password/passphrase. If false, it is generating a username. @@ -92,6 +93,16 @@ export class VaultGeneratorDialogComponent { this.generatedValue = value; } + onAlgorithmSelected = (selected?: AlgorithmInfo) => { + if (selected) { + this.selectButtonText = selected.useGeneratedValue; + } else { + // default to email + this.selectButtonText = this.i18nService.t("useThisEmail"); + } + this.generatedValue = undefined; + }; + /** * Opens the vault generator dialog in a full screen dialog. */ diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index 209691869f0..b9eae380ca0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -37,8 +37,16 @@ import { IconButtonModule, SearchModule, ToastService, + CalloutModule, } from "@bitwarden/components"; -import { CipherViewComponent, CopyCipherFieldService } from "@bitwarden/vault"; +import { + ChangeLoginPasswordService, + CipherViewComponent, + CopyCipherFieldService, + DefaultChangeLoginPasswordService, + DefaultTaskService, + TaskService, +} from "@bitwarden/vault"; import { BrowserApi } from "../../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; @@ -82,10 +90,13 @@ type LoadAction = CipherViewComponent, AsyncActionsModule, PopOutComponent, + CalloutModule, ], providers: [ { provide: ViewPasswordHistoryService, useClass: BrowserViewPasswordHistoryService }, { provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService }, + { provide: TaskService, useClass: DefaultTaskService }, + { provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService }, ], }) export class ViewV2Component { diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs index 40bc36b1b9e..6644da508f4 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs @@ -28,7 +28,7 @@ impl BitwardenDesktopAgent { show_ui_request_tx: auth_request_tx, get_ui_response_rx: auth_response_rx, request_id: Arc::new(AtomicU32::new(0)), - needs_unlock: Arc::new(AtomicBool::new(false)), + needs_unlock: Arc::new(AtomicBool::new(true)), is_running: Arc::new(AtomicBool::new(false)), }; let cloned_agent_state = agent.clone(); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 992f218f6c6..c0dcd9a504a 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.2.2", + "version": "2025.2.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 1ebb1f8de39..19b92d4762a 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -97,7 +97,7 @@ const routes: Routes = [ ], data: { pageTitle: { - key: "verifyIdentity", + key: "verifyYourIdentity", }, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, @@ -126,7 +126,7 @@ const routes: Routes = [ data: { pageIcon: DeviceVerificationIcon, pageTitle: { - key: "verifyIdentity", + key: "verifyYourIdentity", }, pageSubtitle: { key: "weDontRecognizeThisDevice", diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index dce98cde9bc..f3b9e0818db 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -21,8 +21,8 @@ import { SsoComponentV1 } from "../auth/sso-v1.component"; import { TwoFactorOptionsComponentV1 } from "../auth/two-factor-options-v1.component"; import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; +import { SshAgentService } from "../autofill/services/ssh-agent.service"; import { PremiumComponent } from "../billing/app/accounts/premium.component"; -import { SshAgentService } from "../platform/services/ssh-agent.service"; import { AddEditCustomFieldsComponent } from "../vault/app/vault/add-edit-custom-fields.component"; import { AddEditComponent } from "../vault/app/vault/add-edit.component"; import { AttachmentsComponent } from "../vault/app/vault/attachments.component"; diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 3c831ae11dd..08efa4a592e 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -22,8 +22,8 @@ import { UserId } from "@bitwarden/common/types/guid"; import { KeyService as KeyServiceAbstraction } from "@bitwarden/key-management"; import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service"; +import { SshAgentService } from "../../autofill/services/ssh-agent.service"; import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; -import { SshAgentService } from "../../platform/services/ssh-agent.service"; import { VersionService } from "../../platform/services/version.service"; import { NativeMessagingService } from "../../services/native-messaging.service"; diff --git a/apps/desktop/src/auth/login/login-v1.component.ts b/apps/desktop/src/auth/login/login-v1.component.ts index 5d1a1d818d5..ff8688353be 100644 --- a/apps/desktop/src/auth/login/login-v1.component.ts +++ b/apps/desktop/src/auth/login/login-v1.component.ts @@ -3,7 +3,7 @@ import { Component, NgZone, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; import { FormBuilder } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, takeUntil, tap } from "rxjs"; +import { Subject, firstValueFrom, takeUntil, tap } from "rxjs"; import { LoginComponentV1 as BaseLoginComponent } from "@bitwarden/angular/auth/components/login-v1.component"; import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service"; @@ -143,10 +143,11 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe .getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh) .pipe( tap(async (flag) => { - // If the flag is turned ON, we must force a reload to ensure the correct UI is shown if (flag) { + const qParams = await firstValueFrom(this.route.queryParams); + const uniqueQueryParams = { - ...this.route.queryParams, + ...qParams, // adding a unique timestamp to the query params to force a reload t: new Date().getTime().toString(), }; @@ -156,7 +157,7 @@ export class LoginComponentV1 extends BaseLoginComponent implements OnInit, OnDe }); } }), - takeUntil(this.destroy$), + takeUntil(this.componentDestroyed$), ) .subscribe(); } diff --git a/apps/desktop/src/platform/main/main-ssh-agent.service.ts b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts similarity index 100% rename from apps/desktop/src/platform/main/main-ssh-agent.service.ts rename to apps/desktop/src/autofill/main/main-ssh-agent.service.ts diff --git a/apps/desktop/src/platform/services/ssh-agent.service.ts b/apps/desktop/src/autofill/services/ssh-agent.service.ts similarity index 97% rename from apps/desktop/src/platform/services/ssh-agent.service.ts rename to apps/desktop/src/autofill/services/ssh-agent.service.ts index 5fd1bafdc1b..87c6de75a95 100644 --- a/apps/desktop/src/platform/services/ssh-agent.service.ts +++ b/apps/desktop/src/autofill/services/ssh-agent.service.ts @@ -34,9 +34,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherType } from "@bitwarden/common/vault/enums"; import { DialogService, ToastService } from "@bitwarden/components"; -import { ApproveSshRequestComponent } from "../components/approve-ssh-request"; - -import { DesktopSettingsService } from "./desktop-settings.service"; +import { ApproveSshRequestComponent } from "../../platform/components/approve-ssh-request"; +import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; @Injectable({ providedIn: "root", @@ -130,7 +129,7 @@ export class SshAgentService implements OnDestroy { throw error; }), - map(() => message), + map(() => [message, account.id]), ); } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 726b5c4b316..f8c59affa71 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -916,7 +916,7 @@ "message": "Verify with Duo Security for your organization using the Duo Mobile app, SMS, phone call, or U2F security key.", "description": "'Duo Security' and 'Duo Mobile' are product names and should not be translated." }, - "verifyIdentity": { + "verifyYourIdentity": { "message": "Verify your Identity" }, "weDontRecognizeThisDevice": { @@ -3604,5 +3604,8 @@ }, "updateBrowserOrDisableFingerprintDialogMessage": { "message": "The browser extension you are using is out of date. Please update it or disable browser integration fingerprint validation in the desktop app settings." + }, + "changeAtRiskPassword": { + "message": "Change at-risk password" } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7e417e8e5a8..4e167f30ec8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -30,6 +30,7 @@ import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@ import { DefaultBiometricStateService } from "@bitwarden/key-management"; /* eslint-enable import/no-restricted-paths */ +import { MainSshAgentService } from "./autofill/main/main-ssh-agent.service"; import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service"; import { DesktopBiometricsService } from "./key-management/biometrics/desktop.biometrics.service"; import { MainBiometricsIPCListener } from "./key-management/biometrics/main-biometrics-ipc.listener"; @@ -45,7 +46,6 @@ import { NativeAutofillMain } from "./platform/main/autofill/native-autofill.mai import { ClipboardMain } from "./platform/main/clipboard.main"; import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener"; import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.service"; -import { MainSshAgentService } from "./platform/main/main-ssh-agent.service"; import { VersionMain } from "./platform/main/version.main"; import { DesktopSettingsService } from "./platform/services/desktop-settings.service"; import { ElectronLogMainService } from "./platform/services/electron-log.main.service"; diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index bd54446f4e4..cf7d528d36e 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.2.2", + "version": "2025.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.2.2", + "version": "2025.2.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 3c504913522..4296add59ec 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.2.2", + "version": "2025.2.1", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.html b/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.html index 84e64956ca5..47232dff66d 100644 --- a/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.html +++ b/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.html @@ -4,7 +4,7 @@ - {{ selectedFile?.name ?? ("noFileChosen" | i18n) }} - - - {{ "sendFileDesc" | i18n }} {{ "maxFileSize" | i18n }} - - - -

{{ "share" | i18n }}

- - - {{ "sendLinkLabel" | i18n }} - - - - - - {{ "copySendLinkOnSave" | i18n }} - -
-

- -

-
-
-
-
- - {{ "deletionDate" | i18n }} - - - - - - - {{ "deletionDateDesc" | i18n }} - -
-
- - {{ "deletionDate" | i18n }} - - {{ "deletionDateDesc" | i18n }} - -
-
- - - {{ "expirationDate" | i18n }} - - - - - - - - {{ "expirationDateDesc" | i18n }} - -
-
- - - {{ "expirationDate" | i18n }} - - - - - {{ "expirationDateDesc" | i18n }} - -
-
-
- - {{ "maxAccessCount" | i18n }} - - {{ "maxAccessCountDesc" | i18n }} - - - {{ "currentAccessCount" | i18n }} - - -
-
- - {{ "password" | i18n }} - {{ "newPassword" | i18n }} - - - - {{ "sendPasswordDesc" | i18n }} - -
- - {{ "notes" | i18n }} - - {{ "sendNotesDesc" | i18n }} - - - - - {{ "hideEmail" | i18n }} - - - - - {{ "disableThisSend" | i18n }} - -
- - - - - - - - - diff --git a/apps/web/src/app/tools/send/add-edit.component.ts b/apps/web/src/app/tools/send/add-edit.component.ts deleted file mode 100644 index 4ce126a33bc..00000000000 --- a/apps/web/src/app/tools/send/add-edit.component.ts +++ /dev/null @@ -1,102 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; -import { DatePipe } from "@angular/common"; -import { Component, Inject } from "@angular/core"; -import { FormBuilder } from "@angular/forms"; - -import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; -import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; -import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; -import { DialogService, ToastService } from "@bitwarden/components"; - -@Component({ - selector: "app-send-add-edit", - templateUrl: "add-edit.component.html", -}) -export class AddEditComponent extends BaseAddEditComponent { - override componentName = "app-send-add-edit"; - protected selectedFile: File; - - constructor( - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - environmentService: EnvironmentService, - datePipe: DatePipe, - sendService: SendService, - stateService: StateService, - messagingService: MessagingService, - policyService: PolicyService, - logService: LogService, - sendApiService: SendApiService, - dialogService: DialogService, - formBuilder: FormBuilder, - billingAccountProfileStateService: BillingAccountProfileStateService, - protected dialogRef: DialogRef, - @Inject(DIALOG_DATA) params: { sendId: string }, - accountService: AccountService, - toastService: ToastService, - ) { - super( - i18nService, - platformUtilsService, - environmentService, - datePipe, - sendService, - messagingService, - policyService, - logService, - stateService, - sendApiService, - dialogService, - formBuilder, - billingAccountProfileStateService, - accountService, - toastService, - ); - - this.sendId = params.sendId; - } - - async copyLinkToClipboard(link: string): Promise { - // Copy function on web depends on the modal being open or not. Since this event occurs during a transition - // of the modal closing we need to add a small delay to make sure state of the DOM is consistent. - return new Promise((resolve) => { - window.setTimeout(() => resolve(super.copyLinkToClipboard(link)), 500); - }); - } - - protected setSelectedFile(event: Event) { - const fileInputEl = event.target; - const file = fileInputEl.files.length > 0 ? fileInputEl.files[0] : null; - this.selectedFile = file; - } - - submitAndClose = async () => { - this.formGroup.markAllAsTouched(); - if (this.formGroup.invalid) { - return; - } - - const success = await this.submit(); - if (success) { - this.dialogRef.close(); - } - }; - - deleteAndClose = async () => { - const success = await this.delete(); - if (success) { - this.dialogRef.close(); - } - }; -} diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.html b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.html new file mode 100644 index 00000000000..34e28be1084 --- /dev/null +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.html @@ -0,0 +1,23 @@ + + + + + {{ "sendTypeText" | i18n }} + + + + {{ "sendTypeFile" | i18n }} + + + diff --git a/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts new file mode 100644 index 00000000000..8cd052aa016 --- /dev/null +++ b/apps/web/src/app/tools/send/new-send/new-send-dropdown.component.ts @@ -0,0 +1,63 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom, Observable, of, switchMap } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { BadgeModule, ButtonModule, DialogService, MenuModule } from "@bitwarden/components"; +import { DefaultSendFormConfigService, SendAddEditDialogComponent } from "@bitwarden/send-ui"; + +@Component({ + selector: "tools-new-send-dropdown", + templateUrl: "new-send-dropdown.component.html", + standalone: true, + imports: [JslibModule, CommonModule, ButtonModule, MenuModule, BadgeModule], + providers: [DefaultSendFormConfigService], +}) +/** + * A dropdown component that allows the user to create a new Send of a specific type. + */ +export class NewSendDropdownComponent { + /** If true, the plus icon will be hidden */ + @Input() hideIcon: boolean = false; + + /** SendType provided for the markup to pass back the selected type of Send */ + protected sendType = SendType; + + /** Indicates whether the user can access premium features. */ + protected canAccessPremium$: Observable; + + constructor( + private router: Router, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private accountService: AccountService, + private dialogService: DialogService, + private addEditFormConfigService: DefaultSendFormConfigService, + ) { + this.canAccessPremium$ = this.accountService.activeAccount$.pipe( + switchMap((account) => + account + ? this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id) + : of(false), + ), + ); + } + + /** + * Opens the SendAddEditComponent for a new Send with the provided type. + * If has user does not have premium access and the type is File, the user will be redirected to the premium settings page. + * @param type The type of Send to create. + */ + async createSend(type: SendType) { + if (!(await firstValueFrom(this.canAccessPremium$)) && type === SendType.File) { + return await this.router.navigate(["settings/subscription/premium"]); + } + + const formConfig = await this.addEditFormConfigService.buildConfig("add", undefined, type); + + await SendAddEditDialogComponent.open(this.dialogService, { formConfig }); + } +} diff --git a/apps/web/src/app/tools/send/send.component.html b/apps/web/src/app/tools/send/send.component.html index 7aab50b33e5..6f690459bb0 100644 --- a/apps/web/src/app/tools/send/send.component.html +++ b/apps/web/src/app/tools/send/send.component.html @@ -11,11 +11,7 @@ - - + @@ -198,10 +194,11 @@ {{ "sendsNoItemsTitle" | i18n }} {{ "sendsNoItemsMessage" | i18n }} - + diff --git a/apps/web/src/app/tools/send/send.component.ts b/apps/web/src/app/tools/send/send.component.ts index 1268e4bfb50..ed6cb9a2b3c 100644 --- a/apps/web/src/app/tools/send/send.component.ts +++ b/apps/web/src/app/tools/send/send.component.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Component, NgZone, ViewChild, OnInit, OnDestroy, ViewContainerRef } from "@angular/core"; +import { DialogRef } from "@angular/cdk/dialog"; +import { Component, NgZone, OnInit, OnDestroy } from "@angular/core"; import { lastValueFrom } from "rxjs"; import { SendComponent as BaseSendComponent } from "@bitwarden/angular/tools/send/send.component"; @@ -14,6 +15,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { SendId } from "@bitwarden/common/types/guid"; import { DialogService, NoItemsModule, @@ -21,24 +23,30 @@ import { TableDataSource, ToastService, } from "@bitwarden/components"; -import { NoSendsIcon } from "@bitwarden/send-ui"; +import { + DefaultSendFormConfigService, + NoSendsIcon, + SendFormConfig, + SendAddEditDialogComponent, + SendItemDialogResult, +} from "@bitwarden/send-ui"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; -import { AddEditComponent } from "./add-edit.component"; +import { NewSendDropdownComponent } from "./new-send/new-send-dropdown.component"; const BroadcasterSubscriptionId = "SendComponent"; @Component({ selector: "app-send", standalone: true, - imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule], + imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule, NewSendDropdownComponent], templateUrl: "send.component.html", + providers: [DefaultSendFormConfigService], }) export class SendComponent extends BaseSendComponent implements OnInit, OnDestroy { - @ViewChild("sendAddEdit", { read: ViewContainerRef, static: true }) - sendAddEditModalRef: ViewContainerRef; + private sendItemDialogRef?: DialogRef | undefined; noItemIcon = NoSendsIcon; override set filteredSends(filteredSends: SendView[]) { @@ -65,6 +73,7 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro sendApiService: SendApiService, dialogService: DialogService, toastService: ToastService, + private addEditFormConfigService: DefaultSendFormConfigService, ) { super( sendService, @@ -111,17 +120,41 @@ export class SendComponent extends BaseSendComponent implements OnInit, OnDestro return; } - await this.editSend(null); + const config = await this.addEditFormConfigService.buildConfig("add", null, 0); + + await this.openSendItemDialog(config); } async editSend(send: SendView) { - const dialog = this.dialogService.open(AddEditComponent, { - data: { - sendId: send == null ? null : send.id, - }, + const config = await this.addEditFormConfigService.buildConfig( + send == null ? "add" : "edit", + send == null ? null : (send.id as SendId), + send.type, + ); + + await this.openSendItemDialog(config); + } + + /** + * Opens the send item dialog. + * @param formConfig The form configuration. + * */ + async openSendItemDialog(formConfig: SendFormConfig) { + // Prevent multiple dialogs from being opened. + if (this.sendItemDialogRef) { + return; + } + + this.sendItemDialogRef = SendAddEditDialogComponent.open(this.dialogService, { + formConfig, }); - await lastValueFrom(dialog.closed); - await this.load(); + const result = await lastValueFrom(this.sendItemDialogRef.closed); + this.sendItemDialogRef = undefined; + + // If the dialog was closed by deleting the cipher, refresh the vault. + if (result === SendItemDialogResult.Deleted || result === SendItemDialogResult.Saved) { + await this.load(); + } } } diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 59275eb4e7c..881903e79e5 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -36,6 +36,7 @@ import { ToastService, } from "@bitwarden/components"; import { + ChangeLoginPasswordService, CipherAttachmentsComponent, CipherFormComponent, CipherFormConfig, @@ -43,6 +44,9 @@ import { CipherFormModule, CipherViewComponent, DecryptionFailureDialogComponent, + DefaultChangeLoginPasswordService, + DefaultTaskService, + TaskService, } from "@bitwarden/vault"; import { SharedModule } from "../../../shared/shared.module"; @@ -136,6 +140,8 @@ export enum VaultItemDialogResult { { provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService }, { provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService }, RoutedVaultFilterService, + { provide: TaskService, useClass: DefaultTaskService }, + { provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService }, ], }) export class VaultItemDialogComponent implements OnInit, OnDestroy { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts index ac0d0fb1947..5a0a98a84b2 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.module.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.module.ts @@ -5,11 +5,11 @@ import { RouterModule } from "@angular/router"; import { TableModule } from "@bitwarden/components"; +import { CollectionBadgeModule } from "../../../admin-console/organizations/collections/collection-badge/collection-badge.module"; +import { GroupBadgeModule } from "../../../admin-console/organizations/collections/group-badge/group-badge.module"; import { SharedModule } from "../../../shared/shared.module"; import { OrganizationBadgeModule } from "../../individual-vault/organization-badge/organization-badge.module"; import { PipesModule } from "../../individual-vault/pipes/pipes.module"; -import { CollectionBadgeModule } from "../../org-vault/collection-badge/collection-badge.module"; -import { GroupBadgeModule } from "../../org-vault/group-badge/group-badge.module"; import { VaultCipherRowComponent } from "./vault-cipher-row.component"; import { VaultCollectionRowComponent } from "./vault-collection-row.component"; diff --git a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.html b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.html index 0c6beae5d51..b62eb8515be 100644 --- a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.html +++ b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.html @@ -1,12 +1,13 @@ - {{ title }} + {{ titleKey | i18n }} @@ -17,8 +18,9 @@ buttonType="primary" (click)="selectValue()" data-testid="select-button" + [disabled]="!(buttonLabel && generatedValue)" > - {{ selectButtonText }} + {{ buttonLabel }} diff --git a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts index 41f2c7d8348..11a97a1f343 100644 --- a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts @@ -1,19 +1,19 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; -import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { Component, Input, Output, EventEmitter } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock, MockProxy } from "jest-mock-extended"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { AlgorithmInfo } from "@bitwarden/generator-core"; import { CipherFormGeneratorComponent } from "@bitwarden/vault"; import { WebVaultGeneratorDialogAction, WebVaultGeneratorDialogComponent, - WebVaultGeneratorDialogParams, + WebVaultGeneratorDialogResult, } from "./web-generator-dialog.component"; @Component({ @@ -22,7 +22,8 @@ import { standalone: true, }) class MockCipherFormGenerator { - @Input() type: "password" | "username"; + @Input() type: "password" | "username" = "password"; + @Output() algorithmSelected: EventEmitter = new EventEmitter(); @Input() uri?: string; @Output() valueGenerated = new EventEmitter(); } @@ -30,35 +31,20 @@ class MockCipherFormGenerator { describe("WebVaultGeneratorDialogComponent", () => { let component: WebVaultGeneratorDialogComponent; let fixture: ComponentFixture; - - let dialogRef: MockProxy>; + let dialogRef: MockProxy>; let mockI18nService: MockProxy; beforeEach(async () => { - dialogRef = mock>(); + dialogRef = mock>(); mockI18nService = mock(); - const mockDialogData: WebVaultGeneratorDialogParams = { type: "password" }; - await TestBed.configureTestingModule({ imports: [NoopAnimationsModule, WebVaultGeneratorDialogComponent], providers: [ - { - provide: DialogRef, - useValue: dialogRef, - }, - { - provide: DIALOG_DATA, - useValue: mockDialogData, - }, - { - provide: I18nService, - useValue: mockI18nService, - }, - { - provide: PlatformUtilsService, - useValue: mock(), - }, + { provide: DialogRef, useValue: dialogRef }, + { provide: DIALOG_DATA, useValue: { type: "password" } }, + { provide: I18nService, useValue: mockI18nService }, + { provide: PlatformUtilsService, useValue: mock() }, ], }) .overrideComponent(WebVaultGeneratorDialogComponent, { @@ -72,38 +58,73 @@ describe("WebVaultGeneratorDialogComponent", () => { fixture.detectChanges(); }); - it("initializes without errors", () => { - fixture.detectChanges(); + it("should create", () => { expect(component).toBeTruthy(); }); - it("closes the dialog with 'canceled' result when close is called", () => { - const closeSpy = jest.spyOn(dialogRef, "close"); + it("should enable button when value and algorithm are selected", () => { + const generator = fixture.debugElement.query( + By.css("vault-cipher-form-generator"), + ).componentInstance; - (component as any).close(); + generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any); + generator.valueGenerated.emit("test-password"); + fixture.detectChanges(); - expect(closeSpy).toHaveBeenCalledWith({ + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(false); + }); + + it("should disable the button if no value has been generated", () => { + const generator = fixture.debugElement.query( + By.css("vault-cipher-form-generator"), + ).componentInstance; + + generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any); + fixture.detectChanges(); + + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); + }); + + it("should disable the button if no algorithm is selected", () => { + const generator = fixture.debugElement.query( + By.css("vault-cipher-form-generator"), + ).componentInstance; + + generator.valueGenerated.emit("test-password"); + fixture.detectChanges(); + + const button = fixture.debugElement.query( + By.css("[data-testid='select-button']"), + ).nativeElement; + expect(button.disabled).toBe(true); + }); + + it("should close with selected value when confirmed", () => { + const generator = fixture.debugElement.query( + By.css("vault-cipher-form-generator"), + ).componentInstance; + generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any); + generator.valueGenerated.emit("test-password"); + fixture.detectChanges(); + + fixture.debugElement.query(By.css("[data-testid='select-button']")).nativeElement.click(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + action: WebVaultGeneratorDialogAction.Selected, + generatedValue: "test-password", + }); + }); + + it("should close with canceled action when dismissed", () => { + component["close"](); + expect(dialogRef.close).toHaveBeenCalledWith({ action: WebVaultGeneratorDialogAction.Canceled, }); }); - - it("closes the dialog with 'selected' result when selectValue is called", () => { - const closeSpy = jest.spyOn(dialogRef, "close"); - const generatedValue = "generated-value"; - component.onValueGenerated(generatedValue); - - (component as any).selectValue(); - - expect(closeSpy).toHaveBeenCalledWith({ - action: WebVaultGeneratorDialogAction.Selected, - generatedValue: generatedValue, - }); - }); - - it("updates generatedValue when onValueGenerated is called", () => { - const generatedValue = "new-generated-value"; - component.onValueGenerated(generatedValue); - - expect((component as any).generatedValue).toBe(generatedValue); - }); }); diff --git a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.ts b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.ts index a87bcb85804..b0e5514ce21 100644 --- a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.ts +++ b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.ts @@ -6,6 +6,8 @@ import { Component, Inject } from "@angular/core"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { AlgorithmInfo } from "@bitwarden/generator-core"; +import { I18nPipe } from "@bitwarden/ui-common"; import { CipherFormGeneratorComponent } from "@bitwarden/vault"; export interface WebVaultGeneratorDialogParams { @@ -27,13 +29,11 @@ export enum WebVaultGeneratorDialogAction { selector: "web-vault-generator-dialog", templateUrl: "./web-generator-dialog.component.html", standalone: true, - imports: [CommonModule, CipherFormGeneratorComponent, ButtonModule, DialogModule], + imports: [CommonModule, CipherFormGeneratorComponent, ButtonModule, DialogModule, I18nPipe], }) export class WebVaultGeneratorDialogComponent { - protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator"); - protected selectButtonText = this.i18nService.t( - this.isPassword ? "useThisPassword" : "useThisUsername", - ); + protected titleKey = this.isPassword ? "passwordGenerator" : "usernameGenerator"; + protected buttonLabel: string | undefined; /** * Whether the dialog is generating a password/passphrase. If false, it is generating a username. @@ -80,6 +80,16 @@ export class WebVaultGeneratorDialogComponent { this.generatedValue = value; } + onAlgorithmSelected = (selected?: AlgorithmInfo) => { + if (selected) { + this.buttonLabel = selected.useGeneratedValue; + } else { + // default to email + this.buttonLabel = this.i18nService.t("useThisEmail"); + } + this.generatedValue = undefined; + }; + /** * Opens the vault generator dialog. */ diff --git a/apps/web/src/app/vault/individual-vault/vault.module.ts b/apps/web/src/app/vault/individual-vault/vault.module.ts index 712b86a9803..d400d44bf0d 100644 --- a/apps/web/src/app/vault/individual-vault/vault.module.ts +++ b/apps/web/src/app/vault/individual-vault/vault.module.ts @@ -1,9 +1,9 @@ import { NgModule } from "@angular/core"; +import { CollectionBadgeModule } from "../../admin-console/organizations/collections/collection-badge/collection-badge.module"; +import { GroupBadgeModule } from "../../admin-console/organizations/collections/group-badge/group-badge.module"; import { LooseComponentsModule, SharedModule } from "../../shared"; import { CollectionDialogModule } from "../components/collection-dialog"; -import { CollectionBadgeModule } from "../org-vault/collection-badge/collection-badge.module"; -import { GroupBadgeModule } from "../org-vault/group-badge/group-badge.module"; import { BulkDialogsModule } from "./bulk-action-dialogs/bulk-dialogs.module"; import { OrganizationBadgeModule } from "./organization-badge/organization-badge.module"; diff --git a/apps/web/src/app/vault/individual-vault/view.component.spec.ts b/apps/web/src/app/vault/individual-vault/view.component.spec.ts index 9bea7f14eb5..d1117258124 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.spec.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.spec.ts @@ -12,6 +12,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; @@ -21,6 +22,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { KeyService } from "@bitwarden/key-management"; +import { ChangeLoginPasswordService, DefaultTaskService, TaskService } from "@bitwarden/vault"; import { ViewCipherDialogParams, ViewCipherDialogResult, ViewComponent } from "./view.component"; @@ -82,7 +84,33 @@ describe("ViewComponent", () => { }, }, ], - }).compileComponents(); + }) + .overrideComponent(ViewComponent, { + remove: { + providers: [ + { provide: TaskService, useClass: DefaultTaskService }, + { provide: PlatformUtilsService, useValue: PlatformUtilsService }, + { + provide: ChangeLoginPasswordService, + useValue: ChangeLoginPasswordService, + }, + ], + }, + add: { + providers: [ + { + provide: TaskService, + useValue: mock(), + }, + { provide: PlatformUtilsService, useValue: mock() }, + { + provide: ChangeLoginPasswordService, + useValue: mock(), + }, + ], + }, + }) + .compileComponents(); fixture = TestBed.createComponent(ViewComponent); component = fixture.componentInstance; diff --git a/apps/web/src/app/vault/individual-vault/view.component.ts b/apps/web/src/app/vault/individual-vault/view.component.ts index baae6f28bf1..7a2cf3bb2f4 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -26,7 +26,7 @@ import { DialogService, ToastService, } from "@bitwarden/components"; -import { CipherViewComponent } from "@bitwarden/vault"; +import { CipherViewComponent, DefaultTaskService, TaskService } from "@bitwarden/vault"; import { SharedModule } from "../../shared/shared.module"; import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service"; @@ -74,6 +74,7 @@ export interface ViewCipherDialogCloseResult { providers: [ { provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService }, { provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService }, + { provide: TaskService, useClass: DefaultTaskService }, ], }) export class ViewComponent implements OnInit { diff --git a/apps/web/src/connectors/webauthn-fallback.ts b/apps/web/src/connectors/webauthn-fallback.ts index 5410b89dcfa..3561f922e03 100644 --- a/apps/web/src/connectors/webauthn-fallback.ts +++ b/apps/web/src/connectors/webauthn-fallback.ts @@ -82,8 +82,8 @@ document.addEventListener("DOMContentLoaded", async () => { const titleForSmallerScreens = document.getElementById("title-smaller-screens"); const titleForLargerScreens = document.getElementById("title-larger-screens"); - titleForSmallerScreens.innerText = localeService.t("verifyIdentity"); - titleForLargerScreens.innerText = localeService.t("verifyIdentity"); + titleForSmallerScreens.innerText = localeService.t("verifyYourIdentity"); + titleForLargerScreens.innerText = localeService.t("verifyYourIdentity"); const subtitle = document.getElementById("subtitle"); subtitle.innerText = localeService.t("followTheStepsBelowToFinishLoggingIn"); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 22a67cbff28..49613fa5c7c 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -222,6 +222,9 @@ "notes": { "message": "Notes" }, + "privateNote": { + "message": "Private note" + }, "note": { "message": "Note" }, @@ -1200,7 +1203,7 @@ "authenticationSessionTimedOut": { "message": "The authentication session timed out. Please restart the login process." }, - "verifyIdentity": { + "verifyYourIdentity": { "message": "Verify your Identity" }, "weDontRecognizeThisDevice": { @@ -5105,12 +5108,40 @@ "requireSsoExemption": { "message": "Organization owners and admins are exempt from this policy's enforcement." }, + "limitSendViews": { + "message": "Limit views" + }, + "limitSendViewsHint": { + "message": "No one can view this Send after the limit is reached.", + "description": "Displayed under the limit views field on Send" + }, + "limitSendViewsCount": { + "message": "$ACCESSCOUNT$ views left", + "description": "Displayed under the limit views field on Send", + "placeholders": { + "accessCount": { + "content": "$1", + "example": "2" + } + } + }, + "sendDetails": { + "message": "Send details", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, + "sendTypeTextToShare": { + "message": "Text to share" + }, "sendTypeFile": { "message": "File" }, "sendTypeText": { "message": "Text" }, + "sendPasswordDescV3": { + "message": "Add an optional password for recipients to access this Send.", + "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." + }, "createSend": { "message": "New Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5135,19 +5166,15 @@ "message": "Delete Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "deleteSendConfirmation": { - "message": "Are you sure you want to delete this Send?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "whatTypeOfSend": { - "message": "What type of Send is this?", + "deleteSendPermanentConfirmation": { + "message": "Are you sure you want to permanently delete this Send?", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "deletionDate": { "message": "Deletion date" }, - "deletionDateDesc": { - "message": "The Send will be permanently deleted on the specified date and time.", + "deletionDateDescV2": { + "message": "The Send will be permanently deleted on this date.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, "expirationDate": { @@ -5160,21 +5187,6 @@ "maxAccessCount": { "message": "Maximum access count" }, - "maxAccessCountDesc": { - "message": "If set, users will no longer be able to access this Send once the maximum access count is reached.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "currentAccessCount": { - "message": "Current access count" - }, - "sendPasswordDesc": { - "message": "Optionally require a password for users to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendNotesDesc": { - "message": "Private notes about this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "disabled": { "message": "Disabled" }, @@ -5201,13 +5213,6 @@ "removePasswordConfirmation": { "message": "Are you sure you want to remove the password?" }, - "hideEmail": { - "message": "Hide my email address from recipients." - }, - "disableThisSend": { - "message": "Deactivate this Send so that no one can access it.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "allSends": { "message": "All Sends" }, @@ -5218,6 +5223,9 @@ "pendingDeletion": { "message": "Pending deletion" }, + "hideTextByDefault": { + "message": "Hide text by default" + }, "expired": { "message": "Expired" }, @@ -5439,13 +5447,6 @@ "message": "Always show member’s email address with recipients when creating or editing a Send.", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, - "sendOptionsPolicyInEffect": { - "message": "The following organization policies are currently in effect:" - }, - "sendDisableHideEmailInEffect": { - "message": "Users are not allowed to hide their email address from recipients when creating or editing a Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "modifiedPolicyId": { "message": "Modified policy $ID$.", "placeholders": { @@ -5545,27 +5546,6 @@ "personalOwnershipCheckboxDesc": { "message": "Remove individual ownership for organization users" }, - "textHiddenByDefault": { - "message": "When accessing the Send, hide the text by default", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendNameDesc": { - "message": "A friendly name to describe this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendTextDesc": { - "message": "The text you want to Send." - }, - "sendFileDesc": { - "message": "The file you want to Send." - }, - "copySendLinkOnSave": { - "message": "Copy the link to share this Send to my clipboard upon save." - }, - "sendLinkLabel": { - "message": "Send link", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "send": { "message": "Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -5714,6 +5694,9 @@ "dateParsingError": { "message": "There was an error saving your deletion and expiration dates." }, + "hideYourEmail": { + "message": "Hide your email address from viewers." + }, "webAuthnFallbackMsg": { "message": "To verify your 2FA please click the button below." }, @@ -9875,9 +9858,15 @@ "learnMoreAboutApi": { "message": "Learn more about Bitwarden's API" }, + "fileSend": { + "message": "File Send" + }, "fileSends": { "message": "File Sends" }, + "textSend": { + "message": "Text Send" + }, "textSends": { "message": "Text Sends" }, @@ -10514,6 +10503,9 @@ "assignedExceedsAvailable": { "message": "Assigned seats exceed available seats." }, + "changeAtRiskPassword": { + "message": "Change at-risk password" + }, "removeUnlockWithPinPolicyTitle": { "message": "Remove Unlock with PIN" }, diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html index 6483a196a3f..f2e550cb68e 100644 --- a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/member-access-report.component.html @@ -35,7 +35,12 @@ - + {{ "members" | i18n }} @@ -44,25 +49,23 @@ {{ "items" | i18n }} - - - -
- -
- + + +
+ +
+ -
- {{ r.email }} -
+
+ {{ row.email }}
- - {{ r.groupsCount }} - {{ r.collectionsCount }} - {{ r.itemsCount }} - +
+ + {{ row.groupsCount }} + {{ row.collectionsCount }} + {{ row.itemsCount }}
- + diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 3fb578ff7a9..dc6c049101a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -45,8 +45,6 @@ import { DefaultAuthRequestApiService, DefaultLoginSuccessHandlerService, LoginSuccessHandlerService, - PasswordLoginStrategy, - PasswordLoginStrategyData, LoginApprovalComponentServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; @@ -1460,37 +1458,6 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultLoginSuccessHandlerService, deps: [SyncService, UserAsymmetricKeysRegenerationService], }), - safeProvider({ - provide: PasswordLoginStrategy, - useClass: PasswordLoginStrategy, - deps: [ - PasswordLoginStrategyData, - PasswordStrengthServiceAbstraction, - PolicyServiceAbstraction, - LoginStrategyServiceAbstraction, - AccountServiceAbstraction, - InternalMasterPasswordServiceAbstraction, - KeyService, - EncryptService, - ApiServiceAbstraction, - TokenServiceAbstraction, - AppIdServiceAbstraction, - PlatformUtilsServiceAbstraction, - MessagingServiceAbstraction, - LogService, - StateServiceAbstraction, - TwoFactorServiceAbstraction, - InternalUserDecryptionOptionsServiceAbstraction, - BillingAccountProfileStateService, - VaultTimeoutSettingsService, - KdfConfigService, - ], - }), - safeProvider({ - provide: PasswordLoginStrategyData, - useClass: PasswordLoginStrategyData, - deps: [], - }), safeProvider({ provide: TaskService, useClass: DefaultTaskService, diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index fb76ff500eb..fa25bfc8254 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -1,8 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { BehaviorSubject, Subject, firstValueFrom, from, map, switchMap, takeUntil } from "rxjs"; +import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -22,11 +21,9 @@ export class VaultItemsComponent implements OnInit, OnDestroy { loaded = false; ciphers: CipherView[] = []; - searchPlaceholder: string = null; filter: (cipher: CipherView) => boolean = null; deleted = false; organization: Organization; - accessEvents = false; protected searchPending = false; @@ -45,20 +42,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy { protected searchService: SearchService, protected cipherService: CipherService, protected accountService: AccountService, - ) { - this.accountService.activeAccount$ - .pipe( - getUserId, - switchMap((userId) => - this.cipherService.cipherViews$(userId).pipe(map((ciphers) => ({ userId, ciphers }))), - ), - takeUntilDestroyed(), - ) - .subscribe(({ userId, ciphers }) => { - void this.doSearch(ciphers, userId); - this.loaded = true; - }); - } + ) {} ngOnInit(): void { this._searchText$ diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index a84fb93bd23..c291a64a8c5 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -161,8 +161,9 @@ export class LoginComponent implements OnInit, OnDestroy { tap(async (flag) => { // If the flag is turned OFF, we must force a reload to ensure the correct UI is shown if (!flag) { + const qParams = await firstValueFrom(this.activatedRoute.queryParams); const uniqueQueryParams = { - ...this.activatedRoute.queryParams, + ...qParams, // adding a unique timestamp to the query params to force a reload t: new Date().getTime().toString(), // Adding a unique timestamp as a query parameter }; diff --git a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts index 6e0f9eec05e..6c48a471d08 100644 --- a/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts +++ b/libs/auth/src/angular/new-device-verification/new-device-verification.component.ts @@ -20,7 +20,6 @@ import { import { LoginEmailServiceAbstraction } from "../../common/abstractions/login-email.service"; import { LoginStrategyServiceAbstraction } from "../../common/abstractions/login-strategy.service"; -import { PasswordLoginStrategy } from "../../common/login-strategies/password-login.strategy"; /** * Component for verifying a new device via a one-time password (OTP). @@ -58,7 +57,6 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy { constructor( private router: Router, private formBuilder: FormBuilder, - private passwordLoginStrategy: PasswordLoginStrategy, private apiService: ApiService, private loginStrategyService: LoginStrategyServiceAbstraction, private logService: LogService, diff --git a/libs/auth/src/angular/sso/sso.component.ts b/libs/auth/src/angular/sso/sso.component.ts index cc9e5f83c01..ce63769ffca 100644 --- a/libs/auth/src/angular/sso/sso.component.ts +++ b/libs/auth/src/angular/sso/sso.component.ts @@ -427,7 +427,6 @@ export class SsoComponent implements OnInit { ); this.formPromise = this.loginStrategyService.logIn(credentials); const authResult = await this.formPromise; - if (authResult.requiresTwoFactor) { return await this.handleTwoFactorRequired(orgSsoIdentifier); } @@ -441,9 +440,10 @@ export class SsoComponent implements OnInit { // - Browser SSO on extension open // Note: you cannot set this in state before 2FA b/c there won't be an account in state. - // Grabbing the active user id right before making the state set to ensure it exists. - const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(orgSsoIdentifier, userId); + await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier( + orgSsoIdentifier, + authResult.userId, + ); // must come after 2fa check since user decryption options aren't available if 2fa is required const userDecryptionOpts = await firstValueFrom( diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index ec0918b4197..0646da4862b 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -14,6 +14,7 @@ import { VaultTimeoutSettingsService, } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -53,6 +54,7 @@ describe("AuthRequestLoginStrategy", () => { let billingAccountProfileStateService: MockProxy; let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; + let environmentService: MockProxy; const mockUserId = Utils.newGuid() as UserId; let accountService: FakeAccountService; @@ -88,6 +90,7 @@ describe("AuthRequestLoginStrategy", () => { billingAccountProfileStateService = mock(); vaultTimeoutSettingsService = mock(); kdfConfigService = mock(); + environmentService = mock(); accountService = mockAccountServiceWith(mockUserId); masterPasswordService = new FakeMasterPasswordService(); @@ -117,6 +120,7 @@ describe("AuthRequestLoginStrategy", () => { billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, + environmentService, ); tokenResponse = identityTokenResponseFactory(); diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index e1558a3de8b..b4a1e6a77d9 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -25,6 +25,7 @@ import { VaultTimeoutSettingsService, } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -123,6 +124,7 @@ describe("LoginStrategy", () => { let billingAccountProfileStateService: MockProxy; let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; + let environmentService: MockProxy; let passwordLoginStrategy: PasswordLoginStrategy; let credentials: PasswordLoginCredentials; @@ -147,6 +149,7 @@ describe("LoginStrategy", () => { policyService = mock(); passwordStrengthService = mock(); billingAccountProfileStateService = mock(); + environmentService = mock(); vaultTimeoutSettingsService = mock(); @@ -175,6 +178,7 @@ describe("LoginStrategy", () => { billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, + environmentService, ); credentials = new PasswordLoginCredentials(email, masterPassword); }); @@ -496,6 +500,7 @@ describe("LoginStrategy", () => { billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, + environmentService, ); apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory()); @@ -559,6 +564,7 @@ describe("LoginStrategy", () => { billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, + environmentService, ); const result = await passwordLoginStrategy.logIn(credentials); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index a7299c7f0d0..89802c609c0 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -27,6 +27,7 @@ import { } from "@bitwarden/common/key-management/vault-timeout"; import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -93,6 +94,7 @@ export abstract class LoginStrategy { protected billingAccountProfileStateService: BillingAccountProfileStateService, protected vaultTimeoutSettingsService: VaultTimeoutSettingsService, protected KdfConfigService: KdfConfigService, + protected environmentService: EnvironmentService, ) {} abstract exportCache(): CacheData; @@ -196,6 +198,10 @@ export abstract class LoginStrategy { emailVerified: accountInformation.email_verified ?? false, }); + // User env must be seeded from currently set env before switching to the account + // to avoid any incorrect emissions of the global default env. + await this.environmentService.seedUserEnvironment(userId); + await this.accountService.switchAccount(userId); await this.stateService.addAccount( diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index 78fad4443c3..0821405e535 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -18,6 +18,7 @@ import { VaultTimeoutSettingsService, } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -80,6 +81,7 @@ describe("PasswordLoginStrategy", () => { let billingAccountProfileStateService: MockProxy; let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; + let environmentService: MockProxy; let passwordLoginStrategy: PasswordLoginStrategy; let credentials: PasswordLoginCredentials; @@ -106,6 +108,7 @@ describe("PasswordLoginStrategy", () => { billingAccountProfileStateService = mock(); vaultTimeoutSettingsService = mock(); kdfConfigService = mock(); + environmentService = mock(); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeAccessToken.mockResolvedValue({ @@ -144,6 +147,7 @@ describe("PasswordLoginStrategy", () => { billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, + environmentService, ); credentials = new PasswordLoginCredentials(email, masterPassword); tokenResponse = identityTokenResponseFactory(masterPasswordPolicy); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index 5c508dd0c56..6efb17a8d26 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -19,6 +19,7 @@ import { } from "@bitwarden/common/key-management/vault-timeout"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -63,6 +64,7 @@ describe("SsoLoginStrategy", () => { let billingAccountProfileStateService: MockProxy; let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; + let environmentService: MockProxy; let ssoLoginStrategy: SsoLoginStrategy; let credentials: SsoLoginCredentials; @@ -98,6 +100,7 @@ describe("SsoLoginStrategy", () => { billingAccountProfileStateService = mock(); vaultTimeoutSettingsService = mock(); kdfConfigService = mock(); + environmentService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -142,6 +145,7 @@ describe("SsoLoginStrategy", () => { billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, + environmentService, ); credentials = new SsoLoginCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId); }); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index de411c06b6b..c0c7e828b68 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -97,7 +97,6 @@ describe("UserApiLoginStrategy", () => { apiLogInStrategy = new UserApiLoginStrategy( cache, - environmentService, keyConnectorService, accountService, masterPasswordService, @@ -115,6 +114,7 @@ describe("UserApiLoginStrategy", () => { billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, + environmentService, ); credentials = new UserApiLoginCredentials(apiClientId, apiClientSecret); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index 4f6352b5d74..0bff20b4a65 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -7,7 +7,6 @@ import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-con import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { VaultTimeoutAction } from "@bitwarden/common/key-management/vault-timeout"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { UserId } from "@bitwarden/common/types/guid"; import { UserApiLoginCredentials } from "../models/domain/login-credentials"; @@ -31,7 +30,6 @@ export class UserApiLoginStrategy extends LoginStrategy { constructor( data: UserApiLoginStrategyData, - private environmentService: EnvironmentService, private keyConnectorService: KeyConnectorService, ...sharedDeps: ConstructorParameters ) { diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index f4282a4b4df..837c6a2a910 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -16,6 +16,7 @@ import { VaultTimeoutSettingsService, } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -52,6 +53,7 @@ describe("WebAuthnLoginStrategy", () => { let billingAccountProfileStateService: MockProxy; let vaultTimeoutSettingsService: MockProxy; let kdfConfigService: MockProxy; + let environmentService: MockProxy; let webAuthnLoginStrategy!: WebAuthnLoginStrategy; @@ -95,6 +97,7 @@ describe("WebAuthnLoginStrategy", () => { billingAccountProfileStateService = mock(); vaultTimeoutSettingsService = mock(); kdfConfigService = mock(); + environmentService = mock(); tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); @@ -120,6 +123,7 @@ describe("WebAuthnLoginStrategy", () => { billingAccountProfileStateService, vaultTimeoutSettingsService, kdfConfigService, + environmentService, ); // Create credentials diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index 20410c76f1f..4068c09338b 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -402,6 +402,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { this.billingAccountProfileStateService, this.vaultTimeoutSettingsService, this.kdfConfigService, + this.environmentService, ]; return source.pipe( @@ -430,7 +431,6 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.UserApiKey: return new UserApiLoginStrategy( data?.userApiKey ?? new UserApiLoginStrategyData(), - this.environmentService, this.keyConnectorService, ...sharedDeps, ); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index e61cd852977..16680cc711a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -1,3 +1,5 @@ +import { ServerConfig } from "../platform/abstractions/config/server-config"; + /** * Feature flags. * @@ -5,7 +7,6 @@ */ export enum FeatureFlag { /* Admin Console Team */ - ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner", AccountDeprovisioning = "pm-10308-account-deprovisioning", VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint", LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission", @@ -23,6 +24,9 @@ export enum FeatureFlag { NotificationRefresh = "notification-refresh", UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection", + /* Key Management */ + UseSDKForDecryption = "use-sdk-for-decryption", + /* Tools */ ItemShare = "item-share", CriticalApps = "pm-14466-risk-insights-critical-application", @@ -64,7 +68,6 @@ const FALSE = false as boolean; */ export const DefaultFeatureFlagValue = { /* Admin Console Team */ - [FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE, [FeatureFlag.AccountDeprovisioning]: FALSE, [FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE, [FeatureFlag.LimitItemDeletion]: FALSE, @@ -82,6 +85,9 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.NotificationRefresh]: FALSE, [FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE, + /* Key Management */ + [FeatureFlag.UseSDKForDecryption]: FALSE, + /* Tools */ [FeatureFlag.ItemShare]: FALSE, [FeatureFlag.CriticalApps]: FALSE, @@ -113,3 +119,14 @@ export const DefaultFeatureFlagValue = { export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; export type FeatureFlagValueType = DefaultFeatureFlagValueType[Flag]; + +export function getFeatureFlagValue( + serverConfig: ServerConfig | null, + flag: Flag, +) { + if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) { + return DefaultFeatureFlagValue[flag]; + } + + return serverConfig.featureStates[flag] as FeatureFlagValueType; +} diff --git a/libs/common/src/key-management/crypto/abstractions/bulk-encrypt.service.ts b/libs/common/src/key-management/crypto/abstractions/bulk-encrypt.service.ts index 3e47ccdb5f2..a88a29513a8 100644 --- a/libs/common/src/key-management/crypto/abstractions/bulk-encrypt.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/bulk-encrypt.service.ts @@ -1,10 +1,13 @@ -import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface"; -import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { OnServerConfigChange } from "../../../platform/abstractions/config/config.service"; +import { ServerConfig } from "../../../platform/abstractions/config/server-config"; +import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; +import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -export abstract class BulkEncryptService { +export abstract class BulkEncryptService implements OnServerConfigChange { abstract decryptItems( items: Decryptable[], key: SymmetricCryptoKey, ): Promise; + abstract onServerConfigChange(newConfig: ServerConfig): void; } diff --git a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts index 484327bcd27..a8004019b08 100644 --- a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts @@ -1,3 +1,5 @@ +import { OnServerConfigChange } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config"; import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface"; import { Encrypted } from "@bitwarden/common/platform/interfaces/encrypted"; import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface"; @@ -5,7 +7,7 @@ import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-arr import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -export abstract class EncryptService { +export abstract class EncryptService implements OnServerConfigChange { abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise; abstract encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise; /** @@ -55,4 +57,5 @@ export abstract class EncryptService { value: string | Uint8Array, algorithm: "sha1" | "sha256" | "sha512", ): Promise; + abstract onServerConfigChange(newConfig: ServerConfig): void; } diff --git a/libs/common/src/key-management/crypto/services/bulk-encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/bulk-encrypt.service.implementation.ts index 1d1e0f52279..17accdb5dff 100644 --- a/libs/common/src/key-management/crypto/services/bulk-encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/bulk-encrypt.service.implementation.ts @@ -12,6 +12,10 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer"; +import { ServerConfig } from "../../../platform/abstractions/config/server-config"; + +import { buildDecryptMessage, buildSetConfigMessage } from "./encrypt.worker"; + // TTL (time to live) is not strictly required but avoids tying up memory resources if inactive const workerTTL = 60000; // 1 minute const maxWorkers = 8; @@ -57,6 +61,13 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService { return decryptedItems; } + onServerConfigChange(newConfig: ServerConfig): void { + this.workers.forEach((worker) => { + const request = buildSetConfigMessage({ newConfig }); + worker.postMessage(request); + }); + } + /** * Sends items to a set of web workers to decrypt them. This utilizes multiple workers to decrypt items * faster without interrupting other operations (e.g. updating UI). @@ -108,17 +119,18 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService { itemsForWorker.push(...items.slice(end)); } - const request = { - id: Utils.newGuid(), + const id = Utils.newGuid(); + const request = buildDecryptMessage({ + id, items: itemsForWorker, key: key, - }; + }); - worker.postMessage(JSON.stringify(request)); + worker.postMessage(request); results.push( firstValueFrom( fromEvent(worker, "message").pipe( - filter((response: MessageEvent) => response.data?.id === request.id), + filter((response: MessageEvent) => response.data?.id === id), map((response) => JSON.parse(response.data.items)), map((items) => items.map((jsonItem: Jsonify) => { diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index 8a001886837..5489b828dba 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -14,16 +14,31 @@ import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-arr import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { EncryptedObject } from "@bitwarden/common/platform/models/domain/encrypted-object"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { PureCrypto } from "@bitwarden/sdk-internal"; +import { + DefaultFeatureFlagValue, + FeatureFlag, + getFeatureFlagValue, +} from "../../../enums/feature-flag.enum"; +import { ServerConfig } from "../../../platform/abstractions/config/server-config"; import { EncryptService } from "../abstractions/encrypt.service"; export class EncryptServiceImplementation implements EncryptService { + private useSDKForDecryption: boolean = DefaultFeatureFlagValue[FeatureFlag.UseSDKForDecryption]; + constructor( protected cryptoFunctionService: CryptoFunctionService, protected logService: LogService, protected logMacFailures: boolean, ) {} + onServerConfigChange(newConfig: ServerConfig): void { + const old = this.useSDKForDecryption; + this.useSDKForDecryption = getFeatureFlagValue(newConfig, FeatureFlag.UseSDKForDecryption); + this.logService.debug("updated sdk decryption flag", old, this.useSDKForDecryption); + } + async encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise { if (key == null) { throw new Error("No encryption key provided."); @@ -53,20 +68,7 @@ export class EncryptServiceImplementation implements EncryptService { } const encValue = await this.aesEncrypt(plainValue, key); - let macLen = 0; - if (encValue.mac != null) { - macLen = encValue.mac.byteLength; - } - - const encBytes = new Uint8Array(1 + encValue.iv.byteLength + macLen + encValue.data.byteLength); - encBytes.set([encValue.key.encType]); - encBytes.set(new Uint8Array(encValue.iv), 1); - if (encValue.mac != null) { - encBytes.set(new Uint8Array(encValue.mac), 1 + encValue.iv.byteLength); - } - - encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength + macLen); - return new EncArrayBuffer(encBytes); + return EncArrayBuffer.fromParts(encValue.key.encType, encValue.iv, encValue.data, encValue.mac); } async decryptToUtf8( @@ -74,6 +76,15 @@ export class EncryptServiceImplementation implements EncryptService { key: SymmetricCryptoKey, decryptContext: string = "no context", ): Promise { + if (this.useSDKForDecryption) { + this.logService.debug("decrypting with SDK"); + if (encString == null || encString.encryptedString == null) { + throw new Error("encString is null or undefined"); + } + return PureCrypto.symmetric_decrypt(encString.encryptedString, key.keyB64); + } + this.logService.debug("decrypting with javascript"); + if (key == null) { throw new Error("No key provided for decryption."); } @@ -137,6 +148,25 @@ export class EncryptServiceImplementation implements EncryptService { key: SymmetricCryptoKey, decryptContext: string = "no context", ): Promise { + if (this.useSDKForDecryption) { + this.logService.debug("decrypting bytes with SDK"); + if ( + encThing.encryptionType == null || + encThing.ivBytes == null || + encThing.dataBytes == null + ) { + throw new Error("Cannot decrypt, missing type, IV, or data bytes."); + } + const buffer = EncArrayBuffer.fromParts( + encThing.encryptionType, + encThing.ivBytes, + encThing.dataBytes, + encThing.macBytes, + ).buffer; + return PureCrypto.symmetric_decrypt_array_buffer(buffer, key.keyB64); + } + this.logService.debug("decrypting bytes with javascript"); + if (key == null) { throw new Error("No encryption key provided."); } diff --git a/libs/common/src/key-management/crypto/services/encrypt.worker.ts b/libs/common/src/key-management/crypto/services/encrypt.worker.ts index 84ffcf56934..fcde1c83367 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.worker.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.worker.ts @@ -9,19 +9,48 @@ import { ContainerService } from "@bitwarden/common/platform/services/container. import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; +import { ServerConfig } from "../../../platform/abstractions/config/server-config"; +import { LogService } from "../../../platform/abstractions/log.service"; + import { EncryptServiceImplementation } from "./encrypt.service.implementation"; const workerApi: Worker = self as any; let inited = false; let encryptService: EncryptServiceImplementation; +let logService: LogService; + +const DECRYPT_COMMAND_SHELL = Object.freeze({ command: "decrypt" }); +const SET_CONFIG_COMMAND_SHELL = Object.freeze({ command: "setConfig" }); + +type DecryptCommandData = { + id: string; + items: Jsonify>[]; + key: Jsonify; +}; + +type SetConfigCommandData = { newConfig: ServerConfig }; + +export function buildDecryptMessage(data: DecryptCommandData): string { + return JSON.stringify({ + ...data, + ...DECRYPT_COMMAND_SHELL, + }); +} + +export function buildSetConfigMessage(data: SetConfigCommandData): string { + return JSON.stringify({ + ...data, + ...SET_CONFIG_COMMAND_SHELL, + }); +} /** * Bootstrap the worker environment with services required for decryption */ export function init() { const cryptoFunctionService = new WebCryptoFunctionService(self); - const logService = new ConsoleLogService(false); + logService = new ConsoleLogService(false); encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true); const bitwardenContainerService = new ContainerService(null, encryptService); @@ -39,11 +68,20 @@ workerApi.addEventListener("message", async (event: { data: string }) => { } const request: { - id: string; - items: Jsonify>[]; - key: Jsonify; + command: string; } = JSON.parse(event.data); + switch (request.command) { + case DECRYPT_COMMAND_SHELL.command: + return await handleDecrypt(request as unknown as DecryptCommandData); + case SET_CONFIG_COMMAND_SHELL.command: + return await handleSetConfig(request as unknown as SetConfigCommandData); + default: + logService.error(`unknown worker command`, request.command, request); + } +}); + +async function handleDecrypt(request: DecryptCommandData) { const key = SymmetricCryptoKey.fromJSON(request.key); const items = request.items.map((jsonItem) => { const initializer = getClassInitializer>(jsonItem.initializerKey); @@ -55,4 +93,8 @@ workerApi.addEventListener("message", async (event: { data: string }) => { id: request.id, items: JSON.stringify(result), }); -}); +} + +async function handleSetConfig(request: SetConfigCommandData) { + encryptService.onServerConfigChange(request.newConfig); +} diff --git a/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.ts b/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.ts index 80fdd27895d..852ff08165e 100644 --- a/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.ts +++ b/libs/common/src/key-management/crypto/services/fallback-bulk-encrypt.service.ts @@ -1,10 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service"; -import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface"; -import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; - +import { BulkEncryptService } from "../../../key-management/crypto/abstractions/bulk-encrypt.service"; +import { ServerConfig } from "../../../platform/abstractions/config/server-config"; +import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; +import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { EncryptService } from "../abstractions/encrypt.service"; /** @@ -33,4 +33,8 @@ export class FallbackBulkEncryptService implements BulkEncryptService { async setFeatureFlagEncryptService(featureFlagEncryptService: BulkEncryptService) { this.featureFlagEncryptService = featureFlagEncryptService; } + + onServerConfigChange(newConfig: ServerConfig): void { + (this.featureFlagEncryptService ?? this.encryptService).onServerConfigChange(newConfig); + } } diff --git a/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.ts index 0bf96851563..921f881408d 100644 --- a/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/multithread-encrypt.service.implementation.ts @@ -9,7 +9,10 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer"; +import { ServerConfig } from "../../../platform/abstractions/config/server-config"; + import { EncryptServiceImplementation } from "./encrypt.service.implementation"; +import { buildDecryptMessage, buildSetConfigMessage } from "./encrypt.worker"; // TTL (time to live) is not strictly required but avoids tying up memory resources if inactive const workerTTL = 3 * 60000; // 3 minutes @@ -47,17 +50,18 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple this.restartTimeout(); - const request = { - id: Utils.newGuid(), + const id = Utils.newGuid(); + const request = buildDecryptMessage({ + id, items: items, key: key, - }; + }); - this.worker.postMessage(JSON.stringify(request)); + this.worker.postMessage(request); return await firstValueFrom( fromEvent(this.worker, "message").pipe( - filter((response: MessageEvent) => response.data?.id === request.id), + filter((response: MessageEvent) => response.data?.id === id), map((response) => JSON.parse(response.data.items)), map((items) => items.map((jsonItem: Jsonify) => { @@ -71,6 +75,15 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple ); } + override onServerConfigChange(newConfig: ServerConfig): void { + super.onServerConfigChange(newConfig); + + if (this.worker !== null) { + const request = buildSetConfigMessage({ newConfig }); + this.worker.postMessage(request); + } + } + private clear() { this.clear$.next(); this.worker?.terminate(); diff --git a/libs/common/src/platform/abstractions/config/config.service.ts b/libs/common/src/platform/abstractions/config/config.service.ts index 04f150838e4..6158f16d764 100644 --- a/libs/common/src/platform/abstractions/config/config.service.ts +++ b/libs/common/src/platform/abstractions/config/config.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Observable } from "rxjs"; +import { Observable, Subscription } from "rxjs"; import { SemVer } from "semver"; import { FeatureFlag, FeatureFlagValueType } from "../../../enums/feature-flag.enum"; @@ -10,6 +10,8 @@ import { Region } from "../environment.service"; import { ServerConfig } from "./server-config"; +export type ConfigCallback = (serverConfig: ServerConfig) => void; + export abstract class ConfigService { /** The server config of the currently active user */ serverConfig$: Observable; @@ -54,4 +56,10 @@ export abstract class ConfigService { * Triggers a check that the config for the currently active user is up-to-date. If it is not, it will be fetched from the server and stored. */ abstract ensureConfigFetched(): Promise; + + abstract broadcastConfigChangesTo(...listeners: OnServerConfigChange[]): Subscription; +} + +export interface OnServerConfigChange { + onServerConfigChange(newConfig: ServerConfig): void; } diff --git a/libs/common/src/platform/enums/encryption-type.enum.spec.ts b/libs/common/src/platform/enums/encryption-type.enum.spec.ts new file mode 100644 index 00000000000..54fcd2e1227 --- /dev/null +++ b/libs/common/src/platform/enums/encryption-type.enum.spec.ts @@ -0,0 +1,15 @@ +import { + AsymmetricEncryptionTypes, + EncryptionType, + SymmetricEncryptionTypes, +} from "./encryption-type.enum"; + +describe("EncryptionType", () => { + it("classifies all types as symmetric or asymmetric", () => { + const nSymmetric = SymmetricEncryptionTypes.length; + const nAsymmetric = AsymmetricEncryptionTypes.length; + const nTotal = nSymmetric + nAsymmetric; + // enums are indexable by string and number + expect(Object.keys(EncryptionType).length).toEqual(nTotal * 2); + }); +}); diff --git a/libs/common/src/platform/enums/encryption-type.enum.ts b/libs/common/src/platform/enums/encryption-type.enum.ts index a0ffe679279..bc820fe4889 100644 --- a/libs/common/src/platform/enums/encryption-type.enum.ts +++ b/libs/common/src/platform/enums/encryption-type.enum.ts @@ -8,6 +8,19 @@ export enum EncryptionType { Rsa2048_OaepSha1_HmacSha256_B64 = 6, } +export const SymmetricEncryptionTypes = [ + EncryptionType.AesCbc256_B64, + EncryptionType.AesCbc128_HmacSha256_B64, + EncryptionType.AesCbc256_HmacSha256_B64, +] as const; + +export const AsymmetricEncryptionTypes = [ + EncryptionType.Rsa2048_OaepSha256_B64, + EncryptionType.Rsa2048_OaepSha1_B64, + EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64, + EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64, +] as const; + export function encryptionTypeToString(encryptionType: EncryptionType): string { if (encryptionType in EncryptionType) { return EncryptionType[encryptionType]; diff --git a/libs/common/src/platform/interfaces/encrypted.ts b/libs/common/src/platform/interfaces/encrypted.ts index 6f9d3a191df..e67a5468bb4 100644 --- a/libs/common/src/platform/interfaces/encrypted.ts +++ b/libs/common/src/platform/interfaces/encrypted.ts @@ -2,7 +2,7 @@ import { EncryptionType } from "../enums"; export interface Encrypted { encryptionType?: EncryptionType; - dataBytes: Uint8Array; - macBytes: Uint8Array; - ivBytes: Uint8Array; + dataBytes: Uint8Array | null; + macBytes: Uint8Array | null | undefined; + ivBytes: Uint8Array | null; } diff --git a/libs/common/src/platform/models/domain/enc-array-buffer.spec.ts b/libs/common/src/platform/models/domain/enc-array-buffer.spec.ts index 45a45ffe087..1259fc6326e 100644 --- a/libs/common/src/platform/models/domain/enc-array-buffer.spec.ts +++ b/libs/common/src/platform/models/domain/enc-array-buffer.spec.ts @@ -1,5 +1,10 @@ import { makeStaticByteArray } from "../../../../spec"; -import { EncryptionType } from "../../enums"; +import { + EncryptionType, + SymmetricEncryptionTypes, + AsymmetricEncryptionTypes, + encryptionTypeToString, +} from "../../enums"; import { EncArrayBuffer } from "./enc-array-buffer"; @@ -71,4 +76,66 @@ describe("encArrayBuffer", () => { const bytes = makeStaticByteArray(50, 9); expect(() => new EncArrayBuffer(bytes)).toThrow("Error parsing encrypted ArrayBuffer"); }); + + describe("fromParts factory", () => { + const plainValue = makeStaticByteArray(16, 1); + + it("throws if required data is null", () => { + expect(() => + EncArrayBuffer.fromParts(EncryptionType.AesCbc128_HmacSha256_B64, plainValue, null!, null), + ).toThrow("encryptionType, iv, and data must be provided"); + expect(() => + EncArrayBuffer.fromParts(EncryptionType.AesCbc128_HmacSha256_B64, null!, plainValue, null), + ).toThrow("encryptionType, iv, and data must be provided"); + expect(() => EncArrayBuffer.fromParts(null!, plainValue, plainValue, null)).toThrow( + "encryptionType, iv, and data must be provided", + ); + }); + + it.each(SymmetricEncryptionTypes.map((t) => encryptionTypeToString(t)))( + "works for %s", + async (typeName) => { + const type = EncryptionType[typeName as keyof typeof EncryptionType]; + const iv = plainValue; + const mac = type === EncryptionType.AesCbc256_B64 ? null : makeStaticByteArray(32, 20); + const data = plainValue; + + const actual = EncArrayBuffer.fromParts(type, iv, data, mac); + + expect(actual.encryptionType).toEqual(type); + expect(actual.ivBytes).toEqual(iv); + expect(actual.macBytes).toEqual(mac); + expect(actual.dataBytes).toEqual(data); + }, + ); + + it.each(SymmetricEncryptionTypes.filter((t) => t !== EncryptionType.AesCbc256_B64))( + "validates mac length for %s", + (type) => { + const iv = plainValue; + const mac = makeStaticByteArray(1, 20); + const data = plainValue; + + expect(() => EncArrayBuffer.fromParts(type, iv, data, mac)).toThrow("Invalid MAC length"); + }, + ); + + it.each(SymmetricEncryptionTypes.map((t) => encryptionTypeToString(t)))( + "requires or forbids mac for %s", + async (typeName) => { + const type = EncryptionType[typeName as keyof typeof EncryptionType]; + const iv = makeStaticByteArray(16, 10); + const mac = type === EncryptionType.AesCbc256_B64 ? makeStaticByteArray(32, 20) : null; + const data = plainValue; + + expect(() => EncArrayBuffer.fromParts(type, iv, data, mac)).toThrow(); + }, + ); + + it.each(AsymmetricEncryptionTypes)("throws for async type %s", (type) => { + expect(() => EncArrayBuffer.fromParts(type, plainValue, plainValue, null)).toThrow( + `Unknown EncryptionType ${type} for EncArrayBuffer.fromParts`, + ); + }); + }); }); diff --git a/libs/common/src/platform/models/domain/enc-array-buffer.ts b/libs/common/src/platform/models/domain/enc-array-buffer.ts index 305504f57b7..838a6284a94 100644 --- a/libs/common/src/platform/models/domain/enc-array-buffer.ts +++ b/libs/common/src/platform/models/domain/enc-array-buffer.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Utils } from "../../../platform/misc/utils"; import { EncryptionType } from "../../enums"; import { Encrypted } from "../../interfaces/encrypted"; @@ -10,52 +8,86 @@ const MAC_LENGTH = 32; const MIN_DATA_LENGTH = 1; export class EncArrayBuffer implements Encrypted { - readonly encryptionType: EncryptionType = null; - readonly dataBytes: Uint8Array = null; - readonly ivBytes: Uint8Array = null; - readonly macBytes: Uint8Array = null; + readonly encryptionType: EncryptionType; + readonly dataBytes: Uint8Array; + readonly ivBytes: Uint8Array; + readonly macBytes: Uint8Array | null = null; + private static readonly DecryptionError = new Error( + "Error parsing encrypted ArrayBuffer: data is corrupted or has an invalid format.", + ); constructor(readonly buffer: Uint8Array) { - const encBytes = buffer; - const encType = encBytes[0]; + if (buffer == null) { + throw new Error("EncArrayBuffer initialized with null buffer."); + } - switch (encType) { + this.encryptionType = this.buffer[0]; + + switch (this.encryptionType) { case EncryptionType.AesCbc128_HmacSha256_B64: case EncryptionType.AesCbc256_HmacSha256_B64: { const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH + MIN_DATA_LENGTH; - if (encBytes.length < minimumLength) { - this.throwDecryptionError(); + if (this.buffer.length < minimumLength) { + throw EncArrayBuffer.DecryptionError; } - this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH); - this.macBytes = encBytes.slice( + this.ivBytes = this.buffer.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH); + this.macBytes = this.buffer.slice( ENC_TYPE_LENGTH + IV_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH, ); - this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH); + this.dataBytes = this.buffer.slice(ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH); break; } case EncryptionType.AesCbc256_B64: { const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MIN_DATA_LENGTH; - if (encBytes.length < minimumLength) { - this.throwDecryptionError(); + if (this.buffer.length < minimumLength) { + throw EncArrayBuffer.DecryptionError; } - this.ivBytes = encBytes.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH); - this.dataBytes = encBytes.slice(ENC_TYPE_LENGTH + IV_LENGTH); + this.ivBytes = this.buffer.slice(ENC_TYPE_LENGTH, ENC_TYPE_LENGTH + IV_LENGTH); + this.dataBytes = this.buffer.slice(ENC_TYPE_LENGTH + IV_LENGTH); break; } default: - this.throwDecryptionError(); + throw EncArrayBuffer.DecryptionError; } - - this.encryptionType = encType; } - private throwDecryptionError() { - throw new Error( - "Error parsing encrypted ArrayBuffer: data is corrupted or has an invalid format.", - ); + static fromParts( + encryptionType: EncryptionType, + iv: Uint8Array, + data: Uint8Array, + mac: Uint8Array | undefined | null, + ) { + if (encryptionType == null || iv == null || data == null) { + throw new Error("encryptionType, iv, and data must be provided"); + } + + switch (encryptionType) { + case EncryptionType.AesCbc256_B64: + case EncryptionType.AesCbc128_HmacSha256_B64: + case EncryptionType.AesCbc256_HmacSha256_B64: + EncArrayBuffer.validateIvLength(iv); + EncArrayBuffer.validateMacLength(encryptionType, mac); + break; + default: + throw new Error(`Unknown EncryptionType ${encryptionType} for EncArrayBuffer.fromParts`); + } + + let macLen = 0; + if (mac != null) { + macLen = mac.length; + } + + const bytes = new Uint8Array(1 + iv.byteLength + macLen + data.byteLength); + bytes.set([encryptionType], 0); + bytes.set(iv, 1); + if (mac != null) { + bytes.set(mac, 1 + iv.byteLength); + } + bytes.set(data, 1 + iv.byteLength + macLen); + return new EncArrayBuffer(bytes); } static async fromResponse(response: { @@ -72,4 +104,28 @@ export class EncArrayBuffer implements Encrypted { const buffer = Utils.fromB64ToArray(b64); return new EncArrayBuffer(buffer); } + + static validateIvLength(iv: Uint8Array) { + if (iv == null || iv.length !== IV_LENGTH) { + throw new Error("Invalid IV length"); + } + } + + static validateMacLength(encType: EncryptionType, mac: Uint8Array | null | undefined) { + switch (encType) { + case EncryptionType.AesCbc256_B64: + if (mac != null) { + throw new Error("mac must not be provided for AesCbc256_B64"); + } + break; + case EncryptionType.AesCbc128_HmacSha256_B64: + case EncryptionType.AesCbc256_HmacSha256_B64: + if (mac == null || mac.length !== MAC_LENGTH) { + throw new Error("Invalid MAC length"); + } + break; + default: + throw new Error("Invalid encryption type and mac combination"); + } + } } diff --git a/libs/common/src/platform/models/domain/enc-string.ts b/libs/common/src/platform/models/domain/enc-string.ts index 360cb9bab46..46737a836e0 100644 --- a/libs/common/src/platform/models/domain/enc-string.ts +++ b/libs/common/src/platform/models/domain/enc-string.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Jsonify, Opaque } from "type-fest"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; @@ -17,7 +15,7 @@ export class EncString implements Encrypted { decryptedValue?: string; data?: string; iv?: string; - mac?: string; + mac: string | undefined | null; constructor( encryptedStringOrType: string | EncryptionType, @@ -32,15 +30,15 @@ export class EncString implements Encrypted { } } - get ivBytes(): Uint8Array { + get ivBytes(): Uint8Array | null { return this.iv == null ? null : Utils.fromB64ToArray(this.iv); } - get macBytes(): Uint8Array { + get macBytes(): Uint8Array | null { return this.mac == null ? null : Utils.fromB64ToArray(this.mac); } - get dataBytes(): Uint8Array { + get dataBytes(): Uint8Array | null { return this.data == null ? null : Utils.fromB64ToArray(this.data); } @@ -48,7 +46,7 @@ export class EncString implements Encrypted { return this.encryptedString as string; } - static fromJSON(obj: Jsonify): EncString { + static fromJSON(obj: Jsonify): EncString | null { if (obj == null) { return null; } @@ -56,7 +54,12 @@ export class EncString implements Encrypted { return new EncString(obj); } - private initFromData(encType: EncryptionType, data: string, iv: string, mac: string) { + private initFromData( + encType: EncryptionType, + data: string, + iv: string | undefined, + mac: string | undefined, + ) { if (iv != null) { this.encryptedString = (encType + "." + iv + "|" + data) as EncryptedString; } else { @@ -119,15 +122,13 @@ export class EncString implements Encrypted { } { const headerPieces = encryptedString.split("."); let encType: EncryptionType; - let encPieces: string[] = null; + let encPieces: string[]; if (headerPieces.length === 2) { try { - encType = parseInt(headerPieces[0], null); + encType = parseInt(headerPieces[0]); encPieces = headerPieces[1].split("|"); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { + } catch { return { encType: NaN, encPieces: [] }; } } else { @@ -160,7 +161,7 @@ export class EncString implements Encrypted { async decrypt( orgId: string | null, - key: SymmetricCryptoKey = null, + key: SymmetricCryptoKey | null = null, context?: string, ): Promise { if (this.decryptedValue != null) { @@ -219,7 +220,7 @@ export class EncString implements Encrypted { return this.decryptedValue; } - private async getKeyForDecryption(orgId: string) { + private async getKeyForDecryption(orgId: string | null) { const keyService = Utils.getContainerService().getKeyService(); return orgId != null ? await keyService.getOrgKey(orgId) diff --git a/libs/common/src/platform/services/config/config.service.spec.ts b/libs/common/src/platform/services/config/config.service.spec.ts index 369338f945f..ea3b56a32f1 100644 --- a/libs/common/src/platform/services/config/config.service.spec.ts +++ b/libs/common/src/platform/services/config/config.service.spec.ts @@ -361,8 +361,6 @@ describe("ConfigService", () => { const configs = await firstValueFrom(sut.serverConfig$.pipe(bufferCount(2))); - await jest.runOnlyPendingTimersAsync(); - expect(configs[0].gitHash).toBe("existing-data"); expect(configs[1].gitHash).toBe("slow-response"); }); diff --git a/libs/common/src/platform/services/config/default-config.service.ts b/libs/common/src/platform/services/config/default-config.service.ts index cc52a5b8dad..8bca4cc6d3d 100644 --- a/libs/common/src/platform/services/config/default-config.service.ts +++ b/libs/common/src/platform/services/config/default-config.service.ts @@ -10,6 +10,7 @@ import { of, shareReplay, Subject, + Subscription, switchMap, tap, } from "rxjs"; @@ -17,14 +18,14 @@ import { SemVer } from "semver"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; -import { - DefaultFeatureFlagValue, - FeatureFlag, - FeatureFlagValueType, -} from "../../../enums/feature-flag.enum"; +import { FeatureFlag, getFeatureFlagValue } from "../../../enums/feature-flag.enum"; import { UserId } from "../../../types/guid"; import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; -import { ConfigService } from "../../abstractions/config/config.service"; +import { + ConfigCallback, + ConfigService, + OnServerConfigChange, +} from "../../abstractions/config/config.service"; import { ServerConfig } from "../../abstractions/config/server-config"; import { Environment, EnvironmentService, Region } from "../../abstractions/environment.service"; import { LogService } from "../../abstractions/log.service"; @@ -57,6 +58,7 @@ export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record(); + private callbacks: ConfigCallback[] = []; serverConfig$: Observable; @@ -123,26 +125,13 @@ export class DefaultConfigService implements ConfigService { } getFeatureFlag$(key: Flag) { - return this.serverConfig$.pipe( - map((serverConfig) => this.getFeatureFlagValue(serverConfig, key)), - ); - } - - private getFeatureFlagValue( - serverConfig: ServerConfig | null, - flag: Flag, - ) { - if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) { - return DefaultFeatureFlagValue[flag]; - } - - return serverConfig.featureStates[flag] as FeatureFlagValueType; + return this.serverConfig$.pipe(map((serverConfig) => getFeatureFlagValue(serverConfig, key))); } userCachedFeatureFlag$(key: Flag, userId: UserId) { return this.stateProvider .getUser(userId, USER_SERVER_CONFIG) - .state$.pipe(map((config) => this.getFeatureFlagValue(config, key))); + .state$.pipe(map((config) => getFeatureFlagValue(config, key))); } async getFeatureFlag(key: Flag) { @@ -166,6 +155,12 @@ export class DefaultConfigService implements ConfigService { await firstValueFrom(this.serverConfig$); } + broadcastConfigChangesTo(...listeners: OnServerConfigChange[]): Subscription { + return this.serverConfig$.subscribe((config) => + listeners.forEach((listener) => listener.onServerConfigChange(config)), + ); + } + private olderThanRetrievalInterval(date: Date) { return new Date().getTime() - date.getTime() > RETRIEVAL_INTERVAL; } diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index d452732aa3d..a78a9b37a8c 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -134,7 +134,6 @@ export class StateService< } async addAccount(account: TAccount) { - await this.environmentService.seedUserEnvironment(account.profile.userId as UserId); await this.updateState(async (state) => { state.accounts[account.profile.userId] = account; return state; diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index 962865c5bbc..5aeb79023c9 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "../types/guid"; /** error emitted when the `SingleUserDependency` changes Ids */ export type UserChangedError = { diff --git a/libs/common/src/tools/log/factory.ts b/libs/common/src/tools/log/factory.ts index f011c57187a..f8abc4d2240 100644 --- a/libs/common/src/tools/log/factory.ts +++ b/libs/common/src/tools/log/factory.ts @@ -6,6 +6,9 @@ import { DefaultSemanticLogger } from "./default-semantic-logger"; import { DisabledSemanticLogger } from "./disabled-semantic-logger"; import { SemanticLogger } from "./semantic-logger.abstraction"; +/** A type for injection of a log provider */ +export type LogProvider = (context: Jsonify) => SemanticLogger; + /** Instantiates a semantic logger that emits nothing when a message * is logged. * @param _context a static payload that is cloned when the logger @@ -25,8 +28,11 @@ export function disabledSemanticLoggerProvider( * @param settings specializes how the semantic logger functions. * If this is omitted, the logger suppresses debug messages. */ -export function consoleSemanticLoggerProvider(logger: LogService): SemanticLogger { - return new DefaultSemanticLogger(logger, {}); +export function consoleSemanticLoggerProvider( + logger: LogService, + context: Jsonify, +): SemanticLogger { + return new DefaultSemanticLogger(logger, context); } /** Instantiates a semantic logger that emits logs to the console. @@ -42,7 +48,7 @@ export function ifEnabledSemanticLoggerProvider( context: Jsonify, ) { if (enable) { - return new DefaultSemanticLogger(logger, context); + return consoleSemanticLoggerProvider(logger, context); } else { return disabledSemanticLoggerProvider(context); } diff --git a/libs/common/src/tools/state/user-state-subject-dependency-provider.ts b/libs/common/src/tools/state/user-state-subject-dependency-provider.ts index 48eeb315bef..2763aac4830 100644 --- a/libs/common/src/tools/state/user-state-subject-dependency-provider.ts +++ b/libs/common/src/tools/state/user-state-subject-dependency-provider.ts @@ -12,6 +12,7 @@ export abstract class UserStateSubjectDependencyProvider { /** Provides local object persistence */ abstract state: StateProvider; + // FIXME: remove `log` and inject the system provider into the USS instead /** Provides semantic logging */ abstract log: (_context: Jsonify) => SemanticLogger; } diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 500a96238db..b643b642154 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -81,7 +81,7 @@ const DEFAULT_FRAME_SIZE = 32; export class UserStateSubject< State extends object, Secret = State, - Disclosed = never, + Disclosed = Record, Dependencies = null, > extends Observable @@ -243,7 +243,7 @@ export class UserStateSubject< // `init$` becomes the accumulator for `scan` init$.pipe( first(), - map((init) => [init, null] as const), + map((init) => [init, null] as [State, Dependencies]), ), input$.pipe( map((constrained) => constrained.state), @@ -256,7 +256,7 @@ export class UserStateSubject< if (shouldUpdate) { // actual update const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending; - return [next, dependencies]; + return [next, dependencies] as const; } else { // false update this.log.debug("shouldUpdate prevented write"); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index e66b6be0524..05c875e7546 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -980,10 +980,14 @@ export class CipherService implements CipherServiceAbstraction { async upsert(cipher: CipherData | CipherData[]): Promise> { const ciphers = cipher instanceof CipherData ? [cipher] : cipher; - return await this.updateEncryptedCipherState((current) => { + const res = await this.updateEncryptedCipherState((current) => { ciphers.forEach((c) => (current[c.id as CipherId] = c)); return current; }); + // Some state storage providers (e.g. Electron) don't update the state immediately, wait for next tick + // Otherwise, subscribers to cipherViews$ can get stale data + await new Promise((resolve) => setTimeout(resolve, 0)); + return res; } async replace(ciphers: { [id: string]: CipherData }, userId: UserId): Promise { @@ -1000,13 +1004,16 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId = null, ): Promise> { userId ||= await firstValueFrom(this.stateProvider.activeUserId$); - await this.clearDecryptedCiphersState(userId); + await this.clearCache(userId); const updatedCiphers = await this.stateProvider .getUser(userId, ENCRYPTED_CIPHERS) .update((current) => { const result = update(current ?? {}); return result; }); + // Some state storage providers (e.g. Electron) don't update the state immediately, wait for next tick + // Otherwise, subscribers to cipherViews$ can get stale data + await new Promise((resolve) => setTimeout(resolve, 0)); return updatedCiphers; } diff --git a/libs/importer/src/importers/base-importer.ts b/libs/importer/src/importers/base-importer.ts index 1992f676e29..90af5344cfc 100644 --- a/libs/importer/src/importers/base-importer.ts +++ b/libs/importer/src/importers/base-importer.ts @@ -20,6 +20,7 @@ import { ImportResult } from "../models/import-result"; export abstract class BaseImporter { organizationId: string = null; + // FIXME: This should be replaced by injecting the log service. protected logService: LogService = new ConsoleLogService(false); protected newLineRegex = /(?:\r\n|\r|\n)/; diff --git a/libs/importer/src/importers/passsordxp/passwordxp-csv-importer.spec.ts b/libs/importer/src/importers/passsordxp/passwordxp-csv-importer.spec.ts index 1bcc79723ab..0decd1e2830 100644 --- a/libs/importer/src/importers/passsordxp/passwordxp-csv-importer.spec.ts +++ b/libs/importer/src/importers/passsordxp/passwordxp-csv-importer.spec.ts @@ -48,6 +48,8 @@ describe("PasswordXPCsvImporter", () => { beforeEach(() => { importer = new PasswordXPCsvImporter(); + // Importers currently create their own ConsoleLogService. This should be replaced by injecting a test log service. + jest.spyOn(console, "warn").mockImplementation(); }); it("should return success false if CSV data is null", async () => { diff --git a/libs/importer/src/importers/roboform-csv-importer.spec.ts b/libs/importer/src/importers/roboform-csv-importer.spec.ts index 2779a9ddb64..dd385e10b8d 100644 --- a/libs/importer/src/importers/roboform-csv-importer.spec.ts +++ b/libs/importer/src/importers/roboform-csv-importer.spec.ts @@ -5,6 +5,11 @@ import { data as dataNoFolder } from "./spec-data/roboform-csv/empty-folders"; import { data as dataFolder } from "./spec-data/roboform-csv/with-folders"; describe("Roboform CSV Importer", () => { + beforeEach(() => { + // Importers currently create their own ConsoleLogService. This should be replaced by injecting a test log service. + jest.spyOn(console, "warn").mockImplementation(); + }); + it("should parse CSV data", async () => { const importer = new RoboFormCsvImporter(); const result = await importer.parse(dataNoFolder); diff --git a/libs/importer/src/importers/securesafe-csv-importer.spec.ts b/libs/importer/src/importers/securesafe-csv-importer.spec.ts index 799c3c6e9d5..e5ddbf75382 100644 --- a/libs/importer/src/importers/securesafe-csv-importer.spec.ts +++ b/libs/importer/src/importers/securesafe-csv-importer.spec.ts @@ -51,6 +51,11 @@ const CipherData = [ ]; describe("SecureSafe CSV Importer", () => { + beforeEach(() => { + // Importers currently create their own ConsoleLogService. This should be replaced by injecting a test log service. + jest.spyOn(console, "warn").mockImplementation(); + }); + CipherData.forEach((data) => { it(data.title, async () => { const importer = new SecureSafeCsvImporter(); diff --git a/libs/tools/generator/core/src/metadata/index.ts b/libs/tools/generator/core/src/metadata/index.ts new file mode 100644 index 00000000000..2aef138c0e3 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/index.ts @@ -0,0 +1,10 @@ +import { AlgorithmsByType as ABT } from "./data"; +import { CredentialType, CredentialAlgorithm } from "./type"; + +export const AlgorithmsByType: Record> = ABT; + +export { Profile, Type } from "./data"; +export { GeneratorMetadata } from "./generator-metadata"; +export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata"; +export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type"; +export { isForwarderProfile, isForwarderExtensionId } from "./util"; diff --git a/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts b/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts new file mode 100644 index 00000000000..5eafacbef52 --- /dev/null +++ b/libs/tools/generator/core/src/services/generator-profile-provider.spec.ts @@ -0,0 +1,338 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, ReplaySubject, firstValueFrom } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; +import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction"; +import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; +import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; +import { StateConstraints } from "@bitwarden/common/tools/types"; +import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; + +import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec"; +import { CoreProfileMetadata, ProfileContext } from "../metadata/profile-metadata"; +import { GeneratorConstraints } from "../types"; + +import { GeneratorProfileProvider } from "./generator-profile-provider"; + +// arbitrary settings types +type SomeSettings = { foo: string }; + +// fake user information +const SomeUser = "SomeUser" as UserId; +const AnotherUser = "SomeOtherUser" as UserId; +const UnverifiedEmailUser = "UnverifiedEmailUser" as UserId; +const accounts: Record = { + [SomeUser]: { + id: SomeUser, + name: "some user", + email: "some.user@example.com", + emailVerified: true, + }, + [AnotherUser]: { + id: AnotherUser, + name: "some other user", + email: "some.other.user@example.com", + emailVerified: true, + }, + [UnverifiedEmailUser]: { + id: UnverifiedEmailUser, + name: "a user with an unverfied email", + email: "unverified@example.com", + emailVerified: false, + }, +}; +const accountService = new FakeAccountService(accounts); + +const policyService = mock(); +const somePolicy = new Policy({ + data: { fooPolicy: true }, + type: PolicyType.PasswordGenerator, + id: "" as PolicyId, + organizationId: "" as OrganizationId, + enabled: true, +}); + +const stateProvider = new FakeStateProvider(accountService); +const encryptor = mock(); +const encryptorProvider = mock(); + +const dependencyProvider: UserStateSubjectDependencyProvider = { + encryptor: encryptorProvider, + state: stateProvider, + log: disabledSemanticLoggerProvider, +}; + +// settings storage location +const SettingsKey = new UserKeyDefinition(GENERATOR_DISK, "SomeSettings", { + deserializer: (value) => value, + clearOn: [], +}); + +// fake the configuration +const SomeProfile: CoreProfileMetadata = { + type: "core", + storage: { + target: "object", + key: "SomeSettings", + state: GENERATOR_DISK, + classifier: new PrivateClassifier(), + format: "plain", + options: { + deserializer: (value) => value, + clearOn: [], + }, + initial: { foo: "initial" }, + }, + constraints: { + type: PolicyType.PasswordGenerator, + default: { foo: {} }, + create: jest.fn((policies, context) => { + const combined = policies.reduce( + (acc, policy) => ({ fooPolicy: acc.fooPolicy || policy.data.fooPolicy }), + { fooPolicy: false }, + ); + + if (combined.fooPolicy) { + return { + constraints: { + policyInEffect: true, + }, + calibrate(state: SomeSettings) { + return { + constraints: {}, + adjust(state: SomeSettings) { + return { foo: `adjusted(${state.foo})` }; + }, + fix(state: SomeSettings) { + return { foo: `fixed(${state.foo})` }; + }, + } satisfies StateConstraints; + }, + } satisfies GeneratorConstraints; + } else { + return { + constraints: { + policyInEffect: false, + }, + adjust(state: SomeSettings) { + return state; + }, + fix(state: SomeSettings) { + return state; + }, + } satisfies GeneratorConstraints; + } + }), + }, +}; + +const NoPolicyProfile: CoreProfileMetadata = { + type: "core", + storage: { + target: "object", + key: "SomeSettings", + state: GENERATOR_DISK, + classifier: new PrivateClassifier(), + format: "classified", + options: { + deserializer: (value) => value, + clearOn: [], + }, + initial: { foo: "initial" }, + }, + constraints: { + default: { foo: {} }, + create: jest.fn((policies, context) => new IdentityConstraint()), + }, +}; + +describe("GeneratorProfileProvider", () => { + beforeEach(async () => { + policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable()); + const encryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor }); + encryptorProvider.userEncryptor$.mockReturnValue(encryptor$); + jest.clearAllMocks(); + }); + + describe("settings", () => { + it("writes to the user's state", async () => { + const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); + const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); + const settings = profileProvider.settings(SomeProfile, { account$ }); + + settings.next({ foo: "next value" }); + await awaitAsync(); + const result = await firstValueFrom(stateProvider.getUserState$(SettingsKey, SomeUser)); + + expect(result).toEqual({ foo: "next value" }); + }); + + it("waits for the user to become available", async () => { + await stateProvider.setUserState(SettingsKey, { foo: "initial value" }, SomeUser); + const account = new ReplaySubject(1); + const account$ = account.asObservable(); + const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); + + let result: SomeSettings | undefined = undefined; + profileProvider.settings(SomeProfile, { account$ }).subscribe({ + next(settings) { + result = settings; + }, + }); + await awaitAsync(); + expect(result).toBeUndefined(); + account.next(accounts[SomeUser]); + await awaitAsync(); + + // need to use `!` because TypeScript isn't aware that the subscription + // sets `result`, and thus computes the type of `result?.userId` as `never` + expect(result).toEqual({ foo: "initial value" }); + }); + }); + + describe("constraints$", () => { + it("creates constraints without policy in effect when there is no policy", async () => { + const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); + const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); + + const result = await firstValueFrom(profileProvider.constraints$(SomeProfile, { account$ })); + + expect(result.constraints.policyInEffect).toBeFalsy(); + }); + + it("creates constraints with policy in effect when there is a policy", async () => { + const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); + const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); + const policy$ = new BehaviorSubject([somePolicy]); + policyService.getAll$.mockReturnValue(policy$); + + const result = await firstValueFrom(profileProvider.constraints$(SomeProfile, { account$ })); + + expect(result.constraints.policyInEffect).toBeTruthy(); + }); + + it("sends the policy list to profile.constraint.create(...) when a type is specified", async () => { + const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); + const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); + const expectedPolicy = [somePolicy]; + const policy$ = new BehaviorSubject(expectedPolicy); + policyService.getAll$.mockReturnValue(policy$); + + await firstValueFrom(profileProvider.constraints$(SomeProfile, { account$ })); + + expect(SomeProfile.constraints.create).toHaveBeenCalledWith( + expectedPolicy, + expect.any(Object), + ); + }); + + it("sends an empty policy list to profile.constraint.create(...) when a type is omitted", async () => { + const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); + const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); + + await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { account$ })); + + expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith([], expect.any(Object)); + }); + + it("sends the context to profile.constraint.create(...)", async () => { + const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); + const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable(); + const expectedContext: ProfileContext = { + defaultConstraints: NoPolicyProfile.constraints.default, + email: accounts[SomeUser].email, + }; + + await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { account$ })); + + expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith( + expect.any(Array), + expectedContext, + ); + }); + + it("omits nonverified emails from the context sent to profile.constraint.create(...)", async () => { + const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); + const account$ = new BehaviorSubject(accounts[UnverifiedEmailUser]).asObservable(); + const expectedContext: ProfileContext = { + defaultConstraints: NoPolicyProfile.constraints.default, + }; + + await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { account$ })); + + expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith( + expect.any(Array), + expectedContext, + ); + }); + + // FIXME: implement this test case once the fake account service mock supports email verification + it.todo("invokes profile.constraint.create(...) when the user's email address is verified"); + + // FIXME: implement this test case once the fake account service mock supports email updates + it.todo("invokes profile.constraint.create(...) when the user's email address changes"); + + it("follows policy emissions", async () => { + const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); + const account = new BehaviorSubject(accounts[SomeUser]); + const account$ = account.asObservable(); + const somePolicySubject = new BehaviorSubject([somePolicy]); + policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable()); + const emissions: GeneratorConstraints[] = []; + const sub = profileProvider + .constraints$(SomeProfile, { account$ }) + .subscribe((policy) => emissions.push(policy)); + + // swap the active policy for an inactive policy + somePolicySubject.next([]); + await awaitAsync(); + sub.unsubscribe(); + const [someResult, anotherResult] = emissions; + + expect(someResult.constraints.policyInEffect).toBeTruthy(); + expect(anotherResult.constraints.policyInEffect).toBeFalsy(); + }); + + it("errors when the user errors", async () => { + const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); + const account = new BehaviorSubject(accounts[SomeUser]); + const account$ = account.asObservable(); + const expectedError = { some: "error" }; + + let actualError: any = null; + profileProvider.constraints$(SomeProfile, { account$ }).subscribe({ + error: (e: unknown) => { + actualError = e; + }, + }); + account.error(expectedError); + await awaitAsync(); + + expect(actualError).toEqual(expectedError); + }); + + it("completes when the user completes", async () => { + const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService); + const account = new BehaviorSubject(accounts[SomeUser]); + const account$ = account.asObservable(); + + let completed = false; + profileProvider.constraints$(SomeProfile, { account$ }).subscribe({ + complete: () => { + completed = true; + }, + }); + account.complete(); + await awaitAsync(); + + expect(completed).toBeTruthy(); + }); + }); +}); diff --git a/libs/tools/generator/core/src/services/generator-profile-provider.ts b/libs/tools/generator/core/src/services/generator-profile-provider.ts new file mode 100644 index 00000000000..24835e948fd --- /dev/null +++ b/libs/tools/generator/core/src/services/generator-profile-provider.ts @@ -0,0 +1,114 @@ +import { + distinctUntilChanged, + map, + Observable, + switchMap, + takeUntil, + shareReplay, + tap, + of, +} from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BoundDependency } from "@bitwarden/common/tools/dependencies"; +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { anyComplete } from "@bitwarden/common/tools/rx"; +import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; +import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; + +import { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "../metadata"; +import { GeneratorConstraints } from "../types/generator-constraints"; + +/** Surfaces contextual information to credential generators */ +export class GeneratorProfileProvider { + /** Instantiates the context provider + * @param providers dependency injectors for user state subjects + * @param policyService settings constraint lookups + */ + constructor( + private readonly providers: UserStateSubjectDependencyProvider, + private readonly policyService: PolicyService, + ) { + this.log = providers.log({ type: "GeneratorProfileProvider" }); + } + + private readonly log: SemanticLogger; + + /** Get a subject bound to a specific user's settings for the provided profile. + * @param profile determines which profile's settings are loaded + * @param dependencies.singleUserId$ identifies the user to which the settings are bound + * @returns an observable that emits the subject once `dependencies.singleUserId$` becomes + * available and then completes. + * @remarks the subject tracks and enforces policy on the settings it contains. + * It completes when `dependencies.singleUserId$` competes or the user's encryption key + * becomes unavailable. + */ + settings( + profile: Readonly>, + dependencies: BoundDependency<"account", Account>, + ): UserStateSubject { + const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true })); + const constraints$ = this.constraints$(profile, { account$ }); + const subject = new UserStateSubject(profile.storage, this.providers, { + constraints$, + account$, + }); + + return subject; + } + + /** Get the policy constraints for the provided profile + * @param dependencies.account$ constraints are loaded from this account. + * If the account's email is verified, it is passed to the constraints + * @returns an observable that emits the policy once `dependencies.userId$` + * and the policy become available. + */ + constraints$( + profile: Readonly>, + dependencies: BoundDependency<"account", Account>, + ): Observable> { + const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true })); + + const constraints$ = account$.pipe( + distinctUntilChanged((prev, next) => { + return prev.email === next.email && prev.emailVerified === next.emailVerified; + }), + switchMap((account) => { + this.log.debug( + { + accountId: account.id, + profileType: profile.type, + policyType: profile.constraints.type ?? "N/A", + defaultConstraints: profile.constraints.default as object, + }, + "initializing constraints$", + ); + + const policies$ = profile.constraints.type + ? this.policyService.getAll$(profile.constraints.type, account.id) + : of([]); + + const context: ProfileContext = { + defaultConstraints: profile.constraints.default, + }; + if (account.emailVerified) { + this.log.debug({ email: account.email }, "verified email detected; including in context"); + context.email = account.email; + } + + const constraints$ = policies$.pipe( + map((policies) => profile.constraints.create(policies, context)), + tap(() => this.log.debug("constraints created")), + ); + + return constraints$; + }), + // complete policy emissions otherwise `switchMap` holds `constraints$` + // open indefinitely + takeUntil(anyComplete(account$)), + ); + + return constraints$; + } +} diff --git a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.html b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.html new file mode 100644 index 00000000000..d3f3ebedf49 --- /dev/null +++ b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.html @@ -0,0 +1,35 @@ + + + {{ headerText }} + + + + + + + + + +
+ +
+
+
diff --git a/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts new file mode 100644 index 00000000000..fd15c87920f --- /dev/null +++ b/libs/tools/send/send-ui/src/add-edit/send-add-edit-dialog.component.ts @@ -0,0 +1,179 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; +import { FormsModule } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { + AsyncActionsModule, + ButtonModule, + DialogService, + IconButtonModule, + SearchModule, + ToastService, + DialogModule, +} from "@bitwarden/components"; + +import { SendFormConfig, SendFormMode, SendFormModule } from "../send-form"; + +export interface SendItemDialogParams { + /** + * The configuration object for the dialog and form. + */ + formConfig: SendFormConfig; + + /** + * If true, the "edit" button will be disabled in the dialog. + */ + disableForm?: boolean; +} + +export enum SendItemDialogResult { + /** + * A Send was saved (created or updated). + */ + Saved = "saved", + + /** + * A Send was deleted. + */ + Deleted = "deleted", +} + +/** + * Component for adding or editing a send item. + */ +@Component({ + templateUrl: "send-add-edit-dialog.component.html", + standalone: true, + imports: [ + CommonModule, + SearchModule, + JslibModule, + FormsModule, + ButtonModule, + IconButtonModule, + SendFormModule, + AsyncActionsModule, + DialogModule, + ], +}) +export class SendAddEditDialogComponent { + /** + * The header text for the component. + */ + headerText: string; + + /** + * The configuration for the send form. + */ + config: SendFormConfig; + + constructor( + @Inject(DIALOG_DATA) protected params: SendItemDialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + private sendApiService: SendApiService, + private toastService: ToastService, + private dialogService: DialogService, + ) { + this.config = params.formConfig; + this.headerText = this.getHeaderText(this.config.mode, this.config.sendType); + } + + /** + * Handles the event when the send is created. + */ + async onSendCreated(send: SendView) { + // FIXME Add dialogService.open send-created dialog + this.dialogRef.close(SendItemDialogResult.Saved); + return; + } + + /** + * Handles the event when the send is updated. + */ + async onSendUpdated(send: SendView) { + this.dialogRef.close(SendItemDialogResult.Saved); + } + + /** + * Handles the event when the send is deleted. + */ + async onSendDeleted() { + this.dialogRef.close(SendItemDialogResult.Deleted); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("deletedSend"), + }); + } + + /** + * Handles the deletion of the current Send. + */ + deleteSend = async () => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteSend" }, + content: { key: "deleteSendPermanentConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return; + } + + try { + await this.sendApiService.delete(this.config.originalSend?.id); + } catch (e) { + this.toastService.showToast({ + variant: "error", + title: null, + message: e.message, + }); + return; + } + + await this.onSendDeleted(); + }; + + /** + * Gets the header text based on the mode and type. + * @param mode The mode of the send form. + * @param type The type of the send + * @returns The header text. + */ + private getHeaderText(mode: SendFormMode, type: SendType) { + const headerKey = + mode === "edit" || mode === "partial-edit" ? "editItemHeader" : "newItemHeader"; + + switch (type) { + case SendType.Text: + return this.i18nService.t(headerKey, this.i18nService.t("textSend")); + case SendType.File: + return this.i18nService.t(headerKey, this.i18nService.t("fileSend")); + } + } + + /** + * Opens the send add/edit dialog. + * @param dialogService Instance of the DialogService. + * @param params The parameters for the dialog. + * @returns The dialog result. + */ + static open(dialogService: DialogService, params: SendItemDialogParams) { + return dialogService.open( + SendAddEditDialogComponent, + { + data: params, + }, + ); + } +} diff --git a/libs/tools/send/send-ui/src/index.ts b/libs/tools/send/send-ui/src/index.ts index d208709c36d..db4416a13f0 100644 --- a/libs/tools/send/send-ui/src/index.ts +++ b/libs/tools/send/send-ui/src/index.ts @@ -1,6 +1,7 @@ export * from "./icons"; export * from "./send-form"; export { NewSendDropdownComponent } from "./new-send-dropdown/new-send-dropdown.component"; +export * from "./add-edit/send-add-edit-dialog.component"; export { SendListItemsContainerComponent } from "./send-list-items-container/send-list-items-container.component"; export { SendItemsService } from "./services/send-items.service"; export { SendSearchComponent } from "./send-search/send-search.component"; diff --git a/libs/tools/send/send-ui/src/send-search/send-search.component.html b/libs/tools/send/send-ui/src/send-search/send-search.component.html index 898d93da32c..7cf154c0ee8 100644 --- a/libs/tools/send/send-ui/src/send-search/send-search.component.html +++ b/libs/tools/send/send-ui/src/send-search/send-search.component.html @@ -1,9 +1,7 @@ -
- - -
+ + diff --git a/libs/tools/send/send-ui/src/send-search/send-search.component.ts b/libs/tools/send/send-ui/src/send-search/send-search.component.ts index 3d7d7d6285f..8142ce58f64 100644 --- a/libs/tools/send/send-ui/src/send-search/send-search.component.ts +++ b/libs/tools/send/send-ui/src/send-search/send-search.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @@ -20,7 +18,7 @@ const SearchTextDebounceInterval = 200; templateUrl: "send-search.component.html", }) export class SendSearchComponent { - searchText: string; + searchText: string = ""; private searchText$ = new Subject(); diff --git a/libs/vault/src/abstractions/change-login-password.service.ts b/libs/vault/src/abstractions/change-login-password.service.ts index c89162d42be..b8981282f4b 100644 --- a/libs/vault/src/abstractions/change-login-password.service.ts +++ b/libs/vault/src/abstractions/change-login-password.service.ts @@ -4,6 +4,8 @@ export abstract class ChangeLoginPasswordService { /** * Attempts to find a well-known change password URL for the given cipher. Only works for Login ciphers with at * least one http/https URL. If no well-known change password URL is found, the first URL is returned. + * Checks each URL until the first reliable one well-known URL is found, otherwise returns the first URL. + * * Non-Login ciphers and Logins with no valid http/https URLs return null. */ abstract getChangePasswordUrl(cipher: CipherView): Promise; diff --git a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts index 2d539b7ba3a..b9e5ed3c0ab 100644 --- a/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-generator/cipher-form-generator.component.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { CommonModule } from "@angular/common"; import { Component, EventEmitter, Input, Output } from "@angular/core"; @@ -18,9 +16,6 @@ import { AlgorithmInfo, GeneratedCredential } from "@bitwarden/generator-core"; imports: [CommonModule, GeneratorModule], }) export class CipherFormGeneratorComponent { - @Input() - onAlgorithmSelected: (selected: AlgorithmInfo) => void; - @Input() uri: string = ""; @@ -28,17 +23,25 @@ export class CipherFormGeneratorComponent { * The type of generator form to show. */ @Input({ required: true }) - type: "password" | "username"; + type: "password" | "username" = "password"; /** Removes bottom margin of internal sections */ @Input({ transform: coerceBooleanProperty }) disableMargin = false; + @Output() + algorithmSelected = new EventEmitter(); + /** * Emits an event when a new value is generated. */ @Output() valueGenerated = new EventEmitter(); + /** Event handler for when an algorithm is selected */ + onAlgorithmSelected = (selected: AlgorithmInfo) => { + this.algorithmSelected.emit(selected); + }; + /** Event handler for both generation components */ onCredentialGenerated = (generatedCred: GeneratedCredential) => { this.valueGenerated.emit(generatedCred.credential); diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html index f0ebeecdf40..def98b2fe96 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.html +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -3,6 +3,19 @@ {{ "cardExpiredMessage" | i18n }} + + + + + {{ "changeAtRiskPassword" | i18n }} + + + +

- + a?.id)); + activeUserId$ = getUserId(this.accountService.activeAccount$); /** * Optional list of collections the cipher is assigned to. If none are provided, they will be fetched using the @@ -68,12 +75,18 @@ export class CipherViewComponent implements OnChanges, OnDestroy { folder$: Observable | undefined; private destroyed$: Subject = new Subject(); cardIsExpired: boolean = false; + hadPendingChangePasswordTask: boolean = false; + isSecurityTasksEnabled$ = this.configService.getFeatureFlag$(FeatureFlag.SecurityTasks); constructor( private organizationService: OrganizationService, private collectionService: CollectionService, private folderService: FolderService, private accountService: AccountService, + private defaultTaskService: TaskService, + private platformUtilsService: PlatformUtilsService, + private changeLoginPasswordService: ChangeLoginPasswordService, + private configService: ConfigService, ) {} async ngOnChanges() { @@ -137,7 +150,11 @@ export class CipherViewComponent implements OnChanges, OnDestroy { ); } - const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + const userId = await firstValueFrom(this.activeUserId$); + + if (this.cipher.edit && this.cipher.viewPassword) { + await this.checkPendingChangePasswordTasks(userId); + } if (this.cipher.organizationId && userId) { this.organization$ = this.organizationService @@ -147,15 +164,29 @@ export class CipherViewComponent implements OnChanges, OnDestroy { } if (this.cipher.folderId) { - const activeUserId = await firstValueFrom(this.activeUserId$); - - if (!activeUserId) { - return; - } - this.folder$ = this.folderService - .getDecrypted$(this.cipher.folderId, activeUserId) + .getDecrypted$(this.cipher.folderId, userId) .pipe(takeUntil(this.destroyed$)); } } + + async checkPendingChangePasswordTasks(userId: UserId): Promise { + const tasks = await firstValueFrom(this.defaultTaskService.pendingTasks$(userId)); + + this.hadPendingChangePasswordTask = tasks?.some((task) => { + return ( + task.cipherId === this.cipher?.id && task.type === SecurityTaskType.UpdateAtRiskCredential + ); + }); + } + + launchChangePassword = async () => { + if (this.cipher != null) { + const url = await this.changeLoginPasswordService.getChangePasswordUrl(this.cipher); + if (url == null) { + return; + } + this.platformUtilsService.launchUri(url); + } + }; } diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html index 8503604bf7c..6de6fb6848d 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.html @@ -89,6 +89,12 @@ (click)="logCopyEvent()" > + + + {{ "changeAtRiskPassword" | i18n }} + + +

{ { provide: PlatformUtilsService, useValue: mock() }, { provide: ToastService, useValue: mock() }, { provide: I18nService, useValue: { t: (...keys: string[]) => keys.join(" ") } }, + { provide: ConfigService, useValue: mock() }, ], }).compileComponents(); diff --git a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts index c95b2040fd2..27d81f32ee6 100644 --- a/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts +++ b/libs/vault/src/cipher-view/login-credentials/login-credentials-view.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule, DatePipe } from "@angular/common"; -import { Component, inject, Input } from "@angular/core"; +import { Component, EventEmitter, inject, Input, Output } from "@angular/core"; import { Observable, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -10,6 +10,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { @@ -17,6 +18,7 @@ import { SectionComponent, SectionHeaderComponent, TypographyModule, + LinkModule, IconButtonModule, BadgeModule, ColorPasswordModule, @@ -46,10 +48,14 @@ type TotpCodeValues = { ColorPasswordModule, BitTotpCountdownComponent, ReadOnlyCipherCardComponent, + LinkModule, ], }) export class LoginCredentialsViewComponent { @Input() cipher: CipherView; + @Input() activeUserId: UserId; + @Input() hadPendingChangePasswordTask: boolean; + @Output() handleChangePassword = new EventEmitter(); isPremium$: Observable = this.accountService.activeAccount$.pipe( switchMap((account) => @@ -59,6 +65,7 @@ export class LoginCredentialsViewComponent { showPasswordCount: boolean = false; passwordRevealed: boolean = false; totpCodeCopyObj: TotpCodeValues; + private datePipe = inject(DatePipe); constructor( @@ -111,4 +118,8 @@ export class LoginCredentialsViewComponent { this.cipher.organizationId, ); } + + launchChangePasswordEvent(): void { + this.handleChangePassword.emit(); + } } diff --git a/libs/vault/src/services/default-change-login-password.service.spec.ts b/libs/vault/src/services/default-change-login-password.service.spec.ts index 4805f298797..37123604e9a 100644 --- a/libs/vault/src/services/default-change-login-password.service.spec.ts +++ b/libs/vault/src/services/default-change-login-password.service.spec.ts @@ -131,13 +131,13 @@ describe("DefaultChangeLoginPasswordService", () => { const cipher = { type: CipherType.Login, login: Object.assign(new LoginView(), { - uris: [{ uri: "https://example.com" }], + uris: [{ uri: "https://example.com/" }], }), } as CipherView; const url = await service.getChangePasswordUrl(cipher); - expect(url).toBe("https://example.com"); + expect(url).toBe("https://example.com/"); }); it("should return the original URI when the well-known URL is not found", async () => { @@ -146,12 +146,42 @@ describe("DefaultChangeLoginPasswordService", () => { const cipher = { type: CipherType.Login, login: Object.assign(new LoginView(), { - uris: [{ uri: "https://example.com" }], + uris: [{ uri: "https://example.com/" }], }), } as CipherView; const url = await service.getChangePasswordUrl(cipher); - expect(url).toBe("https://example.com"); + expect(url).toBe("https://example.com/"); + }); + + it("should try the next URI if the first one fails", async () => { + mockApiService.nativeFetch.mockImplementation((request) => { + if ( + request.url.endsWith("resource-that-should-not-exist-whose-status-code-should-not-be-200") + ) { + return Promise.resolve(mockShouldNotExistResponse); + } + + if (request.url.endsWith(".well-known/change-password")) { + if (request.url.includes("working.com")) { + return Promise.resolve(mockWellKnownResponse); + } + return Promise.resolve(new Response("Not Found", { status: 404 })); + } + + throw new Error("Unexpected request"); + }); + + const cipher = { + type: CipherType.Login, + login: Object.assign(new LoginView(), { + uris: [{ uri: "https://no-wellknown.com/" }, { uri: "https://working.com/" }], + }), + } as CipherView; + + const url = await service.getChangePasswordUrl(cipher); + + expect(url).toBe("https://working.com/.well-known/change-password"); }); }); diff --git a/libs/vault/src/services/default-change-login-password.service.ts b/libs/vault/src/services/default-change-login-password.service.ts index 25648318c14..29818f95c0a 100644 --- a/libs/vault/src/services/default-change-login-password.service.ts +++ b/libs/vault/src/services/default-change-login-password.service.ts @@ -20,25 +20,31 @@ export class DefaultChangeLoginPasswordService implements ChangeLoginPasswordSer return null; } - // Find the first valid URL that is an HTTP or HTTPS URL - const url = cipher.login.uris + // Filter for valid URLs that are HTTP(S) + const urls = cipher.login.uris .map((m) => Utils.getUrl(m.uri)) - .find((m) => m != null && (m.protocol === "http:" || m.protocol === "https:")); + .filter((m) => m != null && (m.protocol === "http:" || m.protocol === "https:")); - if (url == null) { + if (urls.length === 0) { return null; } - const [reliable, wellKnownChangeUrl] = await Promise.all([ - this.hasReliableHttpStatusCode(url.origin), - this.getWellKnownChangePasswordUrl(url.origin), - ]); + for (const url of urls) { + const [reliable, wellKnownChangeUrl] = await Promise.all([ + this.hasReliableHttpStatusCode(url.origin), + this.getWellKnownChangePasswordUrl(url.origin), + ]); - if (!reliable || wellKnownChangeUrl == null) { - return cipher.login.uri; + // Some servers return a 200 OK for a resource that should not exist + // Which means we cannot trust the well-known URL is valid, so we skip it + // to avoid potentially sending users to a 404 page + if (reliable && wellKnownChangeUrl != null) { + return wellKnownChangeUrl; + } } - return wellKnownChangeUrl; + // No reliable well-known URL found, fallback to the first URL + return urls[0].href; } /** diff --git a/package-lock.json b/package-lock.json index ab526f2730b..142a4e13c21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -230,7 +230,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2025.2.2", + "version": "2025.2.1", "hasInstallScript": true, "license": "GPL-3.0" },