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:
parent
bc7c22ae01
commit
cbbd53803b
@ -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">
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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");
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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([
|
||||
|
@ -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)"
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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": {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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");
|
||||
|
Loading…
Reference in New Issue
Block a user