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