From d24524b33c66f4f0a503471439a85c04e76613b8 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Tue, 23 Sep 2025 08:47:39 -0700 Subject: [PATCH] refactor(device-management): Auth/[PM-19823] Cleanup DeviceManagementOldComponent (#16541) --- .../device-management-old.component.html | 104 ----- .../device-management-old.component.spec.ts | 193 --------- .../device-management-old.component.ts | 373 ------------------ .../security/security-routing.module.ts | 17 +- 4 files changed, 5 insertions(+), 682 deletions(-) delete mode 100644 apps/web/src/app/auth/settings/security/device-management-old.component.html delete mode 100644 apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts delete mode 100644 apps/web/src/app/auth/settings/security/device-management-old.component.ts diff --git a/apps/web/src/app/auth/settings/security/device-management-old.component.html b/apps/web/src/app/auth/settings/security/device-management-old.component.html deleted file mode 100644 index da01d0fe8f..0000000000 --- a/apps/web/src/app/auth/settings/security/device-management-old.component.html +++ /dev/null @@ -1,104 +0,0 @@ - -
-
-

{{ "devices" | i18n }}

- - -

{{ "aDeviceIs" | i18n }}

-
- -
-
- -

{{ "deviceListDescriptionTemp" | i18n }}

- -
- -
- - - - - {{ col.title }} - - - - - - -
- -
-
- - - {{ row.displayName }} - - - - {{ "needsApproval" | i18n }} - - - - {{ row.displayName }} - - {{ "trusted" | i18n }} - - -
- - - {{ - "currentSession" | i18n - }} - {{ - "requestPending" | i18n - }} - - {{ row.firstLogin | date: "medium" }} - -
-
-
diff --git a/apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts b/apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts deleted file mode 100644 index 64fb9003cc..0000000000 --- a/apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts +++ /dev/null @@ -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; - let messageSubject: Subject; - let mockDevices: DeviceView[]; - let vaultBannersService: VaultBannersService; - - const mockDeviceResponse = { - id: "test-id", - requestDeviceType: "test-type", - requestDeviceTypeValue: DeviceType.Android, - requestDeviceIdentifier: "test-identifier", - requestIpAddress: "127.0.0.1", - creationDate: new Date().toISOString(), - responseDate: null, - key: "test-key", - masterPasswordHash: null, - publicKey: "test-public-key", - requestApproved: false, - origin: "test-origin", - }; - - beforeEach(async () => { - messageSubject = new Subject(); - mockDevices = []; - - await TestBed.configureTestingModule({ - imports: [ - RouterTestingModule, - SharedModule, - TableModule, - PopoverModule, - 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); - }); - }); -}); diff --git a/apps/web/src/app/auth/settings/security/device-management-old.component.ts b/apps/web/src/app/auth/settings/security/device-management-old.component.ts deleted file mode 100644 index 816da6e873..0000000000 --- a/apps/web/src/app/auth/settings/security/device-management-old.component.ts +++ /dev/null @@ -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(); - 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 { - 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 { - const requestId = message.notificationId; - if (!requestId) { - return; - } - - const authRequestResponse = await this.authRequestApiService.getAuthRequest(requestId); - if (!authRequestResponse) { - return; - } - - // Add new device to the table - const upsertDevice: DeviceTableData = { - id: "", - type: authRequestResponse.requestDeviceTypeValue, - displayName: this.getHumanReadableDeviceType(authRequestResponse.requestDeviceTypeValue), - loginStatus: this.i18nService.t("requestPending"), - firstLogin: new Date(authRequestResponse.creationDate), - trusted: false, - devicePendingAuthRequest: { - id: authRequestResponse.id, - creationDate: authRequestResponse.creationDate, - }, - hasPendingAuthRequest: true, - identifier: authRequestResponse.requestDeviceIdentifier, - }; - - // If the device already exists in the DB, update the device id and first login date - if (authRequestResponse.requestDeviceIdentifier) { - const existingDevice = await firstValueFrom( - this.devicesService.getDeviceByIdentifier$(authRequestResponse.requestDeviceIdentifier), - ); - - if (existingDevice?.id && existingDevice.creationDate) { - upsertDevice.id = existingDevice.id; - upsertDevice.firstLogin = new Date(existingDevice.creationDate); - } - } - - const existingDeviceIndex = this.dataSource.data.findIndex( - (device) => device.identifier === upsertDevice.identifier, - ); - - if (existingDeviceIndex >= 0) { - // Update existing device - this.dataSource.data[existingDeviceIndex] = upsertDevice; - this.dataSource.data = [...this.dataSource.data]; - } else { - // Add new device - this.dataSource.data = [upsertDevice, ...this.dataSource.data]; - } - } - - /** - * Load current device and all devices - */ - private async loadDevices(): Promise { - try { - const currentDevice = await firstValueFrom(this.devicesService.getCurrentDevice$()); - const devices = await firstValueFrom(this.devicesService.getDevices$()); - - if (!currentDevice || !devices) { - this.loading = false; - return; - } - - this.currentDevice = new DeviceView(currentDevice); - this.updateDeviceTable(devices); - } catch (error) { - this.validationService.showError(error); - } finally { - this.loading = false; - } - } - - /** - * Updates the device table with the latest device data - * @param devices - Array of device views to display in the table - */ - private updateDeviceTable(devices: Array): void { - this.dataSource.data = devices - .map((device: DeviceView): DeviceTableData | null => { - if (!device.id) { - 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 = { - 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); - } - } -} diff --git a/apps/web/src/app/auth/settings/security/security-routing.module.ts b/apps/web/src/app/auth/settings/security/security-routing.module.ts index f758632938..ba476dc910 100644 --- a/apps/web/src/app/auth/settings/security/security-routing.module.ts +++ b/apps/web/src/app/auth/settings/security/security-routing.module.ts @@ -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" }, + }, ], }, ];