1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +00:00

refactor(device-management): Auth/[PM-19823] Cleanup DeviceManagementOldComponent (#16541)

This commit is contained in:
rr-bw
2025-09-23 08:47:39 -07:00
committed by GitHub
parent e0c900cb77
commit d24524b33c
4 changed files with 5 additions and 682 deletions

View File

@@ -1,104 +0,0 @@
<bit-container>
<div class="tw-mt-6 tw-mb-2 tw-pb-2.5">
<div class="tw-flex tw-items-center tw-gap-2">
<h1 class="tw-m-0">{{ "devices" | i18n }}</h1>
<button
[bitPopoverTriggerFor]="infoPopover"
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-flex tw-items-center tw-size-4"
[position]="'right-start'"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</button>
<bit-popover [title]="'whatIsADevice' | i18n" #infoPopover>
<p class="tw-mb-0">{{ "aDeviceIs" | i18n }}</p>
</bit-popover>
<i
*ngIf="asyncActionLoading"
class="bwi bwi-spinner bwi-spin tw-flex tw-items-center tw-size-4"
aria-hidden="true"
></i>
</div>
</div>
<p>{{ "deviceListDescriptionTemp" | i18n }}</p>
<div *ngIf="loading" class="tw-flex tw-justify-center tw-items-center tw-p-4">
<i class="bwi bwi-spinner bwi-spin tw-text-2xl" aria-hidden="true"></i>
</div>
<bit-table-scroll *ngIf="!loading" [dataSource]="dataSource" [rowSize]="50">
<ng-container header>
<th
*ngFor="let col of columnConfig"
[class]="col.headerClass"
bitCell
[bitSortable]="col.sortable ? col.name : null"
[default]="col.name === 'loginStatus' ? 'desc' : null"
scope="col"
role="columnheader"
>
{{ col.title }}
</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">
<div class="tw-flex tw-items-center tw-justify-center tw-w-10">
<i [class]="getDeviceIcon(row.type)" class="bwi-lg" aria-hidden="true"></i>
</div>
<div>
<ng-container *ngIf="row.hasPendingAuthRequest">
<a bitLink href="#" appStopClick (click)="managePendingAuthRequest(row)">
{{ row.displayName }}
</a>
<span class="tw-text-sm tw-text-muted tw-block">
{{ "needsApproval" | i18n }}
</span>
</ng-container>
<ng-container *ngIf="!row.hasPendingAuthRequest">
{{ row.displayName }}
<span
*ngIf="row.trusted && !row.hasPendingAuthRequest"
class="tw-text-sm tw-text-muted tw-block"
>
{{ "trusted" | i18n }}
</span>
</ng-container>
</div>
</td>
<td bitCell>
<span *ngIf="isCurrentDevice(row)" bitBadge variant="primary">{{
"currentSession" | i18n
}}</span>
<span *ngIf="row.hasPendingAuthRequest" bitBadge variant="warning">{{
"requestPending" | i18n
}}</span>
</td>
<td bitCell>{{ row.firstLogin | date: "medium" }}</td>
<!-- <td bitCell>
<button
type="button"
bitIconButton="bwi-ellipsis-v"
[bitMenuTriggerFor]="optionsMenu"
></button>
<bit-menu #optionsMenu>
Remove device button to be re-added later when we have per device session de-authentication.
<button
type="button"
bitMenuItem
(click)="removeDevice(row)"
[disabled]="isCurrentDevice(row)"
>
<span [class]="isCurrentDevice(row) ? 'tw-text-muted' : 'tw-text-danger'">
<i class="bwi bwi-trash" aria-hidden="true"></i>
{{ "removeDevice" | i18n }}
</span>
</button>
</bit-menu>
</td> -->
</ng-template>
</bit-table-scroll>
</bit-container>

View File

@@ -1,193 +0,0 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RouterTestingModule } from "@angular/router/testing";
import { of, Subject } from "rxjs";
import { AuthRequestApiServiceAbstraction } 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,
LayoutComponent,
} from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { VaultBannersService } from "../../../vault/individual-vault/vault-banners/services/vault-banners.service";
import { DeviceManagementOldComponent } from "./device-management-old.component";
class MockResizeObserver {
observe = jest.fn();
unobserve = jest.fn();
disconnect = jest.fn();
}
global.ResizeObserver = MockResizeObserver;
interface Message {
command: string;
notificationId?: string;
}
describe("DeviceManagementOldComponent", () => {
let fixture: ComponentFixture<DeviceManagementOldComponent>;
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,
DeviceManagementOldComponent,
],
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: AuthRequestApiServiceAbstraction,
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(),
},
},
{
provide: LayoutComponent,
useValue: {
mainContent: jest.fn(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(DeviceManagementOldComponent);
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,373 +0,0 @@
import { CommonModule } from "@angular/common";
import { Component, DestroyRef } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom } from "rxjs";
import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval";
import { AuthRequestApiServiceAbstraction } 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-old.component.html",
imports: [CommonModule, SharedModule, TableModule, PopoverModule],
})
export class DeviceManagementOldComponent {
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: AuthRequestApiServiceAbstraction,
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 = LoginApprovalDialogComponent.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);
}
}
}

View File

@@ -2,12 +2,9 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component";
import { DeviceManagementOldComponent } from "./device-management-old.component";
import { PasswordSettingsComponent } from "./password-settings/password-settings.component";
import { SecurityKeysComponent } from "./security-keys.component";
import { SecurityComponent } from "./security.component";
@@ -34,15 +31,11 @@ const routes: Routes = [
component: SecurityKeysComponent,
data: { titleId: "keys" },
},
...featureFlaggedRoute({
defaultComponent: DeviceManagementOldComponent,
flaggedComponent: DeviceManagementComponent,
featureFlag: FeatureFlag.PM14938_BrowserExtensionLoginApproval,
routeOptions: {
path: "device-management",
data: { titleId: "devices" },
},
}),
{
path: "device-management",
component: DeviceManagementComponent,
data: { titleId: "devices" },
},
],
},
];