1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +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:
Alec Rippberger
2025-07-17 12:43:49 -05:00
committed by GitHub
parent 250e46ee70
commit 00b6b0224e
28 changed files with 872 additions and 26 deletions

View File

@@ -3460,6 +3460,22 @@
"logInRequestSent": {
"message": "Request sent"
},
"loginRequestApprovedForEmailOnDevice": {
"message": "Login request approved for $EMAIL$ on $DEVICE$",
"placeholders": {
"email": {
"content": "$1",
"example": "name@example.com"
},
"device": {
"content": "$2",
"example": "Web app - Chrome"
}
}
},
"youDeniedLoginAttemptFromAnotherDevice": {
"message": "You denied a login attempt from another device. If this was you, try to log in with the device again."
},
"masterPasswordChanged": {
"message": "Master password saved"
},
@@ -3556,6 +3572,88 @@
"rememberThisDeviceToMakeFutureLoginsSeamless": {
"message": "Remember this device to make future logins seamless"
},
"manageDevices": {
"message": "Manage devices"
},
"currentSession": {
"message": "Current session"
},
"mobile": {
"message": "Mobile",
"description": "Mobile app"
},
"extension": {
"message": "Extension",
"description": "Browser extension/addon"
},
"desktop": {
"message": "Desktop",
"description": "Desktop app"
},
"webVault": {
"message": "Web vault"
},
"webApp": {
"message": "Web app"
},
"cli": {
"message": "CLI"
},
"sdk": {
"message": "SDK",
"description": "Software Development Kit"
},
"requestPending": {
"message": "Request pending"
},
"firstLogin": {
"message": "First login"
},
"trusted": {
"message": "Trusted"
},
"needsApproval": {
"message": "Needs approval"
},
"devices": {
"message": "Devices"
},
"accessAttemptBy": {
"message": "Access attempt by $EMAIL$",
"placeholders": {
"email": {
"content": "$1",
"example": "name@example.com"
}
}
},
"confirmAccess": {
"message": "Confirm access"
},
"denyAccess": {
"message": "Deny access"
},
"time": {
"message": "Time"
},
"deviceType": {
"message": "Device Type"
},
"loginRequest": {
"message": "Login request"
},
"justNow": {
"message": "Just now"
},
"requestedXMinutesAgo": {
"message": "Requested $MINUTES$ minutes ago",
"placeholders": {
"minutes": {
"content": "$1",
"example": "5"
}
}
},
"deviceApprovalRequired": {
"message": "Device approval required. Select an approval option below:"
},

View File

@@ -102,6 +102,18 @@
</bit-card>
</bit-section>
<bit-section *ngIf="extensionLoginApprovalFlagEnabled">
<bit-section-header>
<h2 bitTypography="h6">{{ "manageDevices" | i18n }}</h2>
</bit-section-header>
<bit-item>
<button bit-item-content type="button" appStopClick routerLink="/device-management">
{{ "devices" | i18n }}
<i slot="end" class="bwi bwi-chevron-right" aria-hidden="true"></i>
</button>
</bit-item>
</bit-section>
<bit-section disableMargin>
<bit-section-header>
<h2 bitTypography="h6">{{ "otherOptions" | i18n }}</h2>

View File

@@ -32,6 +32,7 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import {
VaultTimeout,
VaultTimeoutAction,
@@ -40,6 +41,7 @@ import {
VaultTimeoutSettingsService,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -113,6 +115,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
biometricUnavailabilityReason: string;
showChangeMasterPass = true;
pinEnabled$: Observable<boolean> = of(true);
extensionLoginApprovalFlagEnabled = false;
form = this.formBuilder.group({
vaultTimeout: [null as VaultTimeout | null],
@@ -155,6 +158,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
private biometricsService: BiometricsService,
private vaultNudgesService: NudgesService,
private validationService: ValidationService,
private configService: ConfigService,
) {}
async ngOnInit() {
@@ -235,6 +239,10 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
};
this.form.patchValue(initialValues, { emitEvent: false });
this.extensionLoginApprovalFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM14938_BrowserExtensionLoginApproval,
);
timer(0, 1000)
.pipe(
switchMap(async () => {

View File

@@ -0,0 +1,11 @@
<popup-page>
<popup-header slot="header" pageTitle="{{ 'devices' | i18n }}" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
<div class="tw-bg-background-alt">
<auth-device-management></auth-device-management>
</div>
</popup-page>

View File

@@ -0,0 +1,22 @@
import { Component } from "@angular/core";
import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component";
import { I18nPipe } from "@bitwarden/ui-common";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
@Component({
standalone: true,
selector: "extension-device-management",
templateUrl: "extension-device-management.component.html",
imports: [
DeviceManagementComponent,
I18nPipe,
PopOutComponent,
PopupHeaderComponent,
PopupPageComponent,
],
})
export class ExtensionDeviceManagementComponent {}

View File

@@ -0,0 +1,15 @@
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
/**
* Browser extension implementation of the device management component service
*/
export class ExtensionDeviceManagementComponentService
implements DeviceManagementComponentServiceAbstraction
{
/**
* Don't show header information in browser extension client
*/
showHeaderInformation(): boolean {
return false;
}
}

View File

@@ -814,8 +814,9 @@ export default class MainBackground {
);
this.devicesService = new DevicesServiceImplementation(
this.devicesApiService,
this.appIdService,
this.devicesApiService,
this.i18nService,
);
this.authRequestApiService = new DefaultAuthRequestApiService(this.apiService, this.logService);

View File

@@ -49,6 +49,7 @@ import { AccountSwitcherComponent } from "../auth/popup/account-switching/accoun
import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard";
import { SetPasswordComponent } from "../auth/popup/set-password.component";
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component";
import { ExtensionDeviceManagementComponent } from "../auth/popup/settings/extension-device-management.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
@@ -263,6 +264,12 @@ const routes: Routes = [
canActivate: [authGuard],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "device-management",
component: ExtensionDeviceManagementComponent,
canActivate: [authGuard],
data: { elevation: 1 } satisfies RouteDataProperties,
},
{
path: "notifications",
component: NotificationsSettingsComponent,

View File

@@ -4,6 +4,7 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core";
import { merge, of, Subject } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service";
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
@@ -145,6 +146,7 @@ import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock
import { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service";
import { ExtensionSsoComponentService } from "../../auth/popup/login/extension-sso-component.service";
import { ExtensionLogoutService } from "../../auth/popup/logout/extension-logout.service";
import { ExtensionDeviceManagementComponentService } from "../../auth/services/extension-device-management-component.service";
import { ExtensionTwoFactorAuthComponentService } from "../../auth/services/extension-two-factor-auth-component.service";
import { ExtensionTwoFactorAuthDuoComponentService } from "../../auth/services/extension-two-factor-auth-duo-component.service";
import { ExtensionTwoFactorAuthWebAuthnComponentService } from "../../auth/services/extension-two-factor-auth-webauthn-component.service";
@@ -667,6 +669,11 @@ const safeProviders: SafeProvider[] = [
useClass: ForegroundNotificationsService,
deps: [LogService],
}),
safeProvider({
provide: DeviceManagementComponentServiceAbstraction,
useClass: ExtensionDeviceManagementComponentService,
deps: [],
}),
];
@NgModule({

View File

@@ -20,7 +20,7 @@ import {
import { SharedModule } from "../../../shared";
import { VaultBannersService } from "../../../vault/individual-vault/vault-banners/services/vault-banners.service";
import { DeviceManagementComponent } from "./device-management.component";
import { DeviceManagementOldComponent } from "./device-management-old.component";
class MockResizeObserver {
observe = jest.fn();
@@ -35,8 +35,8 @@ interface Message {
notificationId?: string;
}
describe("DeviceManagementComponent", () => {
let fixture: ComponentFixture<DeviceManagementComponent>;
describe("DeviceManagementOldComponent", () => {
let fixture: ComponentFixture<DeviceManagementOldComponent>;
let messageSubject: Subject<Message>;
let mockDevices: DeviceView[];
let vaultBannersService: VaultBannersService;
@@ -66,7 +66,7 @@ describe("DeviceManagementComponent", () => {
SharedModule,
TableModule,
PopoverModule,
DeviceManagementComponent,
DeviceManagementOldComponent,
],
providers: [
{
@@ -130,7 +130,7 @@ describe("DeviceManagementComponent", () => {
],
}).compileComponents();
fixture = TestBed.createComponent(DeviceManagementComponent);
fixture = TestBed.createComponent(DeviceManagementOldComponent);
vaultBannersService = TestBed.inject(VaultBannersService);
});

View File

@@ -45,10 +45,10 @@ interface DeviceTableData {
*/
@Component({
selector: "app-device-management",
templateUrl: "./device-management.component.html",
templateUrl: "./device-management-old.component.html",
imports: [CommonModule, SharedModule, TableModule, PopoverModule],
})
export class DeviceManagementComponent {
export class DeviceManagementOldComponent {
protected dataSource = new TableDataSource<DeviceTableData>();
protected currentDevice: DeviceView | undefined;
protected loading = true;

View File

@@ -1,13 +1,15 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ChangePasswordComponent } from "../change-password.component";
import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component";
import { DeviceManagementComponent } from "./device-management.component";
import { DeviceManagementOldComponent } from "./device-management-old.component";
import { PasswordSettingsComponent } from "./password-settings/password-settings.component";
import { SecurityKeysComponent } from "./security-keys.component";
import { SecurityComponent } from "./security.component";
@@ -55,11 +57,15 @@ const routes: Routes = [
component: SecurityKeysComponent,
data: { titleId: "keys" },
},
{
path: "device-management",
component: DeviceManagementComponent,
data: { titleId: "devices" },
},
...featureFlaggedRoute({
defaultComponent: DeviceManagementOldComponent,
flaggedComponent: DeviceManagementComponent,
featureFlag: FeatureFlag.PM14938_BrowserExtensionLoginApproval,
routeOptions: {
path: "device-management",
data: { titleId: "devices" },
},
}),
],
},
];

View File

@@ -10,6 +10,8 @@ import {
OrganizationUserApiService,
CollectionService,
} from "@bitwarden/admin-console/common";
import { DefaultDeviceManagementComponentService } from "@bitwarden/angular/auth/device-management/default-device-management-component.service";
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password";
import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
@@ -406,6 +408,11 @@ const safeProviders: SafeProvider[] = [
RouterService,
],
}),
safeProvider({
provide: DeviceManagementComponentServiceAbstraction,
useClass: DefaultDeviceManagementComponentService,
deps: [],
}),
];
@NgModule({

View File

@@ -3489,6 +3489,9 @@
"webVault": {
"message": "Web vault"
},
"webApp": {
"message": "Web app"
},
"cli": {
"message": "CLI"
},
@@ -3971,6 +3974,22 @@
"youDeniedALogInAttemptFromAnotherDevice": {
"message": "You denied a login attempt from another device. If this really was you, try to log in with the device again."
},
"loginRequestApprovedForEmailOnDevice": {
"message": "Login request approved for $EMAIL$ on $DEVICE$",
"placeholders": {
"email": {
"content": "$1",
"example": "name@example.com"
},
"device": {
"content": "$2",
"example": "Web app - Chrome"
}
}
},
"youDeniedLoginAttemptFromAnotherDevice": {
"message": "You denied a login attempt from another device. If this was you, try to log in with the device again."
},
"loginRequestHasAlreadyExpired": {
"message": "Login request has already expired."
},
@@ -4115,6 +4134,9 @@
"reviewLoginRequest": {
"message": "Review login request"
},
"loginRequest": {
"message": "Login request"
},
"freeTrialEndPromptCount": {
"message": "Your free trial ends in $COUNT$ days.",
"placeholders": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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