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:
@@ -3460,6 +3460,22 @@
|
|||||||
"logInRequestSent": {
|
"logInRequestSent": {
|
||||||
"message": "Request sent"
|
"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": {
|
"masterPasswordChanged": {
|
||||||
"message": "Master password saved"
|
"message": "Master password saved"
|
||||||
},
|
},
|
||||||
@@ -3556,6 +3572,88 @@
|
|||||||
"rememberThisDeviceToMakeFutureLoginsSeamless": {
|
"rememberThisDeviceToMakeFutureLoginsSeamless": {
|
||||||
"message": "Remember this device to make future logins seamless"
|
"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": {
|
"deviceApprovalRequired": {
|
||||||
"message": "Device approval required. Select an approval option below:"
|
"message": "Device approval required. Select an approval option below:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,6 +102,18 @@
|
|||||||
</bit-card>
|
</bit-card>
|
||||||
</bit-section>
|
</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 disableMargin>
|
||||||
<bit-section-header>
|
<bit-section-header>
|
||||||
<h2 bitTypography="h6">{{ "otherOptions" | i18n }}</h2>
|
<h2 bitTypography="h6">{{ "otherOptions" | i18n }}</h2>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/
|
|||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import {
|
import {
|
||||||
VaultTimeout,
|
VaultTimeout,
|
||||||
VaultTimeoutAction,
|
VaultTimeoutAction,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
VaultTimeoutSettingsService,
|
VaultTimeoutSettingsService,
|
||||||
VaultTimeoutStringType,
|
VaultTimeoutStringType,
|
||||||
} from "@bitwarden/common/key-management/vault-timeout";
|
} 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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
@@ -113,6 +115,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
|||||||
biometricUnavailabilityReason: string;
|
biometricUnavailabilityReason: string;
|
||||||
showChangeMasterPass = true;
|
showChangeMasterPass = true;
|
||||||
pinEnabled$: Observable<boolean> = of(true);
|
pinEnabled$: Observable<boolean> = of(true);
|
||||||
|
extensionLoginApprovalFlagEnabled = false;
|
||||||
|
|
||||||
form = this.formBuilder.group({
|
form = this.formBuilder.group({
|
||||||
vaultTimeout: [null as VaultTimeout | null],
|
vaultTimeout: [null as VaultTimeout | null],
|
||||||
@@ -155,6 +158,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
|||||||
private biometricsService: BiometricsService,
|
private biometricsService: BiometricsService,
|
||||||
private vaultNudgesService: NudgesService,
|
private vaultNudgesService: NudgesService,
|
||||||
private validationService: ValidationService,
|
private validationService: ValidationService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
@@ -235,6 +239,10 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
this.form.patchValue(initialValues, { emitEvent: false });
|
this.form.patchValue(initialValues, { emitEvent: false });
|
||||||
|
|
||||||
|
this.extensionLoginApprovalFlagEnabled = await this.configService.getFeatureFlag(
|
||||||
|
FeatureFlag.PM14938_BrowserExtensionLoginApproval,
|
||||||
|
);
|
||||||
|
|
||||||
timer(0, 1000)
|
timer(0, 1000)
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(async () => {
|
switchMap(async () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -814,8 +814,9 @@ export default class MainBackground {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.devicesService = new DevicesServiceImplementation(
|
this.devicesService = new DevicesServiceImplementation(
|
||||||
this.devicesApiService,
|
|
||||||
this.appIdService,
|
this.appIdService,
|
||||||
|
this.devicesApiService,
|
||||||
|
this.i18nService,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.authRequestApiService = new DefaultAuthRequestApiService(this.apiService, this.logService);
|
this.authRequestApiService = new DefaultAuthRequestApiService(this.apiService, this.logService);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { AccountSwitcherComponent } from "../auth/popup/account-switching/accoun
|
|||||||
import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard";
|
import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard";
|
||||||
import { SetPasswordComponent } from "../auth/popup/set-password.component";
|
import { SetPasswordComponent } from "../auth/popup/set-password.component";
|
||||||
import { AccountSecurityComponent } from "../auth/popup/settings/account-security.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 { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
|
||||||
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
|
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
|
||||||
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
|
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
|
||||||
@@ -263,6 +264,12 @@ const routes: Routes = [
|
|||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
data: { elevation: 1 } satisfies RouteDataProperties,
|
data: { elevation: 1 } satisfies RouteDataProperties,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "device-management",
|
||||||
|
component: ExtensionDeviceManagementComponent,
|
||||||
|
canActivate: [authGuard],
|
||||||
|
data: { elevation: 1 } satisfies RouteDataProperties,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "notifications",
|
path: "notifications",
|
||||||
component: NotificationsSettingsComponent,
|
component: NotificationsSettingsComponent,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core";
|
|||||||
import { merge, of, Subject } from "rxjs";
|
import { merge, of, Subject } from "rxjs";
|
||||||
|
|
||||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
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 { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service";
|
||||||
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||||
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
|
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 { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service";
|
||||||
import { ExtensionSsoComponentService } from "../../auth/popup/login/extension-sso-component.service";
|
import { ExtensionSsoComponentService } from "../../auth/popup/login/extension-sso-component.service";
|
||||||
import { ExtensionLogoutService } from "../../auth/popup/logout/extension-logout.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 { ExtensionTwoFactorAuthComponentService } from "../../auth/services/extension-two-factor-auth-component.service";
|
||||||
import { ExtensionTwoFactorAuthDuoComponentService } from "../../auth/services/extension-two-factor-auth-duo-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";
|
import { ExtensionTwoFactorAuthWebAuthnComponentService } from "../../auth/services/extension-two-factor-auth-webauthn-component.service";
|
||||||
@@ -667,6 +669,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
useClass: ForegroundNotificationsService,
|
useClass: ForegroundNotificationsService,
|
||||||
deps: [LogService],
|
deps: [LogService],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: DeviceManagementComponentServiceAbstraction,
|
||||||
|
useClass: ExtensionDeviceManagementComponentService,
|
||||||
|
deps: [],
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { SharedModule } from "../../../shared";
|
import { SharedModule } from "../../../shared";
|
||||||
import { VaultBannersService } from "../../../vault/individual-vault/vault-banners/services/vault-banners.service";
|
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 {
|
class MockResizeObserver {
|
||||||
observe = jest.fn();
|
observe = jest.fn();
|
||||||
@@ -35,8 +35,8 @@ interface Message {
|
|||||||
notificationId?: string;
|
notificationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("DeviceManagementComponent", () => {
|
describe("DeviceManagementOldComponent", () => {
|
||||||
let fixture: ComponentFixture<DeviceManagementComponent>;
|
let fixture: ComponentFixture<DeviceManagementOldComponent>;
|
||||||
let messageSubject: Subject<Message>;
|
let messageSubject: Subject<Message>;
|
||||||
let mockDevices: DeviceView[];
|
let mockDevices: DeviceView[];
|
||||||
let vaultBannersService: VaultBannersService;
|
let vaultBannersService: VaultBannersService;
|
||||||
@@ -66,7 +66,7 @@ describe("DeviceManagementComponent", () => {
|
|||||||
SharedModule,
|
SharedModule,
|
||||||
TableModule,
|
TableModule,
|
||||||
PopoverModule,
|
PopoverModule,
|
||||||
DeviceManagementComponent,
|
DeviceManagementOldComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@@ -130,7 +130,7 @@ describe("DeviceManagementComponent", () => {
|
|||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(DeviceManagementComponent);
|
fixture = TestBed.createComponent(DeviceManagementOldComponent);
|
||||||
|
|
||||||
vaultBannersService = TestBed.inject(VaultBannersService);
|
vaultBannersService = TestBed.inject(VaultBannersService);
|
||||||
});
|
});
|
||||||
@@ -45,10 +45,10 @@ interface DeviceTableData {
|
|||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-device-management",
|
selector: "app-device-management",
|
||||||
templateUrl: "./device-management.component.html",
|
templateUrl: "./device-management-old.component.html",
|
||||||
imports: [CommonModule, SharedModule, TableModule, PopoverModule],
|
imports: [CommonModule, SharedModule, TableModule, PopoverModule],
|
||||||
})
|
})
|
||||||
export class DeviceManagementComponent {
|
export class DeviceManagementOldComponent {
|
||||||
protected dataSource = new TableDataSource<DeviceTableData>();
|
protected dataSource = new TableDataSource<DeviceTableData>();
|
||||||
protected currentDevice: DeviceView | undefined;
|
protected currentDevice: DeviceView | undefined;
|
||||||
protected loading = true;
|
protected loading = true;
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { NgModule } from "@angular/core";
|
import { NgModule } from "@angular/core";
|
||||||
import { RouterModule, Routes } from "@angular/router";
|
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 { 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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
|
||||||
import { ChangePasswordComponent } from "../change-password.component";
|
import { ChangePasswordComponent } from "../change-password.component";
|
||||||
import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.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 { PasswordSettingsComponent } from "./password-settings/password-settings.component";
|
||||||
import { SecurityKeysComponent } from "./security-keys.component";
|
import { SecurityKeysComponent } from "./security-keys.component";
|
||||||
import { SecurityComponent } from "./security.component";
|
import { SecurityComponent } from "./security.component";
|
||||||
@@ -55,11 +57,15 @@ const routes: Routes = [
|
|||||||
component: SecurityKeysComponent,
|
component: SecurityKeysComponent,
|
||||||
data: { titleId: "keys" },
|
data: { titleId: "keys" },
|
||||||
},
|
},
|
||||||
{
|
...featureFlaggedRoute({
|
||||||
|
defaultComponent: DeviceManagementOldComponent,
|
||||||
|
flaggedComponent: DeviceManagementComponent,
|
||||||
|
featureFlag: FeatureFlag.PM14938_BrowserExtensionLoginApproval,
|
||||||
|
routeOptions: {
|
||||||
path: "device-management",
|
path: "device-management",
|
||||||
component: DeviceManagementComponent,
|
|
||||||
data: { titleId: "devices" },
|
data: { titleId: "devices" },
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
OrganizationUserApiService,
|
OrganizationUserApiService,
|
||||||
CollectionService,
|
CollectionService,
|
||||||
} from "@bitwarden/admin-console/common";
|
} 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 { 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 { 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";
|
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||||
@@ -406,6 +408,11 @@ const safeProviders: SafeProvider[] = [
|
|||||||
RouterService,
|
RouterService,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
safeProvider({
|
||||||
|
provide: DeviceManagementComponentServiceAbstraction,
|
||||||
|
useClass: DefaultDeviceManagementComponentService,
|
||||||
|
deps: [],
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
|||||||
@@ -3489,6 +3489,9 @@
|
|||||||
"webVault": {
|
"webVault": {
|
||||||
"message": "Web vault"
|
"message": "Web vault"
|
||||||
},
|
},
|
||||||
|
"webApp": {
|
||||||
|
"message": "Web app"
|
||||||
|
},
|
||||||
"cli": {
|
"cli": {
|
||||||
"message": "CLI"
|
"message": "CLI"
|
||||||
},
|
},
|
||||||
@@ -3971,6 +3974,22 @@
|
|||||||
"youDeniedALogInAttemptFromAnotherDevice": {
|
"youDeniedALogInAttemptFromAnotherDevice": {
|
||||||
"message": "You denied a login attempt from another device. If this really was you, try to log in with the device again."
|
"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": {
|
"loginRequestHasAlreadyExpired": {
|
||||||
"message": "Login request has already expired."
|
"message": "Login request has already expired."
|
||||||
},
|
},
|
||||||
@@ -4115,6 +4134,9 @@
|
|||||||
"reviewLoginRequest": {
|
"reviewLoginRequest": {
|
||||||
"message": "Review login request"
|
"message": "Review login request"
|
||||||
},
|
},
|
||||||
|
"loginRequest": {
|
||||||
|
"message": "Login request"
|
||||||
|
},
|
||||||
"freeTrialEndPromptCount": {
|
"freeTrialEndPromptCount": {
|
||||||
"message": "Your free trial ends in $COUNT$ days.",
|
"message": "Your free trial ends in $COUNT$ days.",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1188,7 +1188,7 @@ const safeProviders: SafeProvider[] = [
|
|||||||
safeProvider({
|
safeProvider({
|
||||||
provide: DevicesServiceAbstraction,
|
provide: DevicesServiceAbstraction,
|
||||||
useClass: DevicesServiceImplementation,
|
useClass: DevicesServiceImplementation,
|
||||||
deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction],
|
deps: [AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction],
|
||||||
}),
|
}),
|
||||||
safeProvider({
|
safeProvider({
|
||||||
provide: AuthRequestApiServiceAbstraction,
|
provide: AuthRequestApiServiceAbstraction,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { DeviceType } from "@bitwarden/common/enums";
|
||||||
|
|
||||||
import { DeviceResponse } from "./responses/device.response";
|
import { DeviceResponse } from "./responses/device.response";
|
||||||
import { DeviceView } from "./views/device.view";
|
import { DeviceView } from "./views/device.view";
|
||||||
|
|
||||||
@@ -15,4 +17,5 @@ export abstract class DevicesServiceAbstraction {
|
|||||||
): Observable<DeviceView>;
|
): Observable<DeviceView>;
|
||||||
abstract deactivateDevice$(deviceId: string): Observable<void>;
|
abstract deactivateDevice$(deviceId: string): Observable<void>;
|
||||||
abstract getCurrentDevice$(): Observable<DeviceResponse>;
|
abstract getCurrentDevice$(): Observable<DeviceResponse>;
|
||||||
|
abstract getReadableDeviceTypeName(deviceType: DeviceType): string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Observable, defer, map } from "rxjs";
|
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 { ListResponse } from "../../../models/response/list.response";
|
||||||
import { AppIdService } from "../../../platform/abstractions/app-id.service";
|
import { AppIdService } from "../../../platform/abstractions/app-id.service";
|
||||||
import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction";
|
import { DevicesServiceAbstraction } from "../../abstractions/devices/devices.service.abstraction";
|
||||||
@@ -17,8 +20,9 @@ import { DevicesApiServiceAbstraction } from "../../abstractions/devices-api.ser
|
|||||||
*/
|
*/
|
||||||
export class DevicesServiceImplementation implements DevicesServiceAbstraction {
|
export class DevicesServiceImplementation implements DevicesServiceAbstraction {
|
||||||
constructor(
|
constructor(
|
||||||
private devicesApiService: DevicesApiServiceAbstraction,
|
|
||||||
private appIdService: AppIdService,
|
private appIdService: AppIdService,
|
||||||
|
private devicesApiService: DevicesApiServiceAbstraction,
|
||||||
|
private i18nService: I18nService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,4 +90,23 @@ export class DevicesServiceImplementation implements DevicesServiceAbstraction {
|
|||||||
return this.devicesApiService.getDeviceByIdentifier(deviceIdentifier);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.)
|
* Each device type has a category corresponding to the client type and platform (Android, iOS, Chrome, Firefox, etc.)
|
||||||
*/
|
*/
|
||||||
interface DeviceTypeMetadata {
|
interface DeviceTypeMetadata {
|
||||||
category: "mobile" | "extension" | "webVault" | "desktop" | "cli" | "sdk" | "server";
|
category: "mobile" | "extension" | "webApp" | "desktop" | "cli" | "sdk" | "server";
|
||||||
platform: string;
|
platform: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,15 +49,15 @@ export const DeviceTypeMetadata: Record<DeviceType, DeviceTypeMetadata> = {
|
|||||||
[DeviceType.EdgeExtension]: { category: "extension", platform: "Edge" },
|
[DeviceType.EdgeExtension]: { category: "extension", platform: "Edge" },
|
||||||
[DeviceType.VivaldiExtension]: { category: "extension", platform: "Vivaldi" },
|
[DeviceType.VivaldiExtension]: { category: "extension", platform: "Vivaldi" },
|
||||||
[DeviceType.SafariExtension]: { category: "extension", platform: "Safari" },
|
[DeviceType.SafariExtension]: { category: "extension", platform: "Safari" },
|
||||||
[DeviceType.ChromeBrowser]: { category: "webVault", platform: "Chrome" },
|
[DeviceType.ChromeBrowser]: { category: "webApp", platform: "Chrome" },
|
||||||
[DeviceType.FirefoxBrowser]: { category: "webVault", platform: "Firefox" },
|
[DeviceType.FirefoxBrowser]: { category: "webApp", platform: "Firefox" },
|
||||||
[DeviceType.OperaBrowser]: { category: "webVault", platform: "Opera" },
|
[DeviceType.OperaBrowser]: { category: "webApp", platform: "Opera" },
|
||||||
[DeviceType.EdgeBrowser]: { category: "webVault", platform: "Edge" },
|
[DeviceType.EdgeBrowser]: { category: "webApp", platform: "Edge" },
|
||||||
[DeviceType.IEBrowser]: { category: "webVault", platform: "IE" },
|
[DeviceType.IEBrowser]: { category: "webApp", platform: "IE" },
|
||||||
[DeviceType.SafariBrowser]: { category: "webVault", platform: "Safari" },
|
[DeviceType.SafariBrowser]: { category: "webApp", platform: "Safari" },
|
||||||
[DeviceType.VivaldiBrowser]: { category: "webVault", platform: "Vivaldi" },
|
[DeviceType.VivaldiBrowser]: { category: "webApp", platform: "Vivaldi" },
|
||||||
[DeviceType.DuckDuckGoBrowser]: { category: "webVault", platform: "DuckDuckGo" },
|
[DeviceType.DuckDuckGoBrowser]: { category: "webApp", platform: "DuckDuckGo" },
|
||||||
[DeviceType.UnknownBrowser]: { category: "webVault", platform: "Unknown" },
|
[DeviceType.UnknownBrowser]: { category: "webApp", platform: "Unknown" },
|
||||||
[DeviceType.WindowsDesktop]: { category: "desktop", platform: "Windows" },
|
[DeviceType.WindowsDesktop]: { category: "desktop", platform: "Windows" },
|
||||||
[DeviceType.MacOsDesktop]: { category: "desktop", platform: "macOS" },
|
[DeviceType.MacOsDesktop]: { category: "desktop", platform: "macOS" },
|
||||||
[DeviceType.LinuxDesktop]: { category: "desktop", platform: "Linux" },
|
[DeviceType.LinuxDesktop]: { category: "desktop", platform: "Linux" },
|
||||||
|
|||||||
Reference in New Issue
Block a user