mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 05:43:41 +00:00
feat(extension-login-approvals): [Auth/PM-14939] devices list view for browser (#14620)
Creates a new `DeviceManagementComponent` that fetches devices and formats them before handing them off to a view component for display. View components: - `DeviceManagementTableComponent` - displays on medium to large screens - `DeviceManagementItemGroupComponent` - displays on small screens Feature flag: `PM14938_BrowserExtensionLoginApproval`
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
|
||||
|
||||
/**
|
||||
* Default implementation of the device management component service
|
||||
*/
|
||||
export class DefaultDeviceManagementComponentService
|
||||
implements DeviceManagementComponentServiceAbstraction
|
||||
{
|
||||
/**
|
||||
* Show header information in web client
|
||||
*/
|
||||
showHeaderInformation(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Service abstraction for device management component
|
||||
* Used to determine client-specific behavior
|
||||
*/
|
||||
export abstract class DeviceManagementComponentServiceAbstraction {
|
||||
/**
|
||||
* Whether to show header information (title, description, etc.) in the device management component
|
||||
* @returns true if header information should be shown, false otherwise
|
||||
*/
|
||||
abstract showHeaderInformation(): boolean;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<bit-item-group>
|
||||
<bit-item *ngFor="let device of devices">
|
||||
@if (device.pendingAuthRequest) {
|
||||
<button
|
||||
class="tw-relative"
|
||||
bit-item-content
|
||||
type="button"
|
||||
[attr.tabindex]="device.pendingAuthRequest != null ? 0 : null"
|
||||
(click)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
|
||||
(keydown.enter)="approveOrDenyAuthRequest(device.pendingAuthRequest)"
|
||||
>
|
||||
<!-- Default Content -->
|
||||
<span class="tw-text-base">{{ device.displayName }}</span>
|
||||
|
||||
<!-- Default Trailing Content -->
|
||||
<span class="tw-absolute tw-top-[6px] tw-right-3" slot="default-trailing">
|
||||
<span bitBadge variant="warning">
|
||||
{{ "requestPending" | i18n }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Secondary Content -->
|
||||
<span slot="secondary" class="tw-text-sm">
|
||||
<span>{{ "needsApproval" | i18n }}</span>
|
||||
<div>
|
||||
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
|
||||
<span>{{ device.firstLogin | date: "medium" }}</span>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
} @else {
|
||||
<bit-item-content ngClass="tw-relative">
|
||||
<!-- Default Content -->
|
||||
<span class="tw-text-base">{{ device.displayName }}</span>
|
||||
|
||||
<!-- Default Trailing Content -->
|
||||
<div
|
||||
*ngIf="device.isCurrentDevice"
|
||||
class="tw-absolute tw-top-[6px] tw-right-3"
|
||||
slot="default-trailing"
|
||||
>
|
||||
<span bitBadge variant="primary">
|
||||
{{ "currentSession" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Secondary Content -->
|
||||
<div slot="secondary" class="tw-text-sm">
|
||||
@if (device.isTrusted) {
|
||||
<span>{{ "trusted" | i18n }}</span>
|
||||
} @else {
|
||||
<br />
|
||||
}
|
||||
|
||||
<div>
|
||||
<span class="tw-font-semibold">{{ "firstLogin" | i18n }}: </span>
|
||||
<span>{{ device.firstLogin | date: "medium" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</bit-item-content>
|
||||
}
|
||||
</bit-item>
|
||||
</bit-item-group>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
|
||||
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
|
||||
import { BadgeModule, DialogService, ItemModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { DeviceDisplayData } from "./device-management.component";
|
||||
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
|
||||
|
||||
/** Displays user devices in an item list view */
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "auth-device-management-item-group",
|
||||
templateUrl: "./device-management-item-group.component.html",
|
||||
imports: [BadgeModule, CommonModule, ItemModule, I18nPipe],
|
||||
})
|
||||
export class DeviceManagementItemGroupComponent {
|
||||
@Input() devices: DeviceDisplayData[] = [];
|
||||
|
||||
constructor(private dialogService: DialogService) {}
|
||||
|
||||
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
|
||||
if (pendingAuthRequest == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginApprovalDialog = LoginApprovalComponent.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<bit-table-scroll [dataSource]="tableDataSource" [rowSize]="50">
|
||||
<!-- Table Header -->
|
||||
<ng-container header>
|
||||
<th
|
||||
*ngFor="let column of columnConfig"
|
||||
[class]="column.headerClass"
|
||||
bitCell
|
||||
[bitSortable]="column.sortable ? column.name : ''"
|
||||
[default]="column.name === 'loginStatus' ? 'desc' : false"
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
>
|
||||
{{ column.title }}
|
||||
</th>
|
||||
</ng-container>
|
||||
|
||||
<!-- Table Rows -->
|
||||
<ng-template bitRowDef let-device>
|
||||
<!-- Column: Device Name -->
|
||||
<td bitCell class="tw-flex tw-gap-2">
|
||||
<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)"
|
||||
>
|
||||
{{ device.displayName }}
|
||||
</a>
|
||||
<div class="tw-text-sm tw-text-muted">
|
||||
{{ "needsApproval" | i18n }}
|
||||
</div>
|
||||
} @else {
|
||||
<span>{{ device.displayName }}</span>
|
||||
<div *ngIf="device.isTrusted" class="tw-text-sm tw-text-muted">
|
||||
{{ "trusted" | i18n }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Column: Login Status -->
|
||||
<td bitCell>
|
||||
<div class="tw-flex tw-gap-1">
|
||||
<span *ngIf="device.isCurrentDevice" bitBadge variant="primary">
|
||||
{{ "currentSession" | i18n }}
|
||||
</span>
|
||||
<span *ngIf="device.pendingAuthRequest" bitBadge variant="warning">
|
||||
{{ "requestPending" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Column: First Login -->
|
||||
<td bitCell>{{ device.firstLogin | date: "medium" }}</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnChanges, SimpleChanges } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
|
||||
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
DialogService,
|
||||
LinkModule,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { DeviceDisplayData } from "./device-management.component";
|
||||
import { clearAuthRequestAndResortDevices } from "./resort-devices.helper";
|
||||
|
||||
/** Displays user devices in a sortable table view */
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "auth-device-management-table",
|
||||
templateUrl: "./device-management-table.component.html",
|
||||
imports: [BadgeModule, ButtonModule, CommonModule, JslibModule, LinkModule, TableModule],
|
||||
})
|
||||
export class DeviceManagementTableComponent implements OnChanges {
|
||||
@Input() devices: DeviceDisplayData[] = [];
|
||||
protected tableDataSource = new TableDataSource<DeviceDisplayData>();
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes.devices) {
|
||||
this.tableDataSource.data = this.devices;
|
||||
}
|
||||
}
|
||||
|
||||
protected async approveOrDenyAuthRequest(pendingAuthRequest: DevicePendingAuthRequest | null) {
|
||||
if (pendingAuthRequest == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginApprovalDialog = LoginApprovalComponent.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<div *ngIf="showHeaderInfo" class="tw-mt-6 tw-mb-2 tw-pb-2.5">
|
||||
<div class="tw-flex tw-items-center tw-gap-2 tw-mb-5">
|
||||
<h1 class="tw-m-0">{{ "devices" | i18n }}</h1>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-flex tw-items-center tw-size-4"
|
||||
[bitPopoverTriggerFor]="infoPopover"
|
||||
position="right-start"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<bit-popover [title]="'whatIsADevice' | i18n" #infoPopover>
|
||||
<p>{{ "aDeviceIs" | i18n }}</p>
|
||||
</bit-popover>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
{{ "deviceListDescriptionTemp" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (initializing) {
|
||||
<div 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>
|
||||
} @else {
|
||||
<!-- Table View: displays on medium to large screens -->
|
||||
<auth-device-management-table
|
||||
ngClass="tw-hidden md:tw-block"
|
||||
[devices]="devices"
|
||||
></auth-device-management-table>
|
||||
|
||||
<!-- List View: displays on small screens -->
|
||||
<auth-device-management-item-group
|
||||
ngClass="md:tw-hidden"
|
||||
[devices]="devices"
|
||||
></auth-device-management-item-group>
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
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 { ButtonModule, PopoverModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { DeviceManagementComponentServiceAbstraction } from "./device-management-component.service.abstraction";
|
||||
import { DeviceManagementItemGroupComponent } from "./device-management-item-group.component";
|
||||
import { DeviceManagementTableComponent } from "./device-management-table.component";
|
||||
|
||||
export interface DeviceDisplayData {
|
||||
displayName: string;
|
||||
firstLogin: Date;
|
||||
icon: string;
|
||||
id: string;
|
||||
identifier: string;
|
||||
isCurrentDevice: boolean;
|
||||
isTrusted: boolean;
|
||||
loginStatus: string;
|
||||
pendingAuthRequest: DevicePendingAuthRequest | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `DeviceManagementComponent` fetches user devices and passes them down
|
||||
* to a child component for display.
|
||||
*
|
||||
* The specific child component that gets displayed depends on the viewport width:
|
||||
* - Medium to Large screens = `bit-table` view
|
||||
* - Small screens = `bit-item-group` view
|
||||
*/
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "auth-device-management",
|
||||
templateUrl: "./device-management.component.html",
|
||||
imports: [
|
||||
ButtonModule,
|
||||
CommonModule,
|
||||
DeviceManagementItemGroupComponent,
|
||||
DeviceManagementTableComponent,
|
||||
I18nPipe,
|
||||
PopoverModule,
|
||||
],
|
||||
})
|
||||
export class DeviceManagementComponent implements OnInit {
|
||||
protected devices: DeviceDisplayData[] = [];
|
||||
protected initializing = true;
|
||||
protected showHeaderInfo = false;
|
||||
|
||||
constructor(
|
||||
private authRequestApiService: AuthRequestApiServiceAbstraction,
|
||||
private destroyRef: DestroyRef,
|
||||
private deviceManagementComponentService: DeviceManagementComponentServiceAbstraction,
|
||||
private devicesService: DevicesServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private messageListener: MessageListener,
|
||||
private validationService: ValidationService,
|
||||
) {
|
||||
this.showHeaderInfo = this.deviceManagementComponentService.showHeaderInformation();
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadDevices();
|
||||
|
||||
this.messageListener.allMessages$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((message) => {
|
||||
if (
|
||||
message.command === "openLoginApproval" &&
|
||||
message.notificationId &&
|
||||
typeof message.notificationId === "string"
|
||||
) {
|
||||
void this.upsertDeviceWithPendingAuthRequest(message.notificationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadDevices() {
|
||||
try {
|
||||
const devices = await firstValueFrom(this.devicesService.getDevices$());
|
||||
const currentDevice = await firstValueFrom(this.devicesService.getCurrentDevice$());
|
||||
|
||||
if (!devices || !currentDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.devices = this.mapDevicesToDisplayData(devices, currentDevice);
|
||||
} catch (e) {
|
||||
this.validationService.showError(e);
|
||||
} finally {
|
||||
this.initializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private mapDevicesToDisplayData(
|
||||
devices: DeviceView[],
|
||||
currentDevice: DeviceResponse,
|
||||
): DeviceDisplayData[] {
|
||||
return devices
|
||||
.map((device): DeviceDisplayData | 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;
|
||||
}
|
||||
|
||||
return {
|
||||
displayName: this.devicesService.getReadableDeviceTypeName(device.type),
|
||||
firstLogin: device.creationDate ? new Date(device.creationDate) : new Date(),
|
||||
icon: this.getDeviceIcon(device.type),
|
||||
id: device.id || "",
|
||||
identifier: device.identifier ?? "",
|
||||
isCurrentDevice: this.isCurrentDevice(device, currentDevice),
|
||||
isTrusted: device.response?.isTrusted ?? false,
|
||||
loginStatus: this.getLoginStatus(device, currentDevice),
|
||||
pendingAuthRequest: device.response?.devicePendingAuthRequest ?? null,
|
||||
};
|
||||
})
|
||||
.filter((device) => device !== null);
|
||||
}
|
||||
|
||||
private async upsertDeviceWithPendingAuthRequest(authRequestId: string) {
|
||||
const authRequestResponse = await this.authRequestApiService.getAuthRequest(authRequestId);
|
||||
if (!authRequestResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const upsertDevice: DeviceDisplayData = {
|
||||
displayName: this.devicesService.getReadableDeviceTypeName(
|
||||
authRequestResponse.requestDeviceTypeValue,
|
||||
),
|
||||
firstLogin: new Date(authRequestResponse.creationDate),
|
||||
icon: this.getDeviceIcon(authRequestResponse.requestDeviceTypeValue),
|
||||
id: "",
|
||||
identifier: authRequestResponse.requestDeviceIdentifier,
|
||||
isCurrentDevice: false,
|
||||
isTrusted: false,
|
||||
loginStatus: this.i18nService.t("requestPending"),
|
||||
pendingAuthRequest: {
|
||||
id: authRequestResponse.id,
|
||||
creationDate: authRequestResponse.creationDate,
|
||||
},
|
||||
};
|
||||
|
||||
// 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.devices.findIndex(
|
||||
(device) => device.identifier === upsertDevice.identifier,
|
||||
);
|
||||
|
||||
if (existingDeviceIndex >= 0) {
|
||||
// Update existing device in device list
|
||||
this.devices[existingDeviceIndex] = upsertDevice;
|
||||
this.devices = [...this.devices];
|
||||
} else {
|
||||
// Add new device to device list
|
||||
this.devices = [upsertDevice, ...this.devices];
|
||||
}
|
||||
}
|
||||
|
||||
private getLoginStatus(device: DeviceView, currentDevice: DeviceResponse): string {
|
||||
if (this.isCurrentDevice(device, currentDevice)) {
|
||||
return this.i18nService.t("currentSession");
|
||||
}
|
||||
|
||||
if (this.hasPendingAuthRequest(device)) {
|
||||
return this.i18nService.t("requestPending");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private isCurrentDevice(device: DeviceView, currentDevice: DeviceResponse): boolean {
|
||||
return device.id === currentDevice.id;
|
||||
}
|
||||
|
||||
private hasPendingAuthRequest(device: DeviceView): boolean {
|
||||
return device.response?.devicePendingAuthRequest != null;
|
||||
}
|
||||
|
||||
private getDeviceIcon(type: DeviceType): string {
|
||||
const defaultIcon = "bwi bwi-desktop";
|
||||
const categoryIconMap: Record<string, string> = {
|
||||
webApp: "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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DevicePendingAuthRequest } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
|
||||
|
||||
import { DeviceDisplayData } from "./device-management.component";
|
||||
|
||||
export function clearAuthRequestAndResortDevices(
|
||||
devices: DeviceDisplayData[],
|
||||
pendingAuthRequest: DevicePendingAuthRequest,
|
||||
): DeviceDisplayData[] {
|
||||
return devices
|
||||
.map((device) => {
|
||||
if (device.pendingAuthRequest?.id === pendingAuthRequest.id) {
|
||||
device.pendingAuthRequest = null;
|
||||
device.loginStatus = "";
|
||||
}
|
||||
return device;
|
||||
})
|
||||
.sort(resortDevices);
|
||||
}
|
||||
|
||||
/**
|
||||
* After a device is approved/denied, it will still be at the beginning of the array,
|
||||
* so we must resort the array to ensure it is in the correct order.
|
||||
*
|
||||
* This is a helper function that gets passed to the `Array.sort()` method
|
||||
*/
|
||||
function resortDevices(deviceA: DeviceDisplayData, deviceB: DeviceDisplayData) {
|
||||
// Devices with a pending auth request should be first
|
||||
if (deviceA.pendingAuthRequest) {
|
||||
return -1;
|
||||
}
|
||||
if (deviceB.pendingAuthRequest) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Next is the current device
|
||||
if (deviceA.isCurrentDevice) {
|
||||
return -1;
|
||||
}
|
||||
if (deviceB.isCurrentDevice) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Then sort the rest by display name (alphabetically)
|
||||
if (deviceA.displayName < deviceB.displayName) {
|
||||
return -1;
|
||||
}
|
||||
if (deviceA.displayName > deviceB.displayName) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Default
|
||||
return 0;
|
||||
}
|
||||
@@ -1188,7 +1188,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: DevicesServiceAbstraction,
|
||||
useClass: DevicesServiceImplementation,
|
||||
deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction],
|
||||
deps: [AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AuthRequestApiServiceAbstraction,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
|
||||
import { DeviceResponse } from "./responses/device.response";
|
||||
import { DeviceView } from "./views/device.view";
|
||||
|
||||
@@ -15,4 +17,5 @@ export abstract class DevicesServiceAbstraction {
|
||||
): Observable<DeviceView>;
|
||||
abstract deactivateDevice$(deviceId: string): Observable<void>;
|
||||
abstract getCurrentDevice$(): Observable<DeviceResponse>;
|
||||
abstract getReadableDeviceTypeName(deviceType: DeviceType): string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Observable, defer, map } from "rxjs";
|
||||
|
||||
import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { AppIdService } from "../../../platform/abstractions/app-id.service";
|
||||
import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction";
|
||||
@@ -17,8 +20,9 @@ import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.ser
|
||||
*/
|
||||
export class DevicesServiceImplementation implements DevicesServiceAbstraction {
|
||||
constructor(
|
||||
private devicesApiService: DevicesApiServiceAbstraction,
|
||||
private appIdService: AppIdService,
|
||||
private devicesApiService: DevicesApiServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -86,4 +90,23 @@ export class DevicesServiceImplementation implements DevicesServiceAbstraction {
|
||||
return this.devicesApiService.getDeviceByIdentifier(deviceIdentifier);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Gets a human readable string of the device type name
|
||||
*/
|
||||
getReadableDeviceTypeName(type: DeviceType): string {
|
||||
if (type === undefined) {
|
||||
return this.i18nService.t("unknownDevice");
|
||||
}
|
||||
|
||||
const metadata = DeviceTypeMetadata[type];
|
||||
if (!metadata) {
|
||||
return this.i18nService.t("unknownDevice");
|
||||
}
|
||||
|
||||
const platform =
|
||||
metadata.platform === "Unknown" ? this.i18nService.t("unknown") : metadata.platform;
|
||||
const category = this.i18nService.t(metadata.category);
|
||||
return platform ? `${category} - ${platform}` : category;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export enum DeviceType {
|
||||
* Each device type has a category corresponding to the client type and platform (Android, iOS, Chrome, Firefox, etc.)
|
||||
*/
|
||||
interface DeviceTypeMetadata {
|
||||
category: "mobile" | "extension" | "webVault" | "desktop" | "cli" | "sdk" | "server";
|
||||
category: "mobile" | "extension" | "webApp" | "desktop" | "cli" | "sdk" | "server";
|
||||
platform: string;
|
||||
}
|
||||
|
||||
@@ -49,15 +49,15 @@ export const DeviceTypeMetadata: Record<DeviceType, DeviceTypeMetadata> = {
|
||||
[DeviceType.EdgeExtension]: { category: "extension", platform: "Edge" },
|
||||
[DeviceType.VivaldiExtension]: { category: "extension", platform: "Vivaldi" },
|
||||
[DeviceType.SafariExtension]: { category: "extension", platform: "Safari" },
|
||||
[DeviceType.ChromeBrowser]: { category: "webVault", platform: "Chrome" },
|
||||
[DeviceType.FirefoxBrowser]: { category: "webVault", platform: "Firefox" },
|
||||
[DeviceType.OperaBrowser]: { category: "webVault", platform: "Opera" },
|
||||
[DeviceType.EdgeBrowser]: { category: "webVault", platform: "Edge" },
|
||||
[DeviceType.IEBrowser]: { category: "webVault", platform: "IE" },
|
||||
[DeviceType.SafariBrowser]: { category: "webVault", platform: "Safari" },
|
||||
[DeviceType.VivaldiBrowser]: { category: "webVault", platform: "Vivaldi" },
|
||||
[DeviceType.DuckDuckGoBrowser]: { category: "webVault", platform: "DuckDuckGo" },
|
||||
[DeviceType.UnknownBrowser]: { category: "webVault", platform: "Unknown" },
|
||||
[DeviceType.ChromeBrowser]: { category: "webApp", platform: "Chrome" },
|
||||
[DeviceType.FirefoxBrowser]: { category: "webApp", platform: "Firefox" },
|
||||
[DeviceType.OperaBrowser]: { category: "webApp", platform: "Opera" },
|
||||
[DeviceType.EdgeBrowser]: { category: "webApp", platform: "Edge" },
|
||||
[DeviceType.IEBrowser]: { category: "webApp", platform: "IE" },
|
||||
[DeviceType.SafariBrowser]: { category: "webApp", platform: "Safari" },
|
||||
[DeviceType.VivaldiBrowser]: { category: "webApp", platform: "Vivaldi" },
|
||||
[DeviceType.DuckDuckGoBrowser]: { category: "webApp", platform: "DuckDuckGo" },
|
||||
[DeviceType.UnknownBrowser]: { category: "webApp", platform: "Unknown" },
|
||||
[DeviceType.WindowsDesktop]: { category: "desktop", platform: "Windows" },
|
||||
[DeviceType.MacOsDesktop]: { category: "desktop", platform: "macOS" },
|
||||
[DeviceType.LinuxDesktop]: { category: "desktop", platform: "Linux" },
|
||||
|
||||
Reference in New Issue
Block a user