diff --git a/apps/web/src/app/auth/settings/security/device-management.component.html b/apps/web/src/app/auth/settings/security/device-management.component.html index c38283cfd80..587703c7389 100644 --- a/apps/web/src/app/auth/settings/security/device-management.component.html +++ b/apps/web/src/app/auth/settings/security/device-management.component.html @@ -40,7 +40,8 @@ > {{ col.title }} - + + diff --git a/apps/web/src/app/auth/settings/security/device-management.component.spec.ts b/apps/web/src/app/auth/settings/security/device-management.component.spec.ts new file mode 100644 index 00000000000..84c1dfcb63b --- /dev/null +++ b/apps/web/src/app/auth/settings/security/device-management.component.spec.ts @@ -0,0 +1,181 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { RouterTestingModule } from "@angular/router/testing"; +import { of, Subject } from "rxjs"; + +import { AuthRequestApiService } from "@bitwarden/auth/common"; +import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; +import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view"; +import { DeviceType } from "@bitwarden/common/enums"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { MessageListener } from "@bitwarden/common/platform/messaging"; +import { DialogService, ToastService, TableModule, PopoverModule } from "@bitwarden/components"; + +import { SharedModule } from "../../../shared"; +import { VaultBannersService } from "../../../vault/individual-vault/vault-banners/services/vault-banners.service"; + +import { DeviceManagementComponent } from "./device-management.component"; + +class MockResizeObserver { + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); +} + +global.ResizeObserver = MockResizeObserver; + +interface Message { + command: string; + notificationId?: string; +} + +describe("DeviceManagementComponent", () => { + let fixture: ComponentFixture; + let messageSubject: Subject; + let mockDevices: DeviceView[]; + let vaultBannersService: VaultBannersService; + + const mockDeviceResponse = { + id: "test-id", + requestDeviceType: "test-type", + requestDeviceTypeValue: DeviceType.Android, + requestDeviceIdentifier: "test-identifier", + requestIpAddress: "127.0.0.1", + creationDate: new Date().toISOString(), + responseDate: null, + key: "test-key", + masterPasswordHash: null, + publicKey: "test-public-key", + requestApproved: false, + origin: "test-origin", + }; + + beforeEach(async () => { + messageSubject = new Subject(); + mockDevices = []; + + await TestBed.configureTestingModule({ + imports: [ + RouterTestingModule, + SharedModule, + TableModule, + PopoverModule, + DeviceManagementComponent, + ], + providers: [ + { + provide: DevicesServiceAbstraction, + useValue: { + getDevices$: jest.fn().mockReturnValue(mockDevices), + getCurrentDevice$: jest.fn().mockReturnValue(of(null)), + getDeviceByIdentifier$: jest.fn().mockReturnValue(of(null)), + updateTrustedDeviceKeys: jest.fn(), + }, + }, + { + provide: AuthRequestApiService, + useValue: { + getAuthRequest: jest.fn().mockResolvedValue(mockDeviceResponse), + }, + }, + { + provide: MessageListener, + useValue: { + allMessages$: messageSubject.asObservable(), + }, + }, + { + provide: DialogService, + useValue: { + openSimpleDialog: jest.fn(), + }, + }, + { + provide: ToastService, + useValue: { + success: jest.fn(), + error: jest.fn(), + }, + }, + { + provide: VaultBannersService, + useValue: { + shouldShowPendingAuthRequestBanner: jest.fn(), + }, + }, + { + provide: I18nService, + useValue: { + t: jest.fn((key: string) => key), + }, + }, + { + provide: ValidationService, + useValue: { + showError: jest.fn(), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DeviceManagementComponent); + + vaultBannersService = TestBed.inject(VaultBannersService); + }); + + describe("message listener", () => { + beforeEach(() => { + jest.spyOn(vaultBannersService, "shouldShowPendingAuthRequestBanner").mockResolvedValue(true); + }); + + it("ignores other message types", async () => { + const initialDataLength = (fixture.componentInstance as any).dataSource.data.length; + const message: Message = { command: "other", notificationId: "test-id" }; + messageSubject.next(message); + await fixture.whenStable(); + + expect((fixture.componentInstance as any).dataSource.data.length).toBe(initialDataLength); + }); + + it("adds device to table when auth request message received", async () => { + const initialDataLength = (fixture.componentInstance as any).dataSource.data.length; + const message: Message = { + command: "openLoginApproval", + notificationId: "test-id", + }; + + messageSubject.next(message); + fixture.detectChanges(); + await fixture.whenStable(); + + const dataSource = (fixture.componentInstance as any).dataSource; + expect(dataSource.data.length).toBe(initialDataLength + 1); + + const addedDevice = dataSource.data[0]; + expect(addedDevice).toEqual({ + id: "", + type: mockDeviceResponse.requestDeviceTypeValue, + displayName: expect.any(String), + loginStatus: "requestPending", + firstLogin: expect.any(Date), + trusted: false, + devicePendingAuthRequest: { + id: mockDeviceResponse.id, + creationDate: mockDeviceResponse.creationDate, + }, + hasPendingAuthRequest: true, + identifier: mockDeviceResponse.requestDeviceIdentifier, + }); + }); + + it("stops listening when component is destroyed", async () => { + fixture.destroy(); + const message: Message = { + command: "openLoginApproval", + notificationId: "test-id", + }; + messageSubject.next(message); + expect((fixture.componentInstance as any).dataSource.data.length).toBe(0); + }); + }); +}); diff --git a/apps/web/src/app/auth/settings/security/device-management.component.ts b/apps/web/src/app/auth/settings/security/device-management.component.ts index e22122ad9ae..97107cc0c0b 100644 --- a/apps/web/src/app/auth/settings/security/device-management.component.ts +++ b/apps/web/src/app/auth/settings/security/device-management.component.ts @@ -1,9 +1,10 @@ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, DestroyRef } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { combineLatest, firstValueFrom } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { LoginApprovalComponent } from "@bitwarden/auth/angular"; +import { AuthRequestApiService } from "@bitwarden/auth/common"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { DevicePendingAuthRequest, @@ -13,6 +14,7 @@ import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/de import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { MessageListener } from "@bitwarden/common/platform/messaging"; import { DialogService, ToastService, @@ -23,6 +25,9 @@ import { import { SharedModule } from "../../../shared"; +/** + * Interface representing a row in the device management table + */ interface DeviceTableData { id: string; type: DeviceType; @@ -32,6 +37,7 @@ interface DeviceTableData { trusted: boolean; devicePendingAuthRequest: DevicePendingAuthRequest | null; hasPendingAuthRequest: boolean; + identifier: string; } /** @@ -44,7 +50,6 @@ interface DeviceTableData { imports: [CommonModule, SharedModule, TableModule, PopoverModule], }) export class DeviceManagementComponent { - protected readonly tableId = "device-management-table"; protected dataSource = new TableDataSource(); protected currentDevice: DeviceView | undefined; protected loading = true; @@ -56,32 +61,146 @@ export class DeviceManagementComponent { private dialogService: DialogService, private toastService: ToastService, private validationService: ValidationService, + private messageListener: MessageListener, + private authRequestApiService: AuthRequestApiService, + private destroyRef: DestroyRef, ) { - combineLatest([this.devicesService.getCurrentDevice$(), this.devicesService.getDevices$()]) - .pipe(takeUntilDestroyed()) - .subscribe({ - next: ([currentDevice, devices]: [DeviceResponse, Array]) => { - this.currentDevice = new DeviceView(currentDevice); + void this.initializeDevices(); + } - this.dataSource.data = devices.map((device: DeviceView): DeviceTableData => { - return { - id: device.id, - type: device.type, - displayName: this.getHumanReadableDeviceType(device.type), - loginStatus: this.getLoginStatus(device), - firstLogin: new Date(device.creationDate), - trusted: device.response.isTrusted, - devicePendingAuthRequest: device.response.devicePendingAuthRequest, - hasPendingAuthRequest: this.hasPendingAuthRequest(device.response), - }; - }); + /** + * Initialize the devices list and set up the message listener + */ + private async initializeDevices(): Promise { + try { + await this.loadDevices(); - this.loading = false; - }, - error: () => { - this.loading = false; - }, - }); + this.messageListener.allMessages$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((message) => { + if (message.command !== "openLoginApproval") { + return; + } + // Handle inserting a new device when an auth request is received + this.upsertDeviceWithPendingAuthRequest( + message as { command: string; notificationId: string }, + ).catch((error) => this.validationService.showError(error)); + }); + } catch (error) { + this.validationService.showError(error); + } + } + + /** + * Handle inserting a new device when an auth request is received + * @param message - The auth request message + */ + private async upsertDeviceWithPendingAuthRequest(message: { + command: string; + notificationId: string; + }): Promise { + const requestId = message.notificationId; + if (!requestId) { + return; + } + + const authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId); + if (!authRequestResponse) { + return; + } + + // Add new device to the table + const upsertDevice: DeviceTableData = { + id: "", + type: authRequestResponse.requestDeviceTypeValue, + displayName: this.getHumanReadableDeviceType(authRequestResponse.requestDeviceTypeValue), + loginStatus: this.i18nService.t("requestPending"), + firstLogin: new Date(authRequestResponse.creationDate), + trusted: false, + devicePendingAuthRequest: { + id: authRequestResponse.id, + creationDate: authRequestResponse.creationDate, + }, + hasPendingAuthRequest: true, + identifier: authRequestResponse.requestDeviceIdentifier, + }; + + // If the device already exists in the DB, update the device id and first login date + if (authRequestResponse.requestDeviceIdentifier) { + const existingDevice = await firstValueFrom( + this.devicesService.getDeviceByIdentifier$(authRequestResponse.requestDeviceIdentifier), + ); + + if (existingDevice?.id && existingDevice.creationDate) { + upsertDevice.id = existingDevice.id; + upsertDevice.firstLogin = new Date(existingDevice.creationDate); + } + } + + const existingDeviceIndex = this.dataSource.data.findIndex( + (device) => device.identifier === upsertDevice.identifier, + ); + + if (existingDeviceIndex >= 0) { + // Update existing device + this.dataSource.data[existingDeviceIndex] = upsertDevice; + this.dataSource.data = [...this.dataSource.data]; + } else { + // Add new device + this.dataSource.data = [upsertDevice, ...this.dataSource.data]; + } + } + + /** + * Load current device and all devices + */ + private async loadDevices(): Promise { + try { + const currentDevice = await firstValueFrom(this.devicesService.getCurrentDevice$()); + const devices = await firstValueFrom(this.devicesService.getDevices$()); + + if (!currentDevice || !devices) { + this.loading = false; + return; + } + + this.currentDevice = new DeviceView(currentDevice); + this.updateDeviceTable(devices); + } catch (error) { + this.validationService.showError(error); + } finally { + this.loading = false; + } + } + + /** + * Updates the device table with the latest device data + * @param devices - Array of device views to display in the table + */ + private updateDeviceTable(devices: Array): void { + this.dataSource.data = devices + .map((device: DeviceView): DeviceTableData | null => { + if (!device.id || !device.type || !device.creationDate) { + this.validationService.showError(new Error("Invalid device data")); + return null; + } + + const hasPendingRequest = device.response + ? this.hasPendingAuthRequest(device.response) + : false; + return { + id: device.id, + type: device.type, + displayName: this.getHumanReadableDeviceType(device.type), + loginStatus: this.getLoginStatus(device), + firstLogin: new Date(device.creationDate), + trusted: device.response?.isTrusted ?? false, + devicePendingAuthRequest: device.response?.devicePendingAuthRequest ?? null, + hasPendingAuthRequest: hasPendingRequest, + identifier: device.identifier ?? "", + }; + }) + .filter((device): device is DeviceTableData => device !== null); } /** @@ -140,7 +259,7 @@ export class DeviceManagementComponent { return this.i18nService.t("currentSession"); } - if (device.response.devicePendingAuthRequest?.creationDate) { + if (device?.response?.devicePendingAuthRequest?.creationDate) { return this.i18nService.t("requestPending"); } diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts index 88fae02275f..4ce65b9f771 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.spec.ts @@ -6,7 +6,11 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; +import { DeviceResponse } from "@bitwarden/common/auth/abstractions/devices/responses/device.response"; +import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { DeviceType } from "@bitwarden/common/enums"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { StateProvider } from "@bitwarden/common/platform/state"; @@ -36,6 +40,7 @@ describe("VaultBannersService", () => { const accounts$ = new BehaviorSubject>({ [userId]: { email: "test@bitwarden.com", emailVerified: true, name: "name" } as AccountInfo, }); + const devices$ = new BehaviorSubject([]); beforeEach(() => { lastSync$.next(new Date("2024-05-14")); @@ -79,6 +84,10 @@ describe("VaultBannersService", () => { userDecryptionOptionsById$: () => userDecryptionOptions$, }, }, + { + provide: DevicesServiceAbstraction, + useValue: { getDevices$: () => devices$ }, + }, ], }); }); @@ -274,4 +283,63 @@ describe("VaultBannersService", () => { expect(await service.shouldShowVerifyEmailBanner(userId)).toBe(false); }); }); + + describe("PendingAuthRequest", () => { + const now = new Date(); + let deviceResponse: DeviceResponse; + + beforeEach(() => { + deviceResponse = new DeviceResponse({ + Id: "device1", + UserId: userId, + Name: "Test Device", + Identifier: "test-device", + Type: DeviceType.Android, + CreationDate: now.toISOString(), + RevisionDate: now.toISOString(), + IsTrusted: false, + }); + // Reset devices list, single user state, and active user state before each test + devices$.next([]); + fakeStateProvider.singleUser.states.clear(); + fakeStateProvider.activeUser.states.clear(); + }); + + it("shows pending auth request banner when there is a pending request", async () => { + deviceResponse.devicePendingAuthRequest = { + id: "123", + creationDate: now.toISOString(), + }; + devices$.next([new DeviceView(deviceResponse)]); + + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowPendingAuthRequestBanner(userId)).toBe(true); + }); + + it("does not show pending auth request banner when there are no pending requests", async () => { + deviceResponse.devicePendingAuthRequest = null; + devices$.next([new DeviceView(deviceResponse)]); + + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowPendingAuthRequestBanner(userId)).toBe(false); + }); + + it("dismisses pending auth request banner", async () => { + deviceResponse.devicePendingAuthRequest = { + id: "123", + creationDate: now.toISOString(), + }; + devices$.next([new DeviceView(deviceResponse)]); + + service = TestBed.inject(VaultBannersService); + + expect(await service.shouldShowPendingAuthRequestBanner(userId)).toBe(true); + + await service.dismissBanner(userId, VisibleVaultBanner.PendingAuthRequest); + + expect(await service.shouldShowPendingAuthRequestBanner(userId)).toBe(false); + }); + }); }); diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts index 475cfc2df22..1fa5ae1ad8b 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/services/vault-banners.service.ts @@ -3,6 +3,7 @@ import { Observable, combineLatest, firstValueFrom, map, filter, mergeMap, take import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { @@ -21,6 +22,7 @@ export enum VisibleVaultBanner { OutdatedBrowser = "outdated-browser", Premium = "premium", VerifyEmail = "verify-email", + PendingAuthRequest = "pending-auth-request", } type PremiumBannerReprompt = { @@ -60,8 +62,23 @@ export class VaultBannersService { private kdfConfigService: KdfConfigService, private syncService: SyncService, private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private devicesService: DevicesServiceAbstraction, ) {} + /** Returns true when the pending auth request banner should be shown */ + async shouldShowPendingAuthRequestBanner(userId: UserId): Promise { + const devices = await firstValueFrom(this.devicesService.getDevices$()); + const hasPendingRequest = devices.some( + (device) => device.response?.devicePendingAuthRequest != null, + ); + + const alreadyDismissed = (await this.getBannerDismissedState(userId)).includes( + VisibleVaultBanner.PendingAuthRequest, + ); + + return hasPendingRequest && !alreadyDismissed; + } + shouldShowPremiumBanner$(userId: UserId): Observable { const premiumBannerState = this.premiumBannerState(userId); const premiumSources$ = combineLatest([ diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html index 29909e26716..e97f1579f57 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.html @@ -50,6 +50,20 @@ + + {{ "youHaveAPendingLoginRequest" | i18n }} + + {{ "reviewLoginRequest" | i18n }} + + + { let component: VaultBannersComponent; let fixture: ComponentFixture; + let messageSubject: Subject<{ command: string }>; const premiumBanner$ = new BehaviorSubject(false); + const pendingAuthRequest$ = new BehaviorSubject(false); const mockUserId = Utils.newGuid() as UserId; const bannerService = mock({ - shouldShowPremiumBanner$: jest.fn((userId$: Observable) => premiumBanner$), + shouldShowPremiumBanner$: jest.fn((userId: UserId) => premiumBanner$), shouldShowUpdateBrowserBanner: jest.fn(), shouldShowVerifyEmailBanner: jest.fn(), shouldShowLowKDFBanner: jest.fn(), + shouldShowPendingAuthRequestBanner: jest.fn((userId: UserId) => + Promise.resolve(pendingAuthRequest$.value), + ), dismissBanner: jest.fn(), }); const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); beforeEach(async () => { + messageSubject = new Subject<{ command: string }>(); bannerService.shouldShowUpdateBrowserBanner.mockResolvedValue(false); bannerService.shouldShowVerifyEmailBanner.mockResolvedValue(false); bannerService.shouldShowLowKDFBanner.mockResolvedValue(false); - + pendingAuthRequest$.next(false); premiumBanner$.next(false); await TestBed.configureTestingModule({ @@ -74,6 +81,12 @@ describe("VaultBannersComponent", () => { provide: AccountService, useValue: accountService, }, + { + provide: MessageListener, + useValue: mock({ + allMessages$: messageSubject.asObservable(), + }), + }, ], }) .overrideProvider(VaultBannersService, { useValue: bannerService }) @@ -153,5 +166,76 @@ describe("VaultBannersComponent", () => { }); }); }); + + describe("PendingAuthRequest", () => { + beforeEach(async () => { + pendingAuthRequest$.next(true); + await component.ngOnInit(); + fixture.detectChanges(); + }); + + it("shows pending auth request banner", async () => { + expect(component.visibleBanners).toEqual([VisibleVaultBanner.PendingAuthRequest]); + }); + + it("dismisses pending auth request banner", async () => { + const dismissButton = fixture.debugElement.nativeElement.querySelector( + 'button[biticonbutton="bwi-close"]', + ); + + pendingAuthRequest$.next(false); + dismissButton.click(); + fixture.detectChanges(); + + expect(bannerService.dismissBanner).toHaveBeenCalledWith( + mockUserId, + VisibleVaultBanner.PendingAuthRequest, + ); + + // Wait for async operations to complete + await fixture.whenStable(); + await component.determineVisibleBanners(); + fixture.detectChanges(); + + expect(component.visibleBanners).toEqual([]); + }); + }); + }); + + describe("message listener", () => { + beforeEach(async () => { + bannerService.shouldShowPendingAuthRequestBanner.mockResolvedValue(true); + messageSubject.next({ command: "openLoginApproval" }); + fixture.detectChanges(); + }); + + it("adds pending auth request banner when openLoginApproval message is received", async () => { + await component.ngOnInit(); + messageSubject.next({ command: "openLoginApproval" }); + fixture.detectChanges(); + + expect(component.visibleBanners).toContain(VisibleVaultBanner.PendingAuthRequest); + }); + + it("does not add duplicate pending auth request banner", async () => { + await component.ngOnInit(); + messageSubject.next({ command: "openLoginApproval" }); + messageSubject.next({ command: "openLoginApproval" }); + fixture.detectChanges(); + + const bannerCount = component.visibleBanners.filter( + (b) => b === VisibleVaultBanner.PendingAuthRequest, + ).length; + expect(bannerCount).toBe(1); + }); + + it("ignores other message types", async () => { + bannerService.shouldShowPendingAuthRequestBanner.mockResolvedValue(false); + await component.ngOnInit(); + messageSubject.next({ command: "someOtherCommand" }); + fixture.detectChanges(); + + expect(component.visibleBanners).not.toContain(VisibleVaultBanner.PendingAuthRequest); + }); }); }); diff --git a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts index 5a0c0a535b4..5f5fc1e218d 100644 --- a/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-banners/vault-banners.component.ts @@ -1,11 +1,12 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Component, Input, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { firstValueFrom, map, Observable, switchMap } from "rxjs"; +import { firstValueFrom, map, Observable, switchMap, filter } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessageListener } from "@bitwarden/common/platform/messaging"; +import { UserId } from "@bitwarden/common/types/guid"; import { BannerModule } from "@bitwarden/components"; import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component"; @@ -34,10 +35,24 @@ export class VaultBannersComponent implements OnInit { private router: Router, private i18nService: I18nService, private accountService: AccountService, + private messageListener: MessageListener, ) { this.premiumBannerVisible$ = this.activeUserId$.pipe( + filter((userId): userId is UserId => userId != null), switchMap((userId) => this.vaultBannerService.shouldShowPremiumBanner$(userId)), ); + + // Listen for auth request messages and show banner immediately + this.messageListener.allMessages$ + .pipe( + filter((message: { command: string }) => message.command === "openLoginApproval"), + takeUntilDestroyed(), + ) + .subscribe(() => { + if (!this.visibleBanners.includes(VisibleVaultBanner.PendingAuthRequest)) { + this.visibleBanners = [...this.visibleBanners, VisibleVaultBanner.PendingAuthRequest]; + } + }); } async ngOnInit(): Promise { @@ -46,8 +61,10 @@ export class VaultBannersComponent implements OnInit { async dismissBanner(banner: VisibleVaultBanner): Promise { const activeUserId = await firstValueFrom(this.activeUserId$); + if (!activeUserId) { + return; + } await this.vaultBannerService.dismissBanner(activeUserId, banner); - await this.determineVisibleBanners(); } @@ -63,19 +80,26 @@ export class VaultBannersComponent implements OnInit { } /** Determine which banners should be present */ - private async determineVisibleBanners(): Promise { + async determineVisibleBanners(): Promise { const activeUserId = await firstValueFrom(this.activeUserId$); + if (!activeUserId) { + return; + } + const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner(activeUserId); const showVerifyEmail = await this.vaultBannerService.shouldShowVerifyEmailBanner(activeUserId); const showLowKdf = await this.vaultBannerService.shouldShowLowKDFBanner(activeUserId); + const showPendingAuthRequest = + await this.vaultBannerService.shouldShowPendingAuthRequestBanner(activeUserId); this.visibleBanners = [ showBrowserOutdated ? VisibleVaultBanner.OutdatedBrowser : null, showVerifyEmail ? VisibleVaultBanner.VerifyEmail : null, showLowKdf ? VisibleVaultBanner.KDFSettings : null, - ].filter(Boolean); // remove all falsy values, i.e. null + showPendingAuthRequest ? VisibleVaultBanner.PendingAuthRequest : null, + ].filter((banner): banner is VisibleVaultBanner => banner !== null); // ensures the filtered array contains only VisibleVaultBanner values } freeTrialMessage(organization: FreeTrial) { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index b6fb8279a72..352dfd1fc72 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4127,6 +4127,12 @@ "updateBrowserDesc": { "message": "You are using an unsupported web browser. The web vault may not function properly." }, + "youHaveAPendingLoginRequest": { + "message": "You have a pending login request from another device." + }, + "reviewLoginRequest": { + "message": "Review login request" + }, "freeTrialEndPromptCount": { "message": "Your free trial ends in $COUNT$ days.", "placeholders": { diff --git a/libs/common/src/auth/abstractions/devices/views/device.view.ts b/libs/common/src/auth/abstractions/devices/views/device.view.ts index 22e522b9eb0..f3b78f216e0 100644 --- a/libs/common/src/auth/abstractions/devices/views/device.view.ts +++ b/libs/common/src/auth/abstractions/devices/views/device.view.ts @@ -1,20 +1,19 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { DeviceType } from "../../../../enums"; import { View } from "../../../../models/view/view"; import { DeviceResponse } from "../responses/device.response"; export class DeviceView implements View { - id: string; - userId: string; - name: string; - identifier: string; - type: DeviceType; - creationDate: string; - revisionDate: string; - response: DeviceResponse; + id: string | undefined; + userId: string | undefined; + name: string | undefined; + identifier: string | undefined; + type: DeviceType | undefined; + creationDate: string | undefined; + revisionDate: string | undefined; + response: DeviceResponse | undefined; constructor(deviceResponse: DeviceResponse) { Object.assign(this, deviceResponse); + this.response = deviceResponse; } } diff --git a/libs/common/src/auth/models/response/auth-request.response.ts b/libs/common/src/auth/models/response/auth-request.response.ts index d0c5d663065..88e7c542fae 100644 --- a/libs/common/src/auth/models/response/auth-request.response.ts +++ b/libs/common/src/auth/models/response/auth-request.response.ts @@ -6,7 +6,9 @@ const RequestTimeOut = 60000 * 15; //15 Minutes export class AuthRequestResponse extends BaseResponse { id: string; publicKey: string; - requestDeviceType: DeviceType; + requestDeviceType: string; + requestDeviceTypeValue: DeviceType; + requestDeviceIdentifier: string; requestIpAddress: string; key: string; // could be either an encrypted MasterKey or an encrypted UserKey masterPasswordHash: string; // if hash is present, the `key` above is an encrypted MasterKey (else `key` is an encrypted UserKey) @@ -21,6 +23,8 @@ export class AuthRequestResponse extends BaseResponse { this.id = this.getResponseProperty("Id"); this.publicKey = this.getResponseProperty("PublicKey"); this.requestDeviceType = this.getResponseProperty("RequestDeviceType"); + this.requestDeviceTypeValue = this.getResponseProperty("RequestDeviceTypeValue"); + this.requestDeviceIdentifier = this.getResponseProperty("RequestDeviceIdentifier"); this.requestIpAddress = this.getResponseProperty("RequestIpAddress"); this.key = this.getResponseProperty("Key"); this.masterPasswordHash = this.getResponseProperty("MasterPasswordHash");