1
0
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:
Alec Rippberger 2025-01-07 13:29:36 -06:00 committed by GitHub
parent 02556c1416
commit f99a3c4162
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 549 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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