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");
|