mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-27 22:11:38 +01:00
feat(web): [PM-1214] add device management screen
Adds a device management tab under settings -> security that allows users to: - View and manage their account's connected devices - Remove/deactivate devices - See device details like platform, last login, and trust status - Sort and filter device list with virtual scrolling Resolves PM-1214
This commit is contained in:
parent
02556c1416
commit
f99a3c4162
@ -769,7 +769,10 @@ export default class MainBackground {
|
||||
this.configService,
|
||||
);
|
||||
|
||||
this.devicesService = new DevicesServiceImplementation(this.devicesApiService);
|
||||
this.devicesService = new DevicesServiceImplementation(
|
||||
this.devicesApiService,
|
||||
this.appIdService,
|
||||
);
|
||||
|
||||
this.authRequestService = new AuthRequestService(
|
||||
this.appIdService,
|
||||
|
@ -0,0 +1,88 @@
|
||||
<bit-container>
|
||||
<div class="tabbed-header">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<h1>{{ "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-h-4 tw-w-4"
|
||||
[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>
|
||||
<i
|
||||
*ngIf="asyncActionLoading"
|
||||
class="bwi bwi-spinner bwi-spin tw-flex tw-items-center tw-h-4 tw-w-4"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>{{ "deviceListDescription" | 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>
|
||||
<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>
|
||||
{{ row.displayName }}
|
||||
<span *ngIf="row.trusted" class="tw-text-sm tw-text-muted tw-block">
|
||||
{{ "trusted" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<span *ngIf="isCurrentDevice(row)" bitBadge variant="primary">{{
|
||||
"currentSession" | i18n
|
||||
}}</span>
|
||||
<span *ngIf="hasPendingAuthRequest(row)" 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>
|
||||
<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>
|
@ -0,0 +1,220 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { switchMap } from "rxjs/operators";
|
||||
|
||||
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
|
||||
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 {
|
||||
DialogService,
|
||||
ToastService,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
PopoverModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
interface DeviceTableData {
|
||||
id: string;
|
||||
type: DeviceType;
|
||||
displayName: string;
|
||||
loginStatus: string;
|
||||
firstLogin: Date;
|
||||
trusted: boolean;
|
||||
devicePendingAuthRequest: object | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.component.html",
|
||||
standalone: true,
|
||||
imports: [CommonModule, SharedModule, TableModule, PopoverModule],
|
||||
})
|
||||
export class DeviceManagementComponent {
|
||||
protected readonly tableId = "device-management-table";
|
||||
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,
|
||||
) {
|
||||
this.devicesService
|
||||
.getCurrentDevice$()
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
switchMap((currentDevice) => {
|
||||
this.currentDevice = new DeviceView(currentDevice);
|
||||
return this.devicesService.getDevices$();
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: (devices) => {
|
||||
this.dataSource.data = devices.map((device) => {
|
||||
return {
|
||||
id: device.id,
|
||||
type: device.type,
|
||||
displayName: this.getHumanReadableDeviceType(device.type),
|
||||
loginStatus: this.getLoginStatus(device),
|
||||
devicePendingAuthRequest: device.response.devicePendingAuthRequest,
|
||||
firstLogin: new Date(device.creationDate),
|
||||
trusted: device.response.isTrusted,
|
||||
};
|
||||
});
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @returns True if the device has a pending auth request, false otherwise
|
||||
*/
|
||||
protected hasPendingAuthRequest(device: DeviceTableData): boolean {
|
||||
return (
|
||||
device.devicePendingAuthRequest !== undefined && device.devicePendingAuthRequest !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ import { RouterModule, Routes } from "@angular/router";
|
||||
import { ChangePasswordComponent } from "../change-password.component";
|
||||
import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component";
|
||||
|
||||
import { DeviceManagementComponent } from "./device-management.component";
|
||||
import { SecurityKeysComponent } from "./security-keys.component";
|
||||
import { SecurityComponent } from "./security.component";
|
||||
|
||||
@ -29,6 +30,11 @@ const routes: Routes = [
|
||||
component: SecurityKeysComponent,
|
||||
data: { titleId: "keys" },
|
||||
},
|
||||
{
|
||||
path: "device-management",
|
||||
component: DeviceManagementComponent,
|
||||
data: { titleId: "devices" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
@ -4,6 +4,7 @@
|
||||
<bit-tab-link route="change-password">{{ "masterPassword" | i18n }}</bit-tab-link>
|
||||
</ng-container>
|
||||
<bit-tab-link route="two-factor">{{ "twoStepLogin" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link route="device-management">{{ "devices" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link route="security-keys">{{ "keys" | i18n }}</bit-tab-link>
|
||||
</bit-tab-nav-bar>
|
||||
</app-header>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-security",
|
||||
@ -9,7 +10,10 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
|
||||
export class SecurityComponent implements OnInit {
|
||||
showChangePassword = true;
|
||||
|
||||
constructor(private userVerificationService: UserVerificationService) {}
|
||||
constructor(
|
||||
private userVerificationService: UserVerificationService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.showChangePassword = await this.userVerificationService.hasMasterPassword();
|
||||
|
@ -1128,6 +1128,12 @@
|
||||
"verifyIdentity": {
|
||||
"message": "Verify your Identity"
|
||||
},
|
||||
"whatIsADevice": {
|
||||
"message": "What is a device?"
|
||||
},
|
||||
"aDeviceIs": {
|
||||
"message": "A device is a unique installation of the Bitwarden app where you have logged in. Reinstalling, clearing app data, or clearing your cookies could result in a device appearing multiple times."
|
||||
},
|
||||
"logInInitiated": {
|
||||
"message": "Log in initiated"
|
||||
},
|
||||
@ -1715,6 +1721,12 @@
|
||||
"logBackIn": {
|
||||
"message": "Please log back in."
|
||||
},
|
||||
"currentSession": {
|
||||
"message": "Current session"
|
||||
},
|
||||
"requestPending": {
|
||||
"message": "Request pending"
|
||||
},
|
||||
"logBackInOthersToo": {
|
||||
"message": "Please log back in. If you are using other Bitwarden applications log out and back in to those as well."
|
||||
},
|
||||
@ -3765,6 +3777,15 @@
|
||||
"device": {
|
||||
"message": "Device"
|
||||
},
|
||||
"loginStatus": {
|
||||
"message": "Login status"
|
||||
},
|
||||
"firstLogin": {
|
||||
"message": "First login"
|
||||
},
|
||||
"trusted": {
|
||||
"message": "Trusted"
|
||||
},
|
||||
"creatingAccountOn": {
|
||||
"message": "Creating account on"
|
||||
},
|
||||
@ -8236,6 +8257,18 @@
|
||||
"approveRequest": {
|
||||
"message": "Approve request"
|
||||
},
|
||||
"deviceApproved": {
|
||||
"message": "Device approved"
|
||||
},
|
||||
"deviceRemoved": {
|
||||
"message": "Device removed"
|
||||
},
|
||||
"removeDevice": {
|
||||
"message": "Remove device"
|
||||
},
|
||||
"removeDeviceConfirmation": {
|
||||
"message": "Are you sure you want to remove this device?"
|
||||
},
|
||||
"noDeviceRequests": {
|
||||
"message": "No device requests"
|
||||
},
|
||||
@ -9939,6 +9972,12 @@
|
||||
"removeMembers": {
|
||||
"message": "Remove members"
|
||||
},
|
||||
"devices": {
|
||||
"message": "Devices"
|
||||
},
|
||||
"deviceListDescription": {
|
||||
"message": "Your account was logged in to each of the devices below. If you do not recognize a device, remove it now."
|
||||
},
|
||||
"claimedDomains": {
|
||||
"message": "Claimed domains"
|
||||
},
|
||||
|
@ -1109,7 +1109,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: DevicesServiceAbstraction,
|
||||
useClass: DevicesServiceImplementation,
|
||||
deps: [DevicesApiServiceAbstraction],
|
||||
deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DeviceTrustServiceAbstraction,
|
||||
|
@ -36,4 +36,10 @@ export abstract class DevicesApiServiceAbstraction {
|
||||
* @param deviceIdentifier - current device identifier
|
||||
*/
|
||||
postDeviceTrustLoss: (deviceIdentifier: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Deactivates a device
|
||||
* @param deviceId - The device ID
|
||||
*/
|
||||
deactivateDevice: (deviceId: string) => Promise<void>;
|
||||
}
|
||||
|
@ -1,17 +1,18 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DeviceResponse } from "./responses/device.response";
|
||||
import { DeviceView } from "./views/device.view";
|
||||
|
||||
export abstract class DevicesServiceAbstraction {
|
||||
getDevices$: () => Observable<Array<DeviceView>>;
|
||||
getDeviceByIdentifier$: (deviceIdentifier: string) => Observable<DeviceView>;
|
||||
isDeviceKnownForUser$: (email: string, deviceIdentifier: string) => Observable<boolean>;
|
||||
updateTrustedDeviceKeys$: (
|
||||
abstract getDevices$(): Observable<Array<DeviceView>>;
|
||||
abstract getDeviceByIdentifier$(deviceIdentifier: string): Observable<DeviceView>;
|
||||
abstract isDeviceKnownForUser$(email: string, deviceIdentifier: string): Observable<boolean>;
|
||||
abstract updateTrustedDeviceKeys$(
|
||||
deviceIdentifier: string,
|
||||
devicePublicKeyEncryptedUserKey: string,
|
||||
userKeyEncryptedDevicePublicKey: string,
|
||||
deviceKeyEncryptedDevicePrivateKey: string,
|
||||
) => Observable<DeviceView>;
|
||||
): Observable<DeviceView>;
|
||||
abstract deactivateDevice$(deviceId: string): Observable<void>;
|
||||
abstract getCurrentDevice$(): Observable<DeviceResponse>;
|
||||
}
|
||||
|
@ -9,6 +9,9 @@ export class DeviceResponse extends BaseResponse {
|
||||
type: DeviceType;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
isTrusted: boolean;
|
||||
devicePendingAuthRequest: { id: string; creationDate: string } | null;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
@ -18,5 +21,7 @@ export class DeviceResponse extends BaseResponse {
|
||||
this.type = this.getResponseProperty("Type");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.revisionDate = this.getResponseProperty("RevisionDate");
|
||||
this.isTrusted = this.getResponseProperty("IsTrusted");
|
||||
this.devicePendingAuthRequest = this.getResponseProperty("DevicePendingAuthRequest");
|
||||
}
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ export class DeviceView implements View {
|
||||
type: DeviceType;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
response: DeviceResponse;
|
||||
|
||||
constructor(deviceResponse: DeviceResponse) {
|
||||
Object.assign(this, deviceResponse);
|
||||
|
@ -8,8 +8,8 @@ export class UpdateDevicesTrustRequest extends SecretVerificationRequest {
|
||||
}
|
||||
|
||||
export class DeviceKeysUpdateRequest {
|
||||
encryptedPublicKey: string;
|
||||
encryptedUserKey: string;
|
||||
encryptedPublicKey: string | undefined;
|
||||
encryptedUserKey: string | undefined;
|
||||
}
|
||||
|
||||
export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest {
|
||||
|
@ -0,0 +1,100 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
|
||||
import { DevicesApiServiceImplementation } from "./devices-api.service.implementation";
|
||||
|
||||
describe("DevicesApiServiceImplementation", () => {
|
||||
let devicesApiService: DevicesApiServiceImplementation;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
devicesApiService = new DevicesApiServiceImplementation(apiService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("getKnownDevice", () => {
|
||||
it("calls api with correct parameters", async () => {
|
||||
const email = "test@example.com";
|
||||
const deviceIdentifier = "device123";
|
||||
apiService.send.mockResolvedValue(true);
|
||||
|
||||
const result = await devicesApiService.getKnownDevice(email, deviceIdentifier);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
"/devices/knowndevice",
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDeviceByIdentifier", () => {
|
||||
it("returns device response", async () => {
|
||||
const deviceIdentifier = "device123";
|
||||
const mockResponse = { id: "123", name: "Test Device" };
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await devicesApiService.getDeviceByIdentifier(deviceIdentifier);
|
||||
|
||||
expect(result).toBeInstanceOf(DeviceResponse);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/devices/identifier/${deviceIdentifier}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTrustedDeviceKeys", () => {
|
||||
it("updates device keys and returns device response", async () => {
|
||||
const deviceIdentifier = "device123";
|
||||
const publicKeyEncrypted = "encryptedPublicKey";
|
||||
const userKeyEncrypted = "encryptedUserKey";
|
||||
const deviceKeyEncrypted = "encryptedDeviceKey";
|
||||
const mockResponse = { id: "123", name: "Test Device" };
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await devicesApiService.updateTrustedDeviceKeys(
|
||||
deviceIdentifier,
|
||||
publicKeyEncrypted,
|
||||
userKeyEncrypted,
|
||||
deviceKeyEncrypted,
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(DeviceResponse);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"PUT",
|
||||
`/devices/${deviceIdentifier}/keys`,
|
||||
{
|
||||
encryptedPrivateKey: deviceKeyEncrypted,
|
||||
encryptedPublicKey: userKeyEncrypted,
|
||||
encryptedUserKey: publicKeyEncrypted,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("propagates api errors", async () => {
|
||||
const error = new Error("API Error");
|
||||
apiService.send.mockRejectedValue(error);
|
||||
|
||||
await expect(devicesApiService.getDevices()).rejects.toThrow("API Error");
|
||||
});
|
||||
});
|
||||
});
|
@ -117,4 +117,8 @@ export class DevicesApiServiceImplementation implements DevicesApiServiceAbstrac
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async deactivateDevice(deviceId: string): Promise<void> {
|
||||
await this.apiService.send("POST", `/devices/${deviceId}/deactivate`, null, true, false);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { Observable, defer, map } from "rxjs";
|
||||
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction";
|
||||
import { DeviceResponse } from "../../abstractions/devices/responses/device.response";
|
||||
@ -15,7 +17,10 @@ import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.ser
|
||||
* (i.e., promsise --> observables are cold until subscribed to)
|
||||
*/
|
||||
export class DevicesServiceImplementation implements DevicesServiceAbstraction {
|
||||
constructor(private devicesApiService: DevicesApiServiceAbstraction) {}
|
||||
constructor(
|
||||
private devicesApiService: DevicesApiServiceAbstraction,
|
||||
private appIdService: AppIdService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @description Gets the list of all devices.
|
||||
@ -65,4 +70,21 @@ export class DevicesServiceImplementation implements DevicesServiceAbstraction {
|
||||
),
|
||||
).pipe(map((deviceResponse: DeviceResponse) => new DeviceView(deviceResponse)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Deactivates a device
|
||||
*/
|
||||
deactivateDevice$(deviceId: string): Observable<void> {
|
||||
return defer(() => this.devicesApiService.deactivateDevice(deviceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Gets the current device.
|
||||
*/
|
||||
getCurrentDevice$(): Observable<DeviceResponse> {
|
||||
return defer(async () => {
|
||||
const deviceIdentifier = await this.appIdService.getAppId();
|
||||
return this.devicesApiService.getDeviceByIdentifier(deviceIdentifier);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -27,18 +27,40 @@ export enum DeviceType {
|
||||
LinuxCLI = 25,
|
||||
}
|
||||
|
||||
export const MobileDeviceTypes: Set<DeviceType> = new Set([
|
||||
DeviceType.Android,
|
||||
DeviceType.iOS,
|
||||
DeviceType.AndroidAmazon,
|
||||
]);
|
||||
/**
|
||||
* Device type metadata
|
||||
* 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";
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export const DesktopDeviceTypes: Set<DeviceType> = new Set([
|
||||
DeviceType.WindowsDesktop,
|
||||
DeviceType.MacOsDesktop,
|
||||
DeviceType.LinuxDesktop,
|
||||
DeviceType.UWP,
|
||||
DeviceType.WindowsCLI,
|
||||
DeviceType.MacOsCLI,
|
||||
DeviceType.LinuxCLI,
|
||||
]);
|
||||
export const DeviceTypeMetadata: Record<DeviceType, DeviceTypeMetadata> = {
|
||||
[DeviceType.Android]: { category: "mobile", platform: "Android" },
|
||||
[DeviceType.iOS]: { category: "mobile", platform: "iOS" },
|
||||
[DeviceType.AndroidAmazon]: { category: "mobile", platform: "Amazon" },
|
||||
[DeviceType.ChromeExtension]: { category: "extension", platform: "Chrome" },
|
||||
[DeviceType.FirefoxExtension]: { category: "extension", platform: "Firefox" },
|
||||
[DeviceType.OperaExtension]: { category: "extension", platform: "Opera" },
|
||||
[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.UnknownBrowser]: { category: "webVault", platform: "Unknown" },
|
||||
[DeviceType.WindowsDesktop]: { category: "desktop", platform: "Windows" },
|
||||
[DeviceType.MacOsDesktop]: { category: "desktop", platform: "macOS" },
|
||||
[DeviceType.LinuxDesktop]: { category: "desktop", platform: "Linux" },
|
||||
[DeviceType.UWP]: { category: "desktop", platform: "Windows UWP" },
|
||||
[DeviceType.WindowsCLI]: { category: "cli", platform: "Windows" },
|
||||
[DeviceType.MacOsCLI]: { category: "cli", platform: "macOS" },
|
||||
[DeviceType.LinuxCLI]: { category: "cli", platform: "Linux" },
|
||||
[DeviceType.SDK]: { category: "sdk", platform: "" },
|
||||
[DeviceType.Server]: { category: "server", platform: "" },
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user