1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

fix(device-management-sorting): [Auth/PM-24046] Device Management Sorting (#15889)

This PR makes it so the devices are always sorted in this order (by default):

1. Has Pending Auth Request (if any) comes first
2. Current Device comes second (or first if there are no pending auth requests)
3. First Login Date - the rest of the devices are sorted by first login date (newest to oldest)

This sort order is preserved even after a user approves/denies and auth request - that is, the approved/denied device will re-sort to its correct position according to it's first login date.

Feature Flag: `PM14938_BrowserExtensionLoginApproval`
This commit is contained in:
rr-bw
2025-08-07 13:46:02 -07:00
committed by GitHub
parent b57238ca99
commit c5e5417abf
7 changed files with 51 additions and 71 deletions

View File

@@ -6,8 +6,7 @@
bit-item-content
type="button"
[attr.tabindex]="device.pendingAuthRequest != null ? 0 : null"
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
(keydown.enter)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
(click)="answerAuthRequest(device.pendingAuthRequest)"
>
<!-- Default Content -->
<span class="tw-text-base">{{ device.displayName }}</span>
@@ -21,7 +20,7 @@
<!-- Secondary Content -->
<span slot="secondary" class="tw-text-sm">
<span>{{ "needsApproval" | i18n }}</span>
<br />
<div>
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
<span>{{ device.firstLogin | date: "medium" }}</span>

View File

@@ -1,15 +1,11 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { BadgeModule, DialogService, ItemModule } from "@bitwarden/components";
import { BadgeModule, ItemModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { LoginApprovalDialogComponent } from "../login-approval/login-approval-dialog.component";
import { DeviceDisplayData } from "./device-management.component";
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
/** Displays user devices in an item list view */
@Component({
@@ -20,24 +16,12 @@ import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
})
export class DeviceManagementItemGroupComponent {
@Input() devices: DeviceDisplayData[] = [];
@Output() onAuthRequestAnswered = new EventEmitter<DevicePendingAuthRequest>();
constructor(private dialogService: DialogService) {}
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
protected answerAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
if (pendingAuthRequest == null) {
return;
}
const loginApprovalDialog = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
const result = await firstValueFrom(loginApprovalDialog.closed);
if (result !== undefined && typeof result === "boolean") {
// Auth request was approved or denied, so clear the
// pending auth request and re-sort the device array
this.devices = clearAuthRequestAndResortDevices(this.devices, pendingAuthRequest);
}
this.onAuthRequestAnswered.emit(pendingAuthRequest);
}
}

View File

@@ -1,4 +1,4 @@
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="50">
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="64">
<!-- Table Header -->
<ng-container header>
<th
@@ -6,7 +6,6 @@
[class]="column.headerClass"
bitCell
[bitSortable]="column.sortable ? column.name : ''"
[default]="column.name === 'loginStatus' ? 'desc' : false"
scope="col"
role="columnheader"
>
@@ -17,24 +16,17 @@
<!-- Table Rows -->
<ng-template bitRowDef let-device>
<!-- Column: Device Name -->
<td bitCell class="tw-flex tw-gap-2">
<td bitCell class="tw-flex tw-gap-2 tw-items-center tw-h-16">
<div class="tw-flex tw-items-center tw-justify-center tw-w-10">
<i [class]="device.icon" class="bwi-lg" aria-hidden="true"></i>
</div>
<div>
@if (device.pendingAuthRequest) {
<a
bitLink
href="#"
appStopClick
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
>
<a bitLink href="#" appStopClick (click)="answerAuthRequest(device.pendingAuthRequest)">
{{ device.displayName }}
</a>
<div class="tw-text-sm tw-text-muted">
{{ "needsApproval" | i18n }}
</div>
<br />
} @else {
<span>{{ device.displayName }}</span>
<div *ngIf="device.isTrusted" class="tw-text-sm tw-text-muted">

View File

@@ -1,6 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
@@ -8,16 +7,12 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import {
BadgeModule,
ButtonModule,
DialogService,
LinkModule,
TableDataSource,
TableModule,
} from "@bitwarden/components";
import { LoginApprovalDialogComponent } from "../login-approval/login-approval-dialog.component";
import { DeviceDisplayData } from "./device-management.component";
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
/** Displays user devices in a sortable table view */
@Component({
@@ -28,6 +23,8 @@ import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
})
export class DeviceManagementTableComponent implements OnChanges {
@Input() devices: DeviceDisplayData[] = [];
@Output() onAuthRequestAnswered = new EventEmitter<DevicePendingAuthRequest>();
protected tableDataSource = new TableDataSource<DeviceDisplayData>();
protected readonly columnConfig = [
@@ -51,10 +48,7 @@ export class DeviceManagementTableComponent implements OnChanges {
},
];
constructor(
private i18nService: I18nService,
private dialogService: DialogService,
) {}
constructor(private i18nService: I18nService) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes.devices) {
@@ -62,24 +56,10 @@ export class DeviceManagementTableComponent implements OnChanges {
}
}
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
protected answerAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
if (pendingAuthRequest == null) {
return;
}
const loginApprovalDialog = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
const result = await firstValueFrom(loginApprovalDialog.closed);
if (result !== undefined && typeof result === "boolean") {
// Auth request was approved or denied, so clear the
// pending auth request and re-sort the device array
this.tableDataSource.data = clearAuthRequestAndResortDevices(
this.devices,
pendingAuthRequest,
);
}
this.onAuthRequestAnswered.emit(pendingAuthRequest);
}
}

View File

@@ -30,11 +30,13 @@
<auth-device-management-table
ngClass="tw-hidden md:tw-block"
[devices]="devices"
(onAuthRequestAnswered)="handleAuthRequestAnswered($event)"
></auth-device-management-table>
<!-- List View: displays on small screens -->
<auth-device-management-item-group
ngClass="md:tw-hidden"
[devices]="devices"
(onAuthRequestAnswered)="handleAuthRequestAnswered($event)"
></auth-device-management-item-group>
}

View File

@@ -16,14 +16,18 @@ 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 { ButtonModule, PopoverModule } from "@bitwarden/components";
import { ButtonModule, DialogService, PopoverModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { LoginApprovalDialogComponent } from "../login-approval";
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
import { DeviceManagementItemGroupComponent } from "./device-management-item-group.component";
import { DeviceManagementTableComponent } from "./device-management-table.component";
import { clearAuthRequestAndResortDevices, resortDevices } from "./resort-devices.helper";
export interface DeviceDisplayData {
creationDate: string;
displayName: string;
firstLogin: Date;
icon: string;
@@ -66,6 +70,7 @@ export class DeviceManagementComponent implements OnInit {
private destroyRef: DestroyRef,
private deviceManagementComponentService: DeviceManagementComponentServiceAbstraction,
private devicesService: DevicesServiceAbstraction,
private dialogService: DialogService,
private i18nService: I18nService,
private messageListener: MessageListener,
private validationService: ValidationService,
@@ -130,6 +135,7 @@ export class DeviceManagementComponent implements OnInit {
}
return {
creationDate: device.creationDate,
displayName: this.devicesService.getReadableDeviceTypeName(device.type),
firstLogin: device.creationDate ? new Date(device.creationDate) : new Date(),
icon: this.getDeviceIcon(device.type),
@@ -141,7 +147,8 @@ export class DeviceManagementComponent implements OnInit {
pendingAuthRequest: device.response?.devicePendingAuthRequest ?? null,
};
})
.filter((device) => device !== null);
.filter((device) => device !== null)
.sort(resortDevices);
}
private async upsertDeviceWithPendingAuthRequest(authRequestId: string) {
@@ -151,6 +158,7 @@ export class DeviceManagementComponent implements OnInit {
}
const upsertDevice: DeviceDisplayData = {
creationDate: "",
displayName: this.devicesService.getReadableDeviceTypeName(
authRequestResponse.requestDeviceTypeValue,
),
@@ -174,8 +182,9 @@ export class DeviceManagementComponent implements OnInit {
);
if (existingDevice?.id && existingDevice.creationDate) {
upsertDevice.id = existingDevice.id;
upsertDevice.creationDate = existingDevice.creationDate;
upsertDevice.firstLogin = new Date(existingDevice.creationDate);
upsertDevice.id = existingDevice.id;
}
}
@@ -186,10 +195,10 @@ export class DeviceManagementComponent implements OnInit {
if (existingDeviceIndex >= 0) {
// Update existing device in device list
this.devices[existingDeviceIndex] = upsertDevice;
this.devices = [...this.devices];
this.devices = [...this.devices].sort(resortDevices);
} else {
// Add new device to device list
this.devices = [upsertDevice, ...this.devices];
this.devices = [upsertDevice, ...this.devices].sort(resortDevices);
}
}
@@ -227,4 +236,18 @@ export class DeviceManagementComponent implements OnInit {
const metadata = DeviceTypeMetadata[type];
return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon;
}
protected async handleAuthRequestAnswered(pendingAuthRequest: DevicePendingAuthRequest) {
const loginApprovalDialog = LoginApprovalDialogComponent.open(this.dialogService, {
notificationId: pendingAuthRequest.id,
});
const result = await firstValueFrom(loginApprovalDialog.closed);
if (result !== undefined && typeof result === "boolean") {
// Auth request was approved or denied, so clear the
// pending auth request and re-sort the device array
this.devices = clearAuthRequestAndResortDevices(this.devices, pendingAuthRequest);
}
}
}

View File

@@ -23,7 +23,7 @@ export function clearAuthRequestAndResortDevices(
*
* This is a helper function that gets passed to the `Array.sort()` method
*/
function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
export function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
// Devices with a pending auth request should be first
if (deviceA.pendingAuthRequest) {
return -1;
@@ -40,11 +40,11 @@ function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
return 1;
}
// Then sort the rest by display name (alphabetically)
if (deviceA.displayName < deviceB.displayName) {
// Then sort the rest by creation date (newest to oldest)
if (deviceA.creationDate > deviceB.creationDate) {
return -1;
}
if (deviceA.displayName > deviceB.displayName) {
if (deviceA.creationDate < deviceB.creationDate) {
return 1;
}