1
0
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:
Alec Rippberger
2025-01-07 13:29:36 -06:00
committed by GitHub
parent 02556c1416
commit f99a3c4162
17 changed files with 549 additions and 27 deletions

View File

@@ -1109,7 +1109,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: DevicesServiceAbstraction,
useClass: DevicesServiceImplementation,
deps: [DevicesApiServiceAbstraction],
deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction],
}),
safeProvider({
provide: DeviceTrustServiceAbstraction,

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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");
}
}

View File

@@ -12,6 +12,7 @@ export class DeviceView implements View {
type: DeviceType;
creationDate: string;
revisionDate: string;
response: DeviceResponse;
constructor(deviceResponse: DeviceResponse) {
Object.assign(this, deviceResponse);

View File

@@ -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 {

View File

@@ -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");
});
});
});

View File

@@ -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);
}
}

View File

@@ -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);
});
}
}

View File

@@ -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: "" },
};