1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-25 02:51:59 +01:00

feat(web): [PM-15063] add banner for pending device auth requests

Adds a banner in the web vault to notify users when they have pending device authentication requests. The banner links to the device management screen. Also implements real-time updates to the device management table when new auth requests are received.

JIRA: PM-15063
This commit is contained in:
Alec Rippberger 2025-02-24 11:44:32 -06:00 committed by GitHub
parent bc7c22ae01
commit cbbd53803b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 565 additions and 48 deletions

View File

@ -40,7 +40,8 @@
>
{{ col.title }}
</th>
<th bitCell scope="col" role="columnheader"></th>
<!-- TODO: Add a column for the device actions when available -->
<!-- <th bitCell scope="col" role="columnheader"></th> -->
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell class="tw-flex tw-gap-2">

View File

@ -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<DeviceManagementComponent>;
let messageSubject: Subject<Message>;
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<Message>();
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);
});
});
});

View File

@ -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<DeviceTableData>();
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<DeviceView>]) => {
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<void> {
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<void> {
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<void> {
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<DeviceView>): 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");
}

View File

@ -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<Record<UserId, AccountInfo>>({
[userId]: { email: "test@bitwarden.com", emailVerified: true, name: "name" } as AccountInfo,
});
const devices$ = new BehaviorSubject<DeviceView[]>([]);
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);
});
});
});

View File

@ -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<boolean> {
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<boolean> {
const premiumBannerState = this.premiumBannerState(userId);
const premiumSources$ = combineLatest([

View File

@ -50,6 +50,20 @@
</a>
</bit-banner>
<bit-banner
id="pending-auth-request-banner"
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
bannerType="info"
icon="bwi-info-circle"
*ngIf="visibleBanners.includes(VisibleVaultBanner.PendingAuthRequest)"
(onClose)="dismissBanner(VisibleVaultBanner.PendingAuthRequest)"
>
{{ "youHaveAPendingLoginRequest" | i18n }}
<a bitLink linkType="secondary" routerLink="/settings/security/device-management">
{{ "reviewLoginRequest" | i18n }}
</a>
</bit-banner>
<app-verify-email
id="verify-email-banner"
*ngIf="visibleBanners.includes(VisibleVaultBanner.VerifyEmail)"

View File

@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { RouterTestingModule } from "@angular/router/testing";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, Observable } from "rxjs";
import { BehaviorSubject, Subject } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@ -10,6 +10,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { MessageListener } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
@ -24,24 +25,30 @@ import { VaultBannersComponent } from "./vault-banners.component";
describe("VaultBannersComponent", () => {
let component: VaultBannersComponent;
let fixture: ComponentFixture<VaultBannersComponent>;
let messageSubject: Subject<{ command: string }>;
const premiumBanner$ = new BehaviorSubject<boolean>(false);
const pendingAuthRequest$ = new BehaviorSubject<boolean>(false);
const mockUserId = Utils.newGuid() as UserId;
const bannerService = mock<VaultBannersService>({
shouldShowPremiumBanner$: jest.fn((userId$: Observable<UserId>) => 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<MessageListener>({
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);
});
});
});

View File

@ -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<void> {
@ -46,8 +61,10 @@ export class VaultBannersComponent implements OnInit {
async dismissBanner(banner: VisibleVaultBanner): Promise<void> {
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<void> {
async determineVisibleBanners(): Promise<void> {
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) {

View File

@ -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": {

View File

@ -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;
}
}

View File

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