diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 5203edf0a44..ec0fac137df 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -604,6 +604,15 @@ "yourVaultIsLocked": { "message": "Your vault is locked. Verify your identity to continue." }, + "yourVaultIsLockedV2": { + "message": "Your vault is locked" + }, + "yourAccountIsLocked": { + "message": "Your account is locked" + }, + "or": { + "message": "or" + }, "unlock": { "message": "Unlock" }, @@ -1936,6 +1945,9 @@ "unlockWithBiometrics": { "message": "Unlock with biometrics" }, + "unlockWithMasterPassword": { + "message": "Unlock with master password" + }, "awaitDesktop": { "message": "Awaiting confirmation from desktop" }, @@ -3623,6 +3635,9 @@ "typePasskey": { "message": "Passkey" }, + "accessing": { + "message": "Accessing" + }, "passkeyNotCopied": { "message": "Passkey will not be copied" }, diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.ts b/apps/browser/src/auth/popup/account-switching/current-account.component.ts index 6c7c1e7d92f..12210b2b452 100644 --- a/apps/browser/src/auth/popup/account-switching/current-account.component.ts +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.ts @@ -59,7 +59,7 @@ export class CurrentAccountComponent { } async currentAccountClicked() { - if (this.route.snapshot.data.state.includes("account-switcher")) { + if (this.route.snapshot.data?.state?.includes("account-switcher")) { this.location.back(); } else { await this.router.navigate(["/account-switcher"]); diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index d540ea39edc..9fd52470c0a 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -17,6 +17,8 @@ import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, + LockIcon, + LockV2Component, PasswordHintComponent, RegistrationFinishComponent, RegistrationStartComponent, @@ -181,6 +183,7 @@ const routes: Routes = [ path: "lock", component: LockComponent, canActivate: [lockGuard()], + canMatch: [extensionRefreshRedirect("/lockV2")], data: { state: "lock", doNotSaveUrl: true } satisfies RouteDataProperties, }, ...twofactorRefactorSwap( @@ -438,6 +441,28 @@ const routes: Routes = [ ], }, ), + { + path: "", + component: ExtensionAnonLayoutWrapperComponent, + children: [ + { + path: "lockV2", + canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh), lockGuard()], + data: { + pageIcon: LockIcon, + pageTitle: "yourVaultIsLockedV2", + showReadonlyHostname: true, + showAcctSwitcher: true, + } satisfies ExtensionAnonLayoutWrapperData, + children: [ + { + path: "", + component: LockV2Component, + }, + ], + }, + ], + }, { path: "", component: AnonLayoutWrapperComponent, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 483bf86712a..024b4f46315 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -16,7 +16,7 @@ import { CLIENT_TYPE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; -import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular"; +import { AnonLayoutWrapperDataService, LockComponentService } from "@bitwarden/auth/angular"; import { LockService, PinServiceAbstraction } from "@bitwarden/auth/common"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; @@ -117,6 +117,7 @@ import { ForegroundTaskSchedulerService } from "../../platform/services/task-sch import { BrowserStorageServiceProvider } from "../../platform/storage/browser-storage-service.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging"; +import { ExtensionLockComponentService } from "../../services/extension-lock-component.service"; import { ForegroundVaultTimeoutService } from "../../services/vault-timeout/foreground-vault-timeout.service"; import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.service"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; @@ -536,6 +537,11 @@ const safeProviders: SafeProvider[] = [ provide: CLIENT_TYPE, useValue: ClientType.Browser, }), + safeProvider({ + provide: LockComponentService, + useClass: ExtensionLockComponentService, + deps: [], + }), safeProvider({ provide: Fido2UserVerificationService, useClass: Fido2UserVerificationService, diff --git a/apps/browser/src/services/extension-lock-component.service.spec.ts b/apps/browser/src/services/extension-lock-component.service.spec.ts new file mode 100644 index 00000000000..f537897cf8d --- /dev/null +++ b/apps/browser/src/services/extension-lock-component.service.spec.ts @@ -0,0 +1,325 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/auth/angular"; +import { + PinServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BiometricsService } from "@bitwarden/key-management"; + +import { BrowserRouterService } from "../platform/popup/services/browser-router.service"; + +import { ExtensionLockComponentService } from "./extension-lock-component.service"; + +describe("ExtensionLockComponentService", () => { + let service: ExtensionLockComponentService; + + let userDecryptionOptionsService: MockProxy; + let platformUtilsService: MockProxy; + let biometricsService: MockProxy; + let pinService: MockProxy; + let vaultTimeoutSettingsService: MockProxy; + let cryptoService: MockProxy; + let routerService: MockProxy; + + beforeEach(() => { + userDecryptionOptionsService = mock(); + platformUtilsService = mock(); + biometricsService = mock(); + pinService = mock(); + vaultTimeoutSettingsService = mock(); + cryptoService = mock(); + routerService = mock(); + + TestBed.configureTestingModule({ + providers: [ + ExtensionLockComponentService, + { + provide: UserDecryptionOptionsServiceAbstraction, + useValue: userDecryptionOptionsService, + }, + { + provide: PlatformUtilsService, + useValue: platformUtilsService, + }, + { + provide: BiometricsService, + useValue: biometricsService, + }, + { + provide: PinServiceAbstraction, + useValue: pinService, + }, + { + provide: VaultTimeoutSettingsService, + useValue: vaultTimeoutSettingsService, + }, + { + provide: CryptoService, + useValue: cryptoService, + }, + { + provide: BrowserRouterService, + useValue: routerService, + }, + ], + }); + + service = TestBed.inject(ExtensionLockComponentService); + }); + + it("instantiates", () => { + expect(service).not.toBeFalsy(); + }); + + describe("getPreviousUrl", () => { + it("returns the previous URL", () => { + routerService.getPreviousUrl.mockReturnValue("previousUrl"); + expect(service.getPreviousUrl()).toBe("previousUrl"); + }); + }); + + describe("getBiometricsError", () => { + it("returns a biometric error description when given a valid error type", () => { + expect( + service.getBiometricsError({ + message: "startDesktop", + }), + ).toBe("startDesktopDesc"); + }); + + it("returns null when given an invalid error type", () => { + expect( + service.getBiometricsError({ + message: "invalidError", + }), + ).toBeNull(); + }); + + it("returns null when given a null input", () => { + expect(service.getBiometricsError(null)).toBeNull(); + }); + }); + + describe("isWindowVisible", () => { + it("throws an error", async () => { + await expect(service.isWindowVisible()).rejects.toThrow("Method not implemented."); + }); + }); + + describe("getBiometricsUnlockBtnText", () => { + it("returns the biometric unlock button text", () => { + expect(service.getBiometricsUnlockBtnText()).toBe("unlockWithBiometrics"); + }); + }); + + describe("getAvailableUnlockOptions$", () => { + interface MockInputs { + hasMasterPassword: boolean; + osSupportsBiometric: boolean; + biometricLockSet: boolean; + hasBiometricEncryptedUserKeyStored: boolean; + platformSupportsSecureStorage: boolean; + pinDecryptionAvailable: boolean; + } + + const table: [MockInputs, UnlockOptions][] = [ + [ + // MP + PIN + Biometrics available + { + hasMasterPassword: true, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: true, + }, + { + masterPassword: { + enabled: true, + }, + pin: { + enabled: true, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // PIN + Biometrics available + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: true, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: true, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics available: user key stored with no secure storage + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + platformSupportsSecureStorage: false, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics available: no user key stored with no secure storage + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: false, + platformSupportsSecureStorage: false, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics not available: biometric lock not set + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: false, + hasBiometricEncryptedUserKeyStored: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.EncryptedKeysUnavailable, + }, + }, + ], + [ + // Biometrics not available: user key not stored + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: false, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.EncryptedKeysUnavailable, + }, + }, + ], + [ + // Biometrics not available: OS doesn't support + { + hasMasterPassword: false, + osSupportsBiometric: false, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem, + }, + }, + ], + ]; + + test.each(table)("returns unlock options", async (mockInputs, expectedOutput) => { + const userId = "userId" as UserId; + const userDecryptionOptions = { + hasMasterPassword: mockInputs.hasMasterPassword, + }; + + // MP + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of(userDecryptionOptions), + ); + + // Biometrics + biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric); + vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet); + cryptoService.hasUserKeyStored.mockResolvedValue( + mockInputs.hasBiometricEncryptedUserKeyStored, + ); + platformUtilsService.supportsSecureStorage.mockReturnValue( + mockInputs.platformSupportsSecureStorage, + ); + + // PIN + pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable); + + const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); + + expect(unlockOptions).toEqual(expectedOutput); + }); + }); +}); diff --git a/apps/browser/src/services/extension-lock-component.service.ts b/apps/browser/src/services/extension-lock-component.service.ts new file mode 100644 index 00000000000..58514fa2b17 --- /dev/null +++ b/apps/browser/src/services/extension-lock-component.service.ts @@ -0,0 +1,117 @@ +import { inject } from "@angular/core"; +import { combineLatest, defer, map, Observable } from "rxjs"; + +import { + BiometricsDisableReason, + LockComponentService, + UnlockOptions, +} from "@bitwarden/auth/angular"; +import { + PinServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BiometricsService } from "@bitwarden/key-management"; + +import { BiometricErrors, BiometricErrorTypes } from "../models/biometricErrors"; +import { BrowserRouterService } from "../platform/popup/services/browser-router.service"; + +export class ExtensionLockComponentService implements LockComponentService { + private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); + private readonly platformUtilsService = inject(PlatformUtilsService); + private readonly biometricsService = inject(BiometricsService); + private readonly pinService = inject(PinServiceAbstraction); + private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService); + private readonly cryptoService = inject(CryptoService); + private readonly routerService = inject(BrowserRouterService); + + getPreviousUrl(): string | null { + return this.routerService.getPreviousUrl(); + } + + getBiometricsError(error: any): string | null { + const biometricsError = BiometricErrors[error?.message as BiometricErrorTypes]; + + if (!biometricsError) { + return null; + } + + return biometricsError.description; + } + + async isWindowVisible(): Promise { + throw new Error("Method not implemented."); + } + + getBiometricsUnlockBtnText(): string { + return "unlockWithBiometrics"; + } + + private async isBiometricLockSet(userId: UserId): Promise { + const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId); + const hasBiometricEncryptedUserKeyStored = await this.cryptoService.hasUserKeyStored( + KeySuffixOptions.Biometric, + userId, + ); + const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage(); + + return ( + biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage) + ); + } + + private getBiometricsDisabledReason( + osSupportsBiometric: boolean, + biometricLockSet: boolean, + ): BiometricsDisableReason | null { + if (!osSupportsBiometric) { + return BiometricsDisableReason.NotSupportedOnOperatingSystem; + } else if (!biometricLockSet) { + return BiometricsDisableReason.EncryptedKeysUnavailable; + } + + return null; + } + + getAvailableUnlockOptions$(userId: UserId): Observable { + return combineLatest([ + // Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to + defer(() => this.biometricsService.supportsBiometric()), + defer(() => this.isBiometricLockSet(userId)), + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + defer(() => this.pinService.isPinDecryptionAvailable(userId)), + ]).pipe( + map( + ([ + supportsBiometric, + isBiometricsLockSet, + userDecryptionOptions, + pinDecryptionAvailable, + ]) => { + const disableReason = this.getBiometricsDisabledReason( + supportsBiometric, + isBiometricsLockSet, + ); + + const unlockOpts: UnlockOptions = { + masterPassword: { + enabled: userDecryptionOptions.hasMasterPassword, + }, + pin: { + enabled: pinDecryptionAvailable, + }, + biometrics: { + enabled: supportsBiometric && isBiometricsLockSet, + disableReason: disableReason, + }, + }; + return unlockOpts; + }, + ), + ); + } +} diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html index b3783bfed3a..40c942539f6 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html @@ -4,7 +4,8 @@ diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts index c84b9717df1..20b472f97f3 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.ts @@ -2,12 +2,13 @@ import { CommonModule, Location } from "@angular/common"; import { Component } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; -import { ActivatedRoute, Params } from "@angular/router"; +import { ActivatedRoute, Params, Router } from "@angular/router"; import { map, switchMap } from "rxjs"; 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 { SendId } from "@bitwarden/common/types/guid"; import { @@ -95,14 +96,25 @@ export class SendAddEditComponent { private sendApiService: SendApiService, private toastService: ToastService, private dialogService: DialogService, + private router: Router, ) { this.subscribeToParams(); } /** - * Handles the event when the send is saved. + * Handles the event when the send is created. */ - onSendSaved() { + async onSendCreated(send: SendView) { + await this.router.navigate(["/send-created"], { + queryParams: { sendId: send.id }, + }); + return; + } + + /** + * Handles the event when the send is updated. + */ + onSendUpdated(send: SendView) { this.location.back(); } diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html index 9b56fa74d91..c97d3da1396 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.html @@ -1,6 +1,11 @@
- + diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts index 413f22565e1..bcc4d2e2ccb 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.spec.ts @@ -1,6 +1,6 @@ import { CommonModule, Location } from "@angular/common"; import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ActivatedRoute, RouterLink } from "@angular/router"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; import { RouterTestingModule } from "@angular/router/testing"; import { MockProxy, mock } from "jest-mock-extended"; import { of } from "rxjs"; @@ -33,6 +33,7 @@ describe("SendCreatedComponent", () => { let location: MockProxy; let activatedRoute: MockProxy; let environmentService: MockProxy; + let router: MockProxy; const sendId = "test-send-id"; const deletionDate = new Date(); @@ -52,6 +53,7 @@ describe("SendCreatedComponent", () => { location = mock(); activatedRoute = mock(); environmentService = mock(); + router = mock(); Object.defineProperty(environmentService, "environment$", { configurable: true, get: () => of(new SelfHostedEnvironment({ webVault: "https://example.com" })), @@ -89,6 +91,7 @@ describe("SendCreatedComponent", () => { { provide: ConfigService, useValue: mock() }, { provide: EnvironmentService, useValue: environmentService }, { provide: PopupRouterCacheService, useValue: mock() }, + { provide: Router, useValue: router }, ], }).compileComponents(); }); @@ -109,10 +112,10 @@ describe("SendCreatedComponent", () => { expect(component["daysAvailable"]).toBe(7); }); - it("should navigate back on close", () => { + it("should navigate back to send list on close", async () => { fixture.detectChanges(); - component.close(); - expect(location.back).toHaveBeenCalled(); + await component.close(); + expect(router.navigate).toHaveBeenCalledWith(["/tabs/send"]); }); describe("getDaysAvailable", () => { diff --git a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts index 92339774d05..4ed4da2f81d 100644 --- a/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-created/send-created.component.ts @@ -1,7 +1,7 @@ -import { CommonModule, Location } from "@angular/common"; +import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, RouterLink } from "@angular/router"; +import { ActivatedRoute, Router, RouterLink, RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -30,6 +30,7 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page PopupHeaderComponent, PopupPageComponent, RouterLink, + RouterModule, PopupFooterComponent, IconModule, ], @@ -45,10 +46,11 @@ export class SendCreatedComponent { private sendService: SendService, private route: ActivatedRoute, private toastService: ToastService, - private location: Location, + private router: Router, private environmentService: EnvironmentService, ) { const sendId = this.route.snapshot.queryParamMap.get("sendId"); + this.sendService.sendViews$.pipe(takeUntilDestroyed()).subscribe((sendViews) => { this.send = sendViews.find((s) => s.id === sendId); if (this.send) { @@ -62,8 +64,8 @@ export class SendCreatedComponent { return Math.max(0, Math.ceil((send.deletionDate.getTime() - now) / (1000 * 60 * 60 * 24))); } - close() { - this.location.back(); + async close() { + await this.router.navigate(["/tabs/send"]); } async copyLink() { diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 1e13be12a73..86a39163f3a 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -11,9 +11,12 @@ import { unauthGuardFn, } from "@bitwarden/angular/auth/guards"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; +import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, + LockIcon, + LockV2Component, PasswordHintComponent, RegistrationFinishComponent, RegistrationStartComponent, @@ -62,6 +65,7 @@ const routes: Routes = [ path: "lock", component: LockComponent, canActivate: [lockGuard()], + canMatch: [extensionRefreshRedirect("/lockV2")], }, { path: "login", @@ -190,6 +194,21 @@ const routes: Routes = [ }, ], }, + { + path: "lockV2", + canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh), lockGuard()], + data: { + pageIcon: LockIcon, + pageTitle: "yourVaultIsLockedV2", + showReadonlyHostname: true, + } satisfies AnonLayoutWrapperData, + children: [ + { + path: "", + component: LockV2Component, + }, + ], + }, { path: "set-password-jit", canActivate: [canAccessFeature(FeatureFlag.EmailVerification)], diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index d3d41d277b6..c6b73fbbbca 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -19,7 +19,7 @@ import { CLIENT_TYPE, } from "@bitwarden/angular/services/injection-tokens"; import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module"; -import { SetPasswordJitService } from "@bitwarden/auth/angular"; +import { LockComponentService, SetPasswordJitService } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, PinServiceAbstraction, @@ -86,6 +86,7 @@ import { ElectronRendererStorageService } from "../../platform/services/electron import { I18nRendererService } from "../../platform/services/i18n.renderer.service"; import { fromIpcMessaging } from "../../platform/utils/from-ipc-messaging"; import { fromIpcSystemTheme } from "../../platform/utils/from-ipc-system-theme"; +import { DesktopLockComponentService } from "../../services/desktop-lock-component.service"; import { EncryptedMessageHandlerService } from "../../services/encrypted-message-handler.service"; import { NativeMessageHandlerService } from "../../services/native-message-handler.service"; import { NativeMessagingService } from "../../services/native-messaging.service"; @@ -277,6 +278,11 @@ const safeProviders: SafeProvider[] = [ useClass: NativeMessagingManifestService, deps: [], }), + safeProvider({ + provide: LockComponentService, + useClass: DesktopLockComponentService, + deps: [], + }), safeProvider({ provide: CLIENT_TYPE, useValue: ClientType.Desktop, diff --git a/apps/desktop/src/auth/login/login.component.html b/apps/desktop/src/auth/login/login.component.html index 8a8611ad03d..efec43ffa86 100644 --- a/apps/desktop/src/auth/login/login.component.html +++ b/apps/desktop/src/auth/login/login.component.html @@ -8,7 +8,9 @@ attr.aria-hidden="{{ showingModal }}" >
- Bitwarden + + Bitwarden +

{{ "loginOrCreateNewAccount" | i18n }}

diff --git a/apps/desktop/src/auth/login/login.component.ts b/apps/desktop/src/auth/login/login.component.ts index 2b5910baa96..b43e5bc84f0 100644 --- a/apps/desktop/src/auth/login/login.component.ts +++ b/apps/desktop/src/auth/login/login.component.ts @@ -227,4 +227,11 @@ export class LoginComponent extends BaseLoginComponent implements OnInit, OnDest ); } } + + /** + * Force the validatedEmail flag to false, which will show the login page. + */ + invalidateEmail() { + this.validatedEmail = false; + } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 9504ecb1fa3..0b7a9c678c9 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -918,6 +918,18 @@ "yourVaultIsLocked": { "message": "Your vault is locked. Verify your identity to continue." }, + "yourAccountIsLocked": { + "message": "Your account is locked" + }, + "or": { + "message": "or" + }, + "unlockWithBiometrics": { + "message": "Unlock with biometrics" + }, + "unlockWithMasterPassword": { + "message": "Unlock with master password" + }, "unlock": { "message": "Unlock" }, @@ -2256,6 +2268,9 @@ "locked": { "message": "Locked" }, + "yourVaultIsLockedV2": { + "message": "Your vault is locked" + }, "unlocked": { "message": "Unlocked" }, @@ -2608,6 +2623,9 @@ "important": { "message": "Important:" }, + "accessing": { + "message": "Accessing" + }, "accessTokenUnableToBeDecrypted": { "message": "You have been logged out because your access token could not be decrypted. Please log in again to resolve this issue." }, diff --git a/apps/desktop/src/services/desktop-lock-component.service.spec.ts b/apps/desktop/src/services/desktop-lock-component.service.spec.ts new file mode 100644 index 00000000000..ff1f8328ea3 --- /dev/null +++ b/apps/desktop/src/services/desktop-lock-component.service.spec.ts @@ -0,0 +1,377 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/auth/angular"; +import { + PinServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { DeviceType } from "@bitwarden/common/enums"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BiometricsService } from "@bitwarden/key-management"; + +import { DesktopLockComponentService } from "./desktop-lock-component.service"; + +// ipc mock global +const isWindowVisibleMock = jest.fn(); +const biometricEnabledMock = jest.fn(); +(global as any).ipc = { + keyManagement: { + biometric: { + enabled: biometricEnabledMock, + }, + }, + platform: { + isWindowVisible: isWindowVisibleMock, + }, +}; + +describe("DesktopLockComponentService", () => { + let service: DesktopLockComponentService; + + let userDecryptionOptionsService: MockProxy; + let platformUtilsService: MockProxy; + let biometricsService: MockProxy; + let pinService: MockProxy; + let vaultTimeoutSettingsService: MockProxy; + let cryptoService: MockProxy; + + beforeEach(() => { + userDecryptionOptionsService = mock(); + platformUtilsService = mock(); + biometricsService = mock(); + pinService = mock(); + vaultTimeoutSettingsService = mock(); + cryptoService = mock(); + + TestBed.configureTestingModule({ + providers: [ + DesktopLockComponentService, + { + provide: UserDecryptionOptionsServiceAbstraction, + useValue: userDecryptionOptionsService, + }, + { + provide: PlatformUtilsService, + useValue: platformUtilsService, + }, + { + provide: BiometricsService, + useValue: biometricsService, + }, + { + provide: PinServiceAbstraction, + useValue: pinService, + }, + { + provide: VaultTimeoutSettingsService, + useValue: vaultTimeoutSettingsService, + }, + { + provide: CryptoService, + useValue: cryptoService, + }, + ], + }); + + service = TestBed.inject(DesktopLockComponentService); + }); + + it("instantiates", () => { + expect(service).not.toBeFalsy(); + }); + + // getBiometricsError + describe("getBiometricsError", () => { + it("returns null when given null", () => { + const result = service.getBiometricsError(null); + expect(result).toBeNull(); + }); + + it("returns null when given an unknown error", () => { + const result = service.getBiometricsError({ message: "unknown" }); + expect(result).toBeNull(); + }); + }); + + describe("getPreviousUrl", () => { + it("returns null", () => { + const result = service.getPreviousUrl(); + expect(result).toBeNull(); + }); + }); + + describe("isWindowVisible", () => { + it("returns the window visibility", async () => { + isWindowVisibleMock.mockReturnValue(true); + const result = await service.isWindowVisible(); + expect(result).toBe(true); + }); + }); + + describe("getBiometricsUnlockBtnText", () => { + it("returns the correct text for Mac OS", () => { + platformUtilsService.getDevice.mockReturnValue(DeviceType.MacOsDesktop); + const result = service.getBiometricsUnlockBtnText(); + expect(result).toBe("unlockWithTouchId"); + }); + + it("returns the correct text for Windows", () => { + platformUtilsService.getDevice.mockReturnValue(DeviceType.WindowsDesktop); + const result = service.getBiometricsUnlockBtnText(); + expect(result).toBe("unlockWithWindowsHello"); + }); + + it("returns the correct text for Linux", () => { + platformUtilsService.getDevice.mockReturnValue(DeviceType.LinuxDesktop); + const result = service.getBiometricsUnlockBtnText(); + expect(result).toBe("unlockWithPolkit"); + }); + + it("throws an error for an unsupported platform", () => { + platformUtilsService.getDevice.mockReturnValue("unsupported" as any); + expect(() => service.getBiometricsUnlockBtnText()).toThrowError("Unsupported platform"); + }); + }); + + describe("getAvailableUnlockOptions$", () => { + interface MockInputs { + hasMasterPassword: boolean; + osSupportsBiometric: boolean; + biometricLockSet: boolean; + biometricReady: boolean; + hasBiometricEncryptedUserKeyStored: boolean; + platformSupportsSecureStorage: boolean; + pinDecryptionAvailable: boolean; + } + + const table: [MockInputs, UnlockOptions][] = [ + [ + // MP + PIN + Biometrics available + { + hasMasterPassword: true, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: true, + }, + { + masterPassword: { + enabled: true, + }, + pin: { + enabled: true, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // PIN + Biometrics available + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: true, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: true, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics available: user key stored with no secure storage + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: true, + platformSupportsSecureStorage: false, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics available: no user key stored with no secure storage + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: false, + biometricReady: true, + platformSupportsSecureStorage: false, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: true, + disableReason: null, + }, + }, + ], + [ + // Biometrics not available: biometric not ready + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: false, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.SystemBiometricsUnavailable, + }, + }, + ], + [ + // Biometrics not available: biometric lock not set + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: false, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.EncryptedKeysUnavailable, + }, + }, + ], + [ + // Biometrics not available: user key not stored + { + hasMasterPassword: false, + osSupportsBiometric: true, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: false, + biometricReady: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.EncryptedKeysUnavailable, + }, + }, + ], + [ + // Biometrics not available: OS doesn't support + { + hasMasterPassword: false, + osSupportsBiometric: false, + biometricLockSet: true, + hasBiometricEncryptedUserKeyStored: true, + biometricReady: true, + platformSupportsSecureStorage: true, + pinDecryptionAvailable: false, + }, + { + masterPassword: { + enabled: false, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem, + }, + }, + ], + ]; + + test.each(table)("returns unlock options", async (mockInputs, expectedOutput) => { + const userId = "userId" as UserId; + const userDecryptionOptions = { + hasMasterPassword: mockInputs.hasMasterPassword, + }; + + // MP + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue( + of(userDecryptionOptions), + ); + + // Biometrics + biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric); + vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet); + cryptoService.hasUserKeyStored.mockResolvedValue( + mockInputs.hasBiometricEncryptedUserKeyStored, + ); + platformUtilsService.supportsSecureStorage.mockReturnValue( + mockInputs.platformSupportsSecureStorage, + ); + biometricEnabledMock.mockResolvedValue(mockInputs.biometricReady); + + // PIN + pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable); + + const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); + + expect(unlockOptions).toEqual(expectedOutput); + }); + }); +}); diff --git a/apps/desktop/src/services/desktop-lock-component.service.ts b/apps/desktop/src/services/desktop-lock-component.service.ts new file mode 100644 index 00000000000..f31ee93a726 --- /dev/null +++ b/apps/desktop/src/services/desktop-lock-component.service.ts @@ -0,0 +1,129 @@ +import { inject } from "@angular/core"; +import { combineLatest, defer, map, Observable } from "rxjs"; + +import { + BiometricsDisableReason, + LockComponentService, + UnlockOptions, +} from "@bitwarden/auth/angular"; +import { + PinServiceAbstraction, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; +import { DeviceType } from "@bitwarden/common/enums"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { KeySuffixOptions } from "@bitwarden/common/platform/enums"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BiometricsService } from "@bitwarden/key-management"; + +export class DesktopLockComponentService implements LockComponentService { + private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); + private readonly platformUtilsService = inject(PlatformUtilsService); + private readonly biometricsService = inject(BiometricsService); + private readonly pinService = inject(PinServiceAbstraction); + private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService); + private readonly cryptoService = inject(CryptoService); + + constructor() {} + + getBiometricsError(error: any): string | null { + return null; + } + + getPreviousUrl(): string | null { + return null; + } + + async isWindowVisible(): Promise { + return ipc.platform.isWindowVisible(); + } + + getBiometricsUnlockBtnText(): string { + switch (this.platformUtilsService.getDevice()) { + case DeviceType.MacOsDesktop: + return "unlockWithTouchId"; + case DeviceType.WindowsDesktop: + return "unlockWithWindowsHello"; + case DeviceType.LinuxDesktop: + return "unlockWithPolkit"; + default: + throw new Error("Unsupported platform"); + } + } + + private async isBiometricLockSet(userId: UserId): Promise { + const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId); + const hasBiometricEncryptedUserKeyStored = await this.cryptoService.hasUserKeyStored( + KeySuffixOptions.Biometric, + userId, + ); + const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage(); + + return ( + biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage) + ); + } + + private async isBiometricsSupportedAndReady( + userId: UserId, + ): Promise<{ supportsBiometric: boolean; biometricReady: boolean }> { + const supportsBiometric = await this.biometricsService.supportsBiometric(); + const biometricReady = await ipc.keyManagement.biometric.enabled(userId); + return { supportsBiometric, biometricReady }; + } + + getAvailableUnlockOptions$(userId: UserId): Observable { + return combineLatest([ + // Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to + defer(() => this.isBiometricsSupportedAndReady(userId)), + defer(() => this.isBiometricLockSet(userId)), + this.userDecryptionOptionsService.userDecryptionOptionsById$(userId), + defer(() => this.pinService.isPinDecryptionAvailable(userId)), + ]).pipe( + map( + ([biometricsData, isBiometricsLockSet, userDecryptionOptions, pinDecryptionAvailable]) => { + const disableReason = this.getBiometricsDisabledReason( + biometricsData.supportsBiometric, + isBiometricsLockSet, + biometricsData.biometricReady, + ); + + const unlockOpts: UnlockOptions = { + masterPassword: { + enabled: userDecryptionOptions.hasMasterPassword, + }, + pin: { + enabled: pinDecryptionAvailable, + }, + biometrics: { + enabled: + biometricsData.supportsBiometric && + isBiometricsLockSet && + biometricsData.biometricReady, + disableReason: disableReason, + }, + }; + + return unlockOpts; + }, + ), + ); + } + + private getBiometricsDisabledReason( + osSupportsBiometric: boolean, + biometricLockSet: boolean, + biometricReady: boolean, + ): BiometricsDisableReason | null { + if (!osSupportsBiometric) { + return BiometricsDisableReason.NotSupportedOnOperatingSystem; + } else if (!biometricLockSet) { + return BiometricsDisableReason.EncryptedKeysUnavailable; + } else if (!biometricReady) { + return BiometricsDisableReason.SystemBiometricsUnavailable; + } + return null; + } +} diff --git a/apps/web/src/app/admin-console/organizations/core/views/group.view.ts b/apps/web/src/app/admin-console/organizations/core/views/group.view.ts index 1909b9a863c..67ce47c624a 100644 --- a/apps/web/src/app/admin-console/organizations/core/views/group.view.ts +++ b/apps/web/src/app/admin-console/organizations/core/views/group.view.ts @@ -1,9 +1,8 @@ +import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common"; import { View } from "@bitwarden/common/src/models/view/view"; import { GroupDetailsResponse, GroupResponse } from "../services/group/responses/group.response"; -import { CollectionAccessSelectionView } from "./collection-access-selection.view"; - export class GroupView implements View { id: string; organizationId: string; diff --git a/apps/web/src/app/admin-console/organizations/core/views/index.ts b/apps/web/src/app/admin-console/organizations/core/views/index.ts index ef14753c48a..9408d7757c3 100644 --- a/apps/web/src/app/admin-console/organizations/core/views/index.ts +++ b/apps/web/src/app/admin-console/organizations/core/views/index.ts @@ -1,4 +1,3 @@ -export * from "./collection-access-selection.view"; export * from "./group.view"; export * from "./organization-user.view"; export * from "./organization-user-admin-view"; diff --git a/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts b/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts index 97e77d8543c..b9b034b405d 100644 --- a/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts +++ b/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts @@ -1,11 +1,10 @@ +import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common"; import { OrganizationUserStatusType, OrganizationUserType, } from "@bitwarden/common/admin-console/enums"; import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; -import { CollectionAccessSelectionView } from "./collection-access-selection.view"; - export class OrganizationUserAdminView { id: string; userId: string; diff --git a/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts b/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts index 8988f41487c..7d1a10c5332 100644 --- a/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts +++ b/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts @@ -1,12 +1,13 @@ -import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common"; +import { + OrganizationUserUserDetailsResponse, + CollectionAccessSelectionView, +} from "@bitwarden/admin-console/common"; import { OrganizationUserStatusType, OrganizationUserType, } from "@bitwarden/common/admin-console/enums"; import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api"; -import { CollectionAccessSelectionView } from "./collection-access-selection.view"; - export class OrganizationUserView { id: string; userId: string; diff --git a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts index cdbc049111d..643e76e4c38 100644 --- a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts @@ -14,7 +14,11 @@ import { takeUntil, } from "rxjs"; -import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { + CollectionAdminService, + CollectionAdminView, + OrganizationUserApiService, +} from "@bitwarden/admin-console/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -26,8 +30,6 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { UserId } from "@bitwarden/common/types/guid"; import { DialogService, ToastService } from "@bitwarden/components"; -import { CollectionAdminService } from "../../../vault/core/collection-admin.service"; -import { CollectionAdminView } from "../../../vault/core/views/collection-admin.view"; import { InternalGroupService as GroupService, GroupView } from "../core"; import { AccessItemType, diff --git a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts index fb11ad21c4c..aac096189e0 100644 --- a/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/components/member-dialog/member-dialog.component.ts @@ -13,7 +13,12 @@ import { takeUntil, } from "rxjs"; -import { OrganizationUserApiService } from "@bitwarden/admin-console/common"; +import { + CollectionAccessSelectionView, + CollectionAdminService, + CollectionAdminView, + OrganizationUserApiService, +} from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserStatusType, @@ -24,14 +29,10 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { DialogService, ToastService } from "@bitwarden/components"; -import { CollectionAdminService } from "../../../../../vault/core/collection-admin.service"; -import { CollectionAdminView } from "../../../../../vault/core/views/collection-admin.view"; import { - CollectionAccessSelectionView, GroupService, GroupView, OrganizationUserAdminView, @@ -133,7 +134,6 @@ export class MemberDialogComponent implements OnDestroy { @Inject(DIALOG_DATA) protected params: MemberDialogParams, private dialogRef: DialogRef, private i18nService: I18nService, - private platformUtilsService: PlatformUtilsService, private formBuilder: FormBuilder, // TODO: We should really look into consolidating naming conventions for these services private collectionAdminService: CollectionAdminService, diff --git a/apps/web/src/app/admin-console/organizations/settings/org-import.component.ts b/apps/web/src/app/admin-console/organizations/settings/org-import.component.ts index 36cfd4230c7..2c2d700fe80 100644 --- a/apps/web/src/app/admin-console/organizations/settings/org-import.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/org-import.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; +import { CollectionAdminService } from "@bitwarden/admin-console/common"; import { canAccessVaultTab, OrganizationService, @@ -11,7 +12,6 @@ import { ImportComponent } from "@bitwarden/importer/ui"; import { LooseComponentsModule, SharedModule } from "../../../shared"; import { ImportCollectionAdminService } from "../../../tools/import/import-collection-admin.service"; -import { CollectionAdminService } from "../../../vault/core/collection-admin.service"; @Component({ templateUrl: "org-import.component.html", diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts index 429b62ed0cc..1dc20366942 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.models.ts @@ -1,11 +1,14 @@ -import { OrganizationUserUserDetailsResponse } from "@bitwarden/admin-console/common"; +import { + CollectionAccessSelectionView, + OrganizationUserUserDetailsResponse, +} from "@bitwarden/admin-console/common"; import { OrganizationUserStatusType, OrganizationUserType, } from "@bitwarden/common/admin-console/enums"; import { SelectItemView } from "@bitwarden/components"; -import { CollectionAccessSelectionView, GroupView } from "../../../core"; +import { GroupView } from "../../../core"; /** * Permission options that replace/correspond with manage, readOnly, and hidePassword server fields. diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index c85f0f3204c..9e433b87f36 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -1,3 +1,4 @@ export * from "./webauthn-login"; export * from "./set-password-jit"; export * from "./registration"; +export * from "./web-lock-component.service"; diff --git a/apps/web/src/app/auth/core/services/web-lock-component.service.spec.ts b/apps/web/src/app/auth/core/services/web-lock-component.service.spec.ts new file mode 100644 index 00000000000..5eb26a8c76c --- /dev/null +++ b/apps/web/src/app/auth/core/services/web-lock-component.service.spec.ts @@ -0,0 +1,94 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { WebLockComponentService } from "./web-lock-component.service"; + +describe("WebLockComponentService", () => { + let service: WebLockComponentService; + + let userDecryptionOptionsService: MockProxy; + + beforeEach(() => { + userDecryptionOptionsService = mock(); + + TestBed.configureTestingModule({ + providers: [ + WebLockComponentService, + { + provide: UserDecryptionOptionsServiceAbstraction, + useValue: userDecryptionOptionsService, + }, + ], + }); + + service = TestBed.inject(WebLockComponentService); + }); + + it("instantiates", () => { + expect(service).not.toBeFalsy(); + }); + + describe("getBiometricsError", () => { + it("throws an error when given a null input", () => { + expect(() => service.getBiometricsError(null)).toThrow( + "Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$", + ); + }); + it("throws an error when given a non-null input", () => { + expect(() => service.getBiometricsError("error")).toThrow( + "Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$", + ); + }); + }); + + describe("getPreviousUrl", () => { + it("returns null", () => { + expect(service.getPreviousUrl()).toBeNull(); + }); + }); + + describe("isWindowVisible", () => { + it("throws an error", async () => { + await expect(service.isWindowVisible()).rejects.toThrow("Method not implemented."); + }); + }); + + describe("getBiometricsUnlockBtnText", () => { + it("throws an error", () => { + expect(() => service.getBiometricsUnlockBtnText()).toThrow( + "Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$", + ); + }); + }); + + describe("getAvailableUnlockOptions$", () => { + it("returns an observable of unlock options", async () => { + const userId = "user-id" as UserId; + const userDecryptionOptions = { + hasMasterPassword: true, + }; + userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValueOnce( + of(userDecryptionOptions), + ); + + const unlockOptions = await firstValueFrom(service.getAvailableUnlockOptions$(userId)); + + expect(unlockOptions).toEqual({ + masterPassword: { + enabled: true, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: null, + }, + }); + }); + }); +}); diff --git a/apps/web/src/app/auth/core/services/web-lock-component.service.ts b/apps/web/src/app/auth/core/services/web-lock-component.service.ts new file mode 100644 index 00000000000..e24f299e23b --- /dev/null +++ b/apps/web/src/app/auth/core/services/web-lock-component.service.ts @@ -0,0 +1,55 @@ +import { inject } from "@angular/core"; +import { map, Observable } from "rxjs"; + +import { LockComponentService, UnlockOptions } from "@bitwarden/auth/angular"; +import { + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { UserId } from "@bitwarden/common/types/guid"; + +export class WebLockComponentService implements LockComponentService { + private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction); + + constructor() {} + + getBiometricsError(error: any): string | null { + throw new Error( + "Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$", + ); + } + + getPreviousUrl(): string | null { + return null; + } + + async isWindowVisible(): Promise { + throw new Error("Method not implemented."); + } + + getBiometricsUnlockBtnText(): string { + throw new Error( + "Biometric unlock is not supported in the web app. See getAvailableUnlockOptions$", + ); + } + + getAvailableUnlockOptions$(userId: UserId): Observable { + return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId).pipe( + map((userDecryptionOptions: UserDecryptionOptions) => { + const unlockOpts: UnlockOptions = { + masterPassword: { + enabled: userDecryptionOptions.hasMasterPassword, + }, + pin: { + enabled: false, + }, + biometrics: { + enabled: false, + disableReason: null, + }, + }; + return unlockOpts; + }), + ); + } +} diff --git a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html index 8b5ef867cca..4857a43a1ca 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html +++ b/apps/web/src/app/billing/organizations/billing-sync-api-key.component.html @@ -1,7 +1,7 @@

- {{ (hasBillingToken ? "viewBillingSyncToken" : "generateBillingSyncToken") | i18n }} + {{ (hasBillingToken ? "viewBillingToken" : "generateBillingToken") | i18n }}

diff --git a/apps/web/src/app/billing/organizations/billing-sync-key.component.html b/apps/web/src/app/billing/organizations/billing-sync-key.component.html index 808cd83ec67..5f6b8482875 100644 --- a/apps/web/src/app/billing/organizations/billing-sync-key.component.html +++ b/apps/web/src/app/billing/organizations/billing-sync-key.component.html @@ -1,7 +1,7 @@

- {{ "manageBillingSync" | i18n }} + {{ "manageBillingTokenSync" | i18n }}

{{ "billingSyncKeyDesc" | i18n }}

diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 341324c4a2a..643eeb93bad 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -280,7 +280,7 @@ (click)="manageBillingSync()" *ngIf="canManageBillingSync" > - {{ (hasBillingSyncToken ? "manageBillingSync" : "setUpBillingSync") | i18n }} + {{ (hasBillingSyncToken ? "viewBillingToken" : "setUpBillingSync") | i18n }}
diff --git a/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html b/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html index 464b8a24725..acbb8863c70 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-selfhost.component.html @@ -89,7 +89,7 @@ - {{ "billingSyncDesc" | i18n }} + {{ "automaticBillingSyncDesc" | i18n }} @@ -100,7 +100,7 @@ type="button" (click)="manageBillingSyncSelfHosted()" > - {{ "manageBillingSync" | i18n }} + {{ "manageBillingTokenSync" | i18n }} - + diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts index d5d7634db4c..0442f04fb72 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/settings/account.component.ts @@ -8,7 +8,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { ProviderUpdateRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-update.request"; import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -33,9 +32,6 @@ export class AccountComponent implements OnDestroy, OnInit { providerName: ["" as ProviderResponse["name"]], providerBillingEmail: ["" as ProviderResponse["billingEmail"], Validators.email], }); - protected enableDeleteProvider$ = this.configService.getFeatureFlag$( - FeatureFlag.EnableDeleteProvider, - ); constructor( private apiService: ApiService, diff --git a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts index 3616893e231..443edc1d2fc 100644 --- a/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts +++ b/bitwarden_license/bit-web/src/app/tools/reports/member-access-report/services/member-access-report.service.ts @@ -1,9 +1,9 @@ import { Injectable } from "@angular/core"; +import { CollectionAccessSelectionView } from "@bitwarden/admin-console/common"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { OrganizationId } from "@bitwarden/common/types/guid"; -import { CollectionAccessSelectionView } from "@bitwarden/web-vault/app/admin-console/organizations/core/views"; import { getPermissionList, convertToPermission, diff --git a/libs/admin-console/src/common/collections/abstractions/collection-admin.service.ts b/libs/admin-console/src/common/collections/abstractions/collection-admin.service.ts new file mode 100644 index 00000000000..e5b0bde7ef6 --- /dev/null +++ b/libs/admin-console/src/common/collections/abstractions/collection-admin.service.ts @@ -0,0 +1,16 @@ +import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response"; + +import { CollectionAccessSelectionView, CollectionAdminView } from "../models"; + +export abstract class CollectionAdminService { + getAll: (organizationId: string) => Promise; + get: (organizationId: string, collectionId: string) => Promise; + save: (collection: CollectionAdminView) => Promise; + delete: (organizationId: string, collectionId: string) => Promise; + bulkAssignAccess: ( + organizationId: string, + collectionIds: string[], + users: CollectionAccessSelectionView[], + groups: CollectionAccessSelectionView[], + ) => Promise; +} diff --git a/libs/admin-console/src/common/collections/abstractions/index.ts b/libs/admin-console/src/common/collections/abstractions/index.ts new file mode 100644 index 00000000000..4ee56102061 --- /dev/null +++ b/libs/admin-console/src/common/collections/abstractions/index.ts @@ -0,0 +1 @@ +export * from "./collection-admin.service"; diff --git a/libs/admin-console/src/common/collections/index.ts b/libs/admin-console/src/common/collections/index.ts new file mode 100644 index 00000000000..9187ccd39cf --- /dev/null +++ b/libs/admin-console/src/common/collections/index.ts @@ -0,0 +1,3 @@ +export * from "./abstractions"; +export * from "./models"; +export * from "./services"; diff --git a/apps/web/src/app/vault/core/bulk-collection-access.request.ts b/libs/admin-console/src/common/collections/models/bulk-collection-access.request.ts similarity index 100% rename from apps/web/src/app/vault/core/bulk-collection-access.request.ts rename to libs/admin-console/src/common/collections/models/bulk-collection-access.request.ts diff --git a/apps/web/src/app/admin-console/organizations/core/views/collection-access-selection.view.ts b/libs/admin-console/src/common/collections/models/collection-access-selection.view.ts similarity index 100% rename from apps/web/src/app/admin-console/organizations/core/views/collection-access-selection.view.ts rename to libs/admin-console/src/common/collections/models/collection-access-selection.view.ts diff --git a/apps/web/src/app/vault/core/views/collection-admin.view.ts b/libs/admin-console/src/common/collections/models/collection-admin.view.ts similarity index 92% rename from apps/web/src/app/vault/core/views/collection-admin.view.ts rename to libs/admin-console/src/common/collections/models/collection-admin.view.ts index 10f894505c9..208131a3f71 100644 --- a/apps/web/src/app/vault/core/views/collection-admin.view.ts +++ b/libs/admin-console/src/common/collections/models/collection-admin.view.ts @@ -2,8 +2,9 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { CollectionAccessDetailsResponse } from "@bitwarden/common/src/vault/models/response/collection.response"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; -import { CollectionAccessSelectionView } from "../../../admin-console/organizations/core/views/collection-access-selection.view"; -import { Unassigned } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model"; +import { CollectionAccessSelectionView } from "../models"; + +export const Unassigned = "unassigned"; export class CollectionAdminView extends CollectionView { groups: CollectionAccessSelectionView[] = []; diff --git a/libs/admin-console/src/common/collections/models/index.ts b/libs/admin-console/src/common/collections/models/index.ts new file mode 100644 index 00000000000..4f35728b00a --- /dev/null +++ b/libs/admin-console/src/common/collections/models/index.ts @@ -0,0 +1,3 @@ +export * from "./bulk-collection-access.request"; +export * from "./collection-access-selection.view"; +export * from "./collection-admin.view"; diff --git a/apps/web/src/app/vault/core/collection-admin.service.ts b/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts similarity index 94% rename from apps/web/src/app/vault/core/collection-admin.service.ts rename to libs/admin-console/src/common/collections/services/default-collection-admin.service.ts index e0c15e34047..aa2b5bb91d6 100644 --- a/apps/web/src/app/vault/core/collection-admin.service.ts +++ b/libs/admin-console/src/common/collections/services/default-collection-admin.service.ts @@ -1,5 +1,3 @@ -import { Injectable } from "@angular/core"; - import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -14,13 +12,14 @@ import { CollectionResponse, } from "@bitwarden/common/vault/models/response/collection.response"; -import { CollectionAccessSelectionView } from "../../admin-console/organizations/core"; +import { CollectionAdminService } from "../abstractions"; +import { + BulkCollectionAccessRequest, + CollectionAccessSelectionView, + CollectionAdminView, +} from "../models"; -import { BulkCollectionAccessRequest } from "./bulk-collection-access.request"; -import { CollectionAdminView } from "./views/collection-admin.view"; - -@Injectable() -export class CollectionAdminService { +export class DefaultCollectionAdminService implements CollectionAdminService { constructor( private apiService: ApiService, private cryptoService: CryptoService, diff --git a/libs/admin-console/src/common/collections/services/index.ts b/libs/admin-console/src/common/collections/services/index.ts new file mode 100644 index 00000000000..1e3ed96c6a0 --- /dev/null +++ b/libs/admin-console/src/common/collections/services/index.ts @@ -0,0 +1 @@ +export * from "./default-collection-admin.service"; diff --git a/libs/admin-console/src/common/index.ts b/libs/admin-console/src/common/index.ts index 0af54f8ffbf..edeff5aa314 100644 --- a/libs/admin-console/src/common/index.ts +++ b/libs/admin-console/src/common/index.ts @@ -1 +1,2 @@ export * from "./organization-user"; +export * from "./collections"; diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index 831d505a388..1c8f4f656e1 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -1,6 +1,6 @@ import { Directive, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; +import { ActivatedRoute, NavigationSkipped, Router } from "@angular/router"; import { Subject, firstValueFrom, of } from "rxjs"; import { switchMap, take, takeUntil } from "rxjs/operators"; @@ -121,6 +121,14 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, ) .subscribe(); + // If the user navigates to /login from /login, reset the validatedEmail flag + // This should bring the user back to the login screen with the email field + this.router.events.pipe(takeUntil(this.destroy$)).subscribe((event) => { + if (event instanceof NavigationSkipped && event.url === "/login") { + this.validatedEmail = false; + } + }); + // Backup check to handle unknown case where activatedRoute is not available // This shouldn't happen under normal circumstances if (!this.route) { diff --git a/libs/angular/src/auth/guards/auth.guard.ts b/libs/angular/src/auth/guards/auth.guard.ts index b54f114d3d4..1486b9b57d8 100644 --- a/libs/angular/src/auth/guards/auth.guard.ts +++ b/libs/angular/src/auth/guards/auth.guard.ts @@ -38,6 +38,8 @@ export const authGuard: CanActivateFn = async ( if (routerState != null) { messagingService.send("lockedUrl", { url: routerState.url }); } + // TODO PM-9674: when extension refresh is finished, remove promptBiometric + // as it has been integrated into the component as a default feature. return router.createUrlTree(["lock"], { queryParams: { promptBiometric: true } }); } diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.html b/libs/auth/src/angular/anon-layout/anon-layout.component.html index 9e6c27f6016..aa5ff029652 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.html +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.html @@ -9,7 +9,9 @@ 'tw-min-h-[calc(100vh-54px)]': clientType === 'desktop', }" > - + + +
diff --git a/libs/auth/src/angular/anon-layout/anon-layout.component.ts b/libs/auth/src/angular/anon-layout/anon-layout.component.ts index a40fafc5db9..ff28f24a936 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout.component.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout.component.ts @@ -1,5 +1,6 @@ import { CommonModule } from "@angular/common"; import { Component, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core"; +import { RouterModule } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { ClientType } from "@bitwarden/common/enums"; @@ -15,7 +16,7 @@ import { BitwardenLogo, BitwardenShield } from "../icons"; standalone: true, selector: "auth-anon-layout", templateUrl: "./anon-layout.component.html", - imports: [IconModule, CommonModule, TypographyModule, SharedModule], + imports: [IconModule, CommonModule, TypographyModule, SharedModule, RouterModule], }) export class AnonLayoutComponent implements OnInit, OnChanges { @Input() title: string; diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index bfb3a67aedc..6de473c33e7 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -43,5 +43,9 @@ export * from "./registration/registration-env-selector/registration-env-selecto export * from "./registration/registration-finish/registration-finish.service"; export * from "./registration/registration-finish/default-registration-finish.service"; +// lock +export * from "./lock/lock.component"; +export * from "./lock/lock-component.service"; + // vault timeout export * from "./vault-timeout-input/vault-timeout-input.component"; diff --git a/libs/auth/src/angular/lock/lock-component.service.ts b/libs/auth/src/angular/lock/lock-component.service.ts new file mode 100644 index 00000000000..fe54db21baa --- /dev/null +++ b/libs/auth/src/angular/lock/lock-component.service.ts @@ -0,0 +1,48 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +export enum BiometricsDisableReason { + NotSupportedOnOperatingSystem = "NotSupportedOnOperatingSystem", + EncryptedKeysUnavailable = "BiometricsEncryptedKeysUnavailable", + SystemBiometricsUnavailable = "SystemBiometricsUnavailable", +} + +// ex: type UnlockOptionValue = "masterPassword" | "pin" | "biometrics" +export type UnlockOptionValue = (typeof UnlockOption)[keyof typeof UnlockOption]; + +export const UnlockOption = Object.freeze({ + MasterPassword: "masterPassword", + Pin: "pin", + Biometrics: "biometrics", +}) satisfies { [Prop in keyof UnlockOptions as Capitalize]: Prop }; + +export type UnlockOptions = { + masterPassword: { + enabled: boolean; + }; + pin: { + enabled: boolean; + }; + biometrics: { + enabled: boolean; + disableReason: BiometricsDisableReason | null; + }; +}; + +/** + * The LockComponentService is a service which allows the single libs/auth LockComponent to delegate all + * client specific functionality to client specific services implementations of LockComponentService. + */ +export abstract class LockComponentService { + // Extension + abstract getBiometricsError(error: any): string | null; + abstract getPreviousUrl(): string | null; + + // Desktop only + abstract isWindowVisible(): Promise; + abstract getBiometricsUnlockBtnText(): string; + + // Multi client + abstract getAvailableUnlockOptions$(userId: UserId): Observable; +} diff --git a/libs/auth/src/angular/lock/lock.component.html b/libs/auth/src/angular/lock/lock.component.html new file mode 100644 index 00000000000..5f5991c681e --- /dev/null +++ b/libs/auth/src/angular/lock/lock.component.html @@ -0,0 +1,191 @@ + +
+ +
+
+ + + + + + +
+

{{ "or" | i18n }}

+ + + + + + + + + + +
+
+ + + +
+ + {{ "pin" | i18n }} + + + + +
+ + +

{{ "or" | i18n }}

+ + + + + + + + + + +
+
+
+ + + +
+ + {{ "masterPass" | i18n }} + + + + + + +
+ + +

{{ "or" | i18n }}

+ + + + + + + + + + +
+
+
+
diff --git a/libs/auth/src/angular/lock/lock.component.ts b/libs/auth/src/angular/lock/lock.component.ts new file mode 100644 index 00000000000..7bea14f221e --- /dev/null +++ b/libs/auth/src/angular/lock/lock.component.ts @@ -0,0 +1,638 @@ +import { CommonModule } from "@angular/common"; +import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { Router } from "@angular/router"; +import { BehaviorSubject, firstValueFrom, Subject, switchMap, take, takeUntil } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { + MasterPasswordVerification, + MasterPasswordVerificationResponse, +} from "@bitwarden/common/auth/types/verification"; +import { ClientType } from "@bitwarden/common/enums"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.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 { KeySuffixOptions } from "@bitwarden/common/platform/enums"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + DialogService, + FormFieldModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; +import { BiometricStateService } from "@bitwarden/key-management"; + +import { PinServiceAbstraction } from "../../common/abstractions"; +import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; + +import { + UnlockOption, + LockComponentService, + UnlockOptions, + UnlockOptionValue, +} from "./lock-component.service"; + +const BroadcasterSubscriptionId = "LockComponent"; + +const clientTypeToSuccessRouteRecord: Partial> = { + [ClientType.Web]: "vault", + [ClientType.Desktop]: "vault", + [ClientType.Browser]: "/tabs/current", +}; + +@Component({ + selector: "bit-lock", + templateUrl: "lock.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + ReactiveFormsModule, + ButtonModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + ], +}) +export class LockV2Component implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + activeAccount: { id: UserId | undefined } & AccountInfo; + + clientType: ClientType; + ClientType = ClientType; + + unlockOptions: UnlockOptions = null; + + UnlockOption = UnlockOption; + + private _activeUnlockOptionBSubject: BehaviorSubject = + new BehaviorSubject(null); + + activeUnlockOption$ = this._activeUnlockOptionBSubject.asObservable(); + + set activeUnlockOption(value: UnlockOptionValue) { + this._activeUnlockOptionBSubject.next(value); + } + + get activeUnlockOption(): UnlockOptionValue { + return this._activeUnlockOptionBSubject.value; + } + + private invalidPinAttempts = 0; + + biometricUnlockBtnText: string; + + // masterPassword = ""; + showPassword = false; + private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions = undefined; + + forcePasswordResetRoute = "update-temp-password"; + + formGroup: FormGroup; + + // Desktop properties: + private deferFocus: boolean = null; + private biometricAsked = false; + + // Browser extension properties: + private isInitialLockScreen = (window as any).previousPopupUrl == null; + + defaultUnlockOptionSetForUser = false; + + unlockingViaBiometrics = false; + + constructor( + private accountService: AccountService, + private pinService: PinServiceAbstraction, + private userVerificationService: UserVerificationService, + private cryptoService: CryptoService, + private platformUtilsService: PlatformUtilsService, + private router: Router, + private dialogService: DialogService, + private messagingService: MessagingService, + private biometricStateService: BiometricStateService, + private ngZone: NgZone, + private i18nService: I18nService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, + private logService: LogService, + private deviceTrustService: DeviceTrustServiceAbstraction, + private syncService: SyncService, + private policyService: InternalPolicyService, + private passwordStrengthService: PasswordStrengthServiceAbstraction, + private formBuilder: FormBuilder, + private toastService: ToastService, + + private lockComponentService: LockComponentService, + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + + // desktop deps + private broadcasterService: BroadcasterService, + ) {} + + async ngOnInit() { + this.listenForActiveUnlockOptionChanges(); + + // Listen for active account changes + this.listenForActiveAccountChanges(); + + // Identify client + this.clientType = this.platformUtilsService.getClientType(); + + if (this.clientType === "desktop") { + await this.desktopOnInit(); + } + } + + // Base component methods + private listenForActiveUnlockOptionChanges() { + this.activeUnlockOption$ + .pipe(takeUntil(this.destroy$)) + .subscribe((activeUnlockOption: UnlockOptionValue) => { + if (activeUnlockOption === UnlockOption.Pin) { + this.buildPinForm(); + } else if (activeUnlockOption === UnlockOption.MasterPassword) { + this.buildMasterPasswordForm(); + } + }); + } + + private buildMasterPasswordForm() { + this.formGroup = this.formBuilder.group( + { + masterPassword: ["", [Validators.required]], + }, + { updateOn: "submit" }, + ); + } + + private buildPinForm() { + this.formGroup = this.formBuilder.group( + { + pin: ["", [Validators.required]], + }, + { updateOn: "submit" }, + ); + } + + private listenForActiveAccountChanges() { + this.accountService.activeAccount$ + .pipe( + switchMap((account) => { + return this.handleActiveAccountChange(account); + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + private async handleActiveAccountChange(activeAccount: { id: UserId | undefined } & AccountInfo) { + this.activeAccount = activeAccount; + + this.resetDataOnActiveAccountChange(); + + this.setEmailAsPageSubtitle(activeAccount.email); + + this.unlockOptions = await firstValueFrom( + this.lockComponentService.getAvailableUnlockOptions$(activeAccount.id), + ); + + this.setDefaultActiveUnlockOption(this.unlockOptions); + + if (this.unlockOptions.biometrics.enabled) { + await this.handleBiometricsUnlockEnabled(); + } + } + + private resetDataOnActiveAccountChange() { + this.defaultUnlockOptionSetForUser = false; + this.unlockOptions = null; + this.activeUnlockOption = null; + this.formGroup = null; // new form group will be created based on new active unlock option + + // Desktop properties: + this.biometricAsked = false; + } + + private setEmailAsPageSubtitle(email: string) { + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageSubtitle: { + subtitle: email, + translate: false, + }, + }); + } + + private setDefaultActiveUnlockOption(unlockOptions: UnlockOptions) { + // Priorities should be Biometrics > Pin > Master Password for speed + if (unlockOptions.biometrics.enabled) { + this.activeUnlockOption = UnlockOption.Biometrics; + } else if (unlockOptions.pin.enabled) { + this.activeUnlockOption = UnlockOption.Pin; + } else if (unlockOptions.masterPassword.enabled) { + this.activeUnlockOption = UnlockOption.MasterPassword; + } + } + + private async handleBiometricsUnlockEnabled() { + this.biometricUnlockBtnText = this.lockComponentService.getBiometricsUnlockBtnText(); + + const autoPromptBiometrics = await firstValueFrom( + this.biometricStateService.promptAutomatically$, + ); + + // TODO: PM-12546 - we need to make our biometric autoprompt experience consistent between the + // desktop and extension. + if (this.clientType === "desktop") { + if (autoPromptBiometrics) { + await this.desktopAutoPromptBiometrics(); + } + } + + if (this.clientType === "browser") { + if ( + this.unlockOptions.biometrics.enabled && + autoPromptBiometrics && + this.isInitialLockScreen // only autoprompt biometrics on initial lock screen + ) { + await this.unlockViaBiometrics(); + } + } + } + + // Note: this submit method is only used for unlock methods that require a form and user input. + // For biometrics unlock, the method is called directly. + submit = async (): Promise => { + if (this.activeUnlockOption === UnlockOption.Pin) { + return await this.unlockViaPin(); + } + + await this.unlockViaMasterPassword(); + }; + + async logOut() { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "logOut" }, + content: { key: "logOutConfirmation" }, + acceptButtonText: { key: "logOut" }, + type: "warning", + }); + + if (confirmed) { + this.messagingService.send("logout", { userId: this.activeAccount.id }); + } + } + + async unlockViaBiometrics(): Promise { + this.unlockingViaBiometrics = true; + + if (!this.unlockOptions.biometrics.enabled) { + this.unlockingViaBiometrics = false; + return; + } + + try { + await this.biometricStateService.setUserPromptCancelled(); + const userKey = await this.cryptoService.getUserKeyFromStorage( + KeySuffixOptions.Biometric, + this.activeAccount.id, + ); + + // If user cancels biometric prompt, userKey is undefined. + if (userKey) { + await this.setUserKeyAndContinue(userKey, false); + } + + this.unlockingViaBiometrics = false; + } catch (e) { + // Cancelling is a valid action. + if (e?.message === "canceled") { + this.unlockingViaBiometrics = false; + return; + } + + let biometricTranslatedErrorDesc; + + if (this.clientType === "browser") { + const biometricErrorDescTranslationKey = this.lockComponentService.getBiometricsError(e); + + if (biometricErrorDescTranslationKey) { + biometricTranslatedErrorDesc = this.i18nService.t(biometricErrorDescTranslationKey); + } + } + + // if no translation key found, show generic error message + if (!biometricTranslatedErrorDesc) { + biometricTranslatedErrorDesc = this.i18nService.t("unexpectedError"); + } + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "error" }, + content: biometricTranslatedErrorDesc, + acceptButtonText: { key: "tryAgain" }, + type: "danger", + }); + + if (confirmed) { + // try again + await this.unlockViaBiometrics(); + } + + this.unlockingViaBiometrics = false; + } + } + + togglePassword() { + this.showPassword = !this.showPassword; + const input = document.getElementById( + this.unlockOptions.pin.enabled ? "pin" : "masterPassword", + ); + if (this.ngZone.isStable) { + input.focus(); + } else { + // eslint-disable-next-line rxjs-angular/prefer-takeuntil + this.ngZone.onStable.pipe(take(1)).subscribe(() => input.focus()); + } + } + + private validatePin(): boolean { + if (this.formGroup.invalid) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("pinRequired"), + }); + return false; + } + + return true; + } + + private async unlockViaPin() { + if (!this.validatePin()) { + return; + } + + const pin = this.formGroup.controls.pin.value; + + const MAX_INVALID_PIN_ENTRY_ATTEMPTS = 5; + + try { + const userKey = await this.pinService.decryptUserKeyWithPin(pin, this.activeAccount.id); + + if (userKey) { + await this.setUserKeyAndContinue(userKey); + return; // successfully unlocked + } + + // Failure state: invalid PIN or failed decryption + this.invalidPinAttempts++; + + // Log user out if they have entered an invalid PIN too many times + if (this.invalidPinAttempts >= MAX_INVALID_PIN_ENTRY_ATTEMPTS) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("tooManyInvalidPinEntryAttemptsLoggingOut"), + }); + this.messagingService.send("logout"); + return; + } + + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidPin"), + }); + } catch { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("unexpectedError"), + }); + } + } + + private validateMasterPassword(): boolean { + if (this.formGroup.invalid) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("masterPasswordRequired"), + }); + return false; + } + + return true; + } + + private async unlockViaMasterPassword() { + if (!this.validateMasterPassword()) { + return; + } + + const masterPassword = this.formGroup.controls.masterPassword.value; + + const verification = { + type: VerificationType.MasterPassword, + secret: masterPassword, + } as MasterPasswordVerification; + + let passwordValid = false; + let masterPasswordVerificationResponse: MasterPasswordVerificationResponse; + try { + masterPasswordVerificationResponse = + await this.userVerificationService.verifyUserByMasterPassword( + verification, + this.activeAccount.id, + this.activeAccount.email, + ); + + this.enforcedMasterPasswordOptions = MasterPasswordPolicyOptions.fromResponse( + masterPasswordVerificationResponse.policyOptions, + ); + passwordValid = true; + } catch (e) { + this.logService.error(e); + } + + if (!passwordValid) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidMasterPassword"), + }); + return; + } + + const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey( + masterPasswordVerificationResponse.masterKey, + ); + await this.setUserKeyAndContinue(userKey, true); + } + + private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) { + await this.cryptoService.setUserKey(key, this.activeAccount.id); + + // Now that we have a decrypted user key in memory, we can check if we + // need to establish trust on the current device + await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id); + + await this.doContinue(evaluatePasswordAfterUnlock); + } + + private async doContinue(evaluatePasswordAfterUnlock: boolean) { + await this.biometricStateService.resetUserPromptCancelled(); + this.messagingService.send("unlocked"); + + if (evaluatePasswordAfterUnlock) { + try { + // If we do not have any saved policies, attempt to load them from the service + if (this.enforcedMasterPasswordOptions == undefined) { + this.enforcedMasterPasswordOptions = await firstValueFrom( + this.policyService.masterPasswordPolicyOptions$(), + ); + } + + if (this.requirePasswordChange()) { + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); + await this.router.navigate([this.forcePasswordResetRoute]); + return; + } + } catch (e) { + // Do not prevent unlock if there is an error evaluating policies + this.logService.error(e); + } + } + + // Vault can be de-synced since notifications get ignored while locked. Need to check whether sync is required using the sync service. + await this.syncService.fullSync(false); + + if (this.clientType === "browser") { + const previousUrl = this.lockComponentService.getPreviousUrl(); + if (previousUrl) { + await this.router.navigateByUrl(previousUrl); + } + } + + // determine success route based on client type + const successRoute = clientTypeToSuccessRouteRecord[this.clientType]; + await this.router.navigate([successRoute]); + } + + /** + * Checks if the master password meets the enforced policy requirements + * If not, returns false + */ + private requirePasswordChange(): boolean { + if ( + this.enforcedMasterPasswordOptions == undefined || + !this.enforcedMasterPasswordOptions.enforceOnLogin + ) { + return false; + } + + const masterPassword = this.formGroup.controls.masterPassword.value; + + const passwordStrength = this.passwordStrengthService.getPasswordStrength( + masterPassword, + this.activeAccount.email, + )?.score; + + return !this.policyService.evaluateMasterPassword( + passwordStrength, + masterPassword, + this.enforcedMasterPasswordOptions, + ); + } + + // ----------------------------------------------------------------------------------------------- + // Desktop methods: + // ----------------------------------------------------------------------------------------------- + + async desktopOnInit() { + // TODO: move this into a WindowService and subscribe to messages via MessageListener service. + this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { + this.ngZone.run(() => { + switch (message.command) { + case "windowHidden": + this.onWindowHidden(); + break; + case "windowIsFocused": + if (this.deferFocus === null) { + this.deferFocus = !message.windowIsFocused; + if (!this.deferFocus) { + this.focusInput(); + } + } else if (this.deferFocus && message.windowIsFocused) { + this.focusInput(); + this.deferFocus = false; + } + break; + default: + } + }); + }); + this.messagingService.send("getWindowIsFocused"); + } + + private async desktopAutoPromptBiometrics() { + if (!this.unlockOptions?.biometrics?.enabled || this.biometricAsked) { + return; + } + + // prevent the biometric prompt from showing if the user has already cancelled it + if (await firstValueFrom(this.biometricStateService.promptCancelled$)) { + return; + } + + const windowVisible = await this.lockComponentService.isWindowVisible(); + + if (windowVisible) { + this.biometricAsked = true; + await this.unlockViaBiometrics(); + } + } + + onWindowHidden() { + this.showPassword = false; + } + + private focusInput() { + if (this.unlockOptions) { + document.getElementById(this.unlockOptions.pin.enabled ? "pin" : "masterPassword")?.focus(); + } + } + + // ----------------------------------------------------------------------------------------------- + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + + if (this.clientType === "desktop") { + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + } + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 2b7d2bea334..f8967212b20 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -9,7 +9,6 @@ export enum FeatureFlag { GeneratorToolsModernization = "generator-tools-modernization", EnableConsolidatedBilling = "enable-consolidated-billing", AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section", - EnableDeleteProvider = "AC-1218-delete-provider", ExtensionRefresh = "extension-refresh", PersistPopupView = "persist-popup-view", PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service", @@ -54,7 +53,6 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.GeneratorToolsModernization]: FALSE, [FeatureFlag.EnableConsolidatedBilling]: FALSE, [FeatureFlag.AC1795_UpdatedSubscriptionStatusSection]: FALSE, - [FeatureFlag.EnableDeleteProvider]: FALSE, [FeatureFlag.ExtensionRefresh]: FALSE, [FeatureFlag.PersistPopupView]: FALSE, [FeatureFlag.PM4154_BulkEncryptionService]: FALSE, diff --git a/libs/common/src/tools/send/services/send-api.service.abstraction.ts b/libs/common/src/tools/send/services/send-api.service.abstraction.ts index 100985c4870..4109df19680 100644 --- a/libs/common/src/tools/send/services/send-api.service.abstraction.ts +++ b/libs/common/src/tools/send/services/send-api.service.abstraction.ts @@ -36,5 +36,5 @@ export abstract class SendApiService { renewSendFileUploadUrl: (sendId: string, fileId: string) => Promise; removePassword: (id: string) => Promise; delete: (id: string) => Promise; - save: (sendData: [Send, EncArrayBuffer]) => Promise; + save: (sendData: [Send, EncArrayBuffer]) => Promise; } diff --git a/libs/common/src/tools/send/services/send-api.service.ts b/libs/common/src/tools/send/services/send-api.service.ts index 2cb2ff1c2f0..ff71408bce3 100644 --- a/libs/common/src/tools/send/services/send-api.service.ts +++ b/libs/common/src/tools/send/services/send-api.service.ts @@ -135,11 +135,12 @@ export class SendApiService implements SendApiServiceAbstraction { return this.apiService.send("DELETE", "/sends/" + id, null, true, false); } - async save(sendData: [Send, EncArrayBuffer]): Promise { + async save(sendData: [Send, EncArrayBuffer]): Promise { const response = await this.upload(sendData); const data = new SendData(response); await this.sendService.upsert(data); + return new Send(data); } async delete(id: string): Promise { diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html index dbd86d7f5b7..adbca181947 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html @@ -35,6 +35,7 @@ bitIconButton="bwi-clone" bitSuffix [appA11yTitle]="'copyPassword' | i18n" + [disabled]="!sendOptionsForm.get('password').value" [valueLabel]="'password' | i18n" [appCopyClick]="sendOptionsForm.get('password').value" showToast diff --git a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts index 1d93804e11f..07939ccb06c 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/send-form.component.ts @@ -85,9 +85,14 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send submitBtn?: ButtonComponent; /** - * Event emitted when the send is saved successfully. + * Event emitted when the send is created successfully. */ - @Output() sendSaved = new EventEmitter(); + @Output() onSendCreated = new EventEmitter(); + + /** + * Event emitted when the send is updated successfully. + */ + @Output() onSendUpdated = new EventEmitter(); /** * The original send being edited or cloned. Null for add mode. @@ -200,22 +205,26 @@ export class SendFormComponent implements AfterViewInit, OnInit, OnChanges, Send return; } + const sendView = await this.addEditFormService.saveSend( + this.updatedSendView, + this.file, + this.config, + ); + + if (this.config.mode === "add") { + this.onSendCreated.emit(sendView); + return; + } + if (Utils.isNullOrWhitespace(this.updatedSendView.password)) { this.updatedSendView.password = null; } - await this.addEditFormService.saveSend(this.updatedSendView, this.file, this.config); - this.toastService.showToast({ variant: "success", title: null, - message: this.i18nService.t( - this.config.mode === "edit" || this.config.mode === "partial-edit" - ? "editedItem" - : "addedItem", - ), + message: this.i18nService.t("editedItem"), }); - - this.sendSaved.emit(this.updatedSendView); + this.onSendUpdated.emit(this.updatedSendView); }; } diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts index 9b6a6360ac7..9eb37b07e50 100644 --- a/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts +++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts @@ -19,6 +19,7 @@ export class DefaultSendFormService implements SendFormService { async saveSend(send: SendView, file: File | ArrayBuffer, config: SendFormConfig) { const sendData = await this.sendService.encrypt(send, file, send.password, null); - return await this.sendApiService.save(sendData); + const newSend = await this.sendApiService.save(sendData); + return await this.decryptSend(newSend); } } diff --git a/package-lock.json b/package-lock.json index c2571510a9b..3a4c9e91842 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,7 +97,7 @@ "@storybook/theming": "8.2.9", "@types/argon2-browser": "1.18.4", "@types/chrome": "0.0.272", - "@types/firefox-webext-browser": "111.0.5", + "@types/firefox-webext-browser": "120.0.4", "@types/inquirer": "8.2.10", "@types/jest": "29.5.12", "@types/jquery": "3.5.30", @@ -9127,9 +9127,9 @@ "license": "MIT" }, "node_modules/@types/firefox-webext-browser": { - "version": "111.0.5", - "resolved": "https://registry.npmjs.org/@types/firefox-webext-browser/-/firefox-webext-browser-111.0.5.tgz", - "integrity": "sha512-YYE+4MeJvq7DZ+UzPD8c5uN1HJpGu4Fl6O6PEAfBJQmLzQkfTWlgMjZMJQHAmcH3rjVS5fjN+jMkkZ4ZTlKbmA==", + "version": "120.0.4", + "resolved": "https://registry.npmjs.org/@types/firefox-webext-browser/-/firefox-webext-browser-120.0.4.tgz", + "integrity": "sha512-lBrpf08xhiZBigrtdQfUaqX1UauwZ+skbFiL8u2Tdra/rklkKadYmIzTwkNZSWtuZ7OKpFqbE2HHfDoFqvZf6w==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index d2a42da8592..115b02eebe2 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@storybook/theming": "8.2.9", "@types/argon2-browser": "1.18.4", "@types/chrome": "0.0.272", - "@types/firefox-webext-browser": "111.0.5", + "@types/firefox-webext-browser": "120.0.4", "@types/inquirer": "8.2.10", "@types/jest": "29.5.12", "@types/jquery": "3.5.30",