1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 05:53:42 +00:00
Files
browser/apps/web/src/app/auth/settings/security/device-management.component.ts
Alec Rippberger 87847dc806 fix: check device id and creationDate for falsey values
This commit adds validation to check for falsey values in device 'id' and 'creationDate' fields in the device management component. This prevents potential issues when these string values are empty or otherwise evaluate to false.

Resolves PM-18757
2025-03-20 15:23:19 -05:00

375 lines
12 KiB
TypeScript

import { CommonModule } from "@angular/common";
import { Component, DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
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,
DeviceResponse,
} from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view";
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,
TableDataSource,
TableModule,
PopoverModule,
} from "@bitwarden/components";
import { SharedModule } from "../../../shared";
/**
* Interface representing a row in the device management table
*/
interface DeviceTableData {
id: string;
type: DeviceType;
displayName: string;
loginStatus: string;
firstLogin: Date;
trusted: boolean;
devicePendingAuthRequest: DevicePendingAuthRequest | null;
hasPendingAuthRequest: boolean;
identifier: string;
}
/**
* Provides a table of devices and allows the user to log out, approve or remove a device
*/
@Component({
selector: "app-device-management",
templateUrl: "./device-management.component.html",
standalone: true,
imports: [CommonModule, SharedModule, TableModule, PopoverModule],
})
export class DeviceManagementComponent {
protected dataSource = new TableDataSource<DeviceTableData>();
protected currentDevice: DeviceView | undefined;
protected loading = true;
protected asyncActionLoading = false;
constructor(
private i18nService: I18nService,
private devicesService: DevicesServiceAbstraction,
private dialogService: DialogService,
private toastService: ToastService,
private validationService: ValidationService,
private messageListener: MessageListener,
private authRequestApiService: AuthRequestApiService,
private destroyRef: DestroyRef,
) {
void this.initializeDevices();
}
/**
* Initialize the devices list and set up the message listener
*/
private async initializeDevices(): Promise<void> {
try {
await this.loadDevices();
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) {
this.validationService.showError(new Error(this.i18nService.t("deviceIdMissing")));
return null;
}
if (device.type == undefined) {
this.validationService.showError(new Error(this.i18nService.t("deviceTypeMissing")));
return null;
}
if (!device.creationDate) {
this.validationService.showError(
new Error(this.i18nService.t("deviceCreationDateMissing")),
);
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);
}
/**
* Column configuration for the table
*/
protected readonly columnConfig = [
{
name: "displayName",
title: this.i18nService.t("device"),
headerClass: "tw-w-1/3",
sortable: true,
},
{
name: "loginStatus",
title: this.i18nService.t("loginStatus"),
headerClass: "tw-w-1/3",
sortable: true,
},
{
name: "firstLogin",
title: this.i18nService.t("firstLogin"),
headerClass: "tw-w-1/3",
sortable: true,
},
];
/**
* Get the icon for a device type
* @param type - The device type
* @returns The icon for the device type
*/
getDeviceIcon(type: DeviceType): string {
const defaultIcon = "bwi bwi-desktop";
const categoryIconMap: Record<string, string> = {
webVault: "bwi bwi-browser",
desktop: "bwi bwi-desktop",
mobile: "bwi bwi-mobile",
cli: "bwi bwi-cli",
extension: "bwi bwi-puzzle",
sdk: "bwi bwi-desktop",
};
const metadata = DeviceTypeMetadata[type];
return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon;
}
/**
* Get the login status of a device
* It will return the current session if the device is the current device
* It will return the date of the pending auth request when available
* @param device - The device
* @returns The login status
*/
private getLoginStatus(device: DeviceView): string {
if (this.isCurrentDevice(device)) {
return this.i18nService.t("currentSession");
}
if (device?.response?.devicePendingAuthRequest?.creationDate) {
return this.i18nService.t("requestPending");
}
return "";
}
/**
* Get a human readable device type from the DeviceType enum
* @param type - The device type
* @returns The human readable device type
*/
private getHumanReadableDeviceType(type: DeviceType): string {
const metadata = DeviceTypeMetadata[type];
if (!metadata) {
return this.i18nService.t("unknownDevice");
}
// If the platform is "Unknown" translate it since it is not a proper noun
const platform =
metadata.platform === "Unknown" ? this.i18nService.t("unknown") : metadata.platform;
const category = this.i18nService.t(metadata.category);
return platform ? `${category} - ${platform}` : category;
}
/**
* Check if a device is the current device
* @param device - The device or device table data
* @returns True if the device is the current device, false otherwise
*/
protected isCurrentDevice(device: DeviceView | DeviceTableData): boolean {
return "response" in device
? device.id === this.currentDevice?.id
: device.id === this.currentDevice?.id;
}
/**
* Check if a device has a pending auth request
* @param device - The device response
* @returns True if the device has a pending auth request, false otherwise
*/
private hasPendingAuthRequest(device: DeviceResponse): boolean {
return (
device.devicePendingAuthRequest !== undefined && device.devicePendingAuthRequest !== null
);
}
/**
* Open a dialog to approve or deny a pending auth request for a device
*/
async managePendingAuthRequest(device: DeviceTableData) {
if (device.devicePendingAuthRequest === undefined || device.devicePendingAuthRequest === null) {
return;
}
const dialogRef = LoginApprovalComponent.open(this.dialogService, {
notificationId: device.devicePendingAuthRequest.id,
});
const result = await firstValueFrom(dialogRef.closed);
if (result !== undefined && typeof result === "boolean") {
// auth request approved or denied so reset
device.devicePendingAuthRequest = null;
device.hasPendingAuthRequest = false;
}
}
/**
* Remove a device
* @param device - The device
*/
protected async removeDevice(device: DeviceTableData) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "removeDevice" },
content: { key: "removeDeviceConfirmation" },
type: "warning",
});
if (!confirmed) {
return;
}
try {
this.asyncActionLoading = true;
await firstValueFrom(this.devicesService.deactivateDevice$(device.id));
this.asyncActionLoading = false;
// Remove the device from the data source
this.dataSource.data = this.dataSource.data.filter((d) => d.id !== device.id);
this.toastService.showToast({
title: "",
message: this.i18nService.t("deviceRemoved"),
variant: "success",
});
} catch (error) {
this.validationService.showError(error);
}
}
}