mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23:34 +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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user