diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index ee8cd412625..11d13392ce2 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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:" }, diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index d835497d9be..3de1cc81a69 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -102,6 +102,18 @@ + + +

{{ "manageDevices" | i18n }}

+
+ + + +
+

{{ "otherOptions" | i18n }}

diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 4f9e1f7414a..6c072532a5d 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -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 = 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 () => { diff --git a/apps/browser/src/auth/popup/settings/extension-device-management.component.html b/apps/browser/src/auth/popup/settings/extension-device-management.component.html new file mode 100644 index 00000000000..aadbe6b81d0 --- /dev/null +++ b/apps/browser/src/auth/popup/settings/extension-device-management.component.html @@ -0,0 +1,11 @@ + + + + + + + +
+ +
+
diff --git a/apps/browser/src/auth/popup/settings/extension-device-management.component.ts b/apps/browser/src/auth/popup/settings/extension-device-management.component.ts new file mode 100644 index 00000000000..793965db141 --- /dev/null +++ b/apps/browser/src/auth/popup/settings/extension-device-management.component.ts @@ -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 {} diff --git a/apps/browser/src/auth/services/extension-device-management-component.service.ts b/apps/browser/src/auth/services/extension-device-management-component.service.ts new file mode 100644 index 00000000000..2585ba3198c --- /dev/null +++ b/apps/browser/src/auth/services/extension-device-management-component.service.ts @@ -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; + } +} diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 0d7fe740069..1dfc947b284 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -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); diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index da5a6c43d36..47ba2326557 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -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, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 54d09ab9d8c..3887c8c8b12 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -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({ diff --git a/apps/web/src/app/auth/settings/security/device-management.component.html b/apps/web/src/app/auth/settings/security/device-management-old.component.html similarity index 100% rename from apps/web/src/app/auth/settings/security/device-management.component.html rename to apps/web/src/app/auth/settings/security/device-management-old.component.html diff --git a/apps/web/src/app/auth/settings/security/device-management.component.spec.ts b/apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts similarity index 95% rename from apps/web/src/app/auth/settings/security/device-management.component.spec.ts rename to apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts index 2821d4a6d76..64fb9003ccf 100644 --- a/apps/web/src/app/auth/settings/security/device-management.component.spec.ts +++ b/apps/web/src/app/auth/settings/security/device-management-old.component.spec.ts @@ -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; +describe("DeviceManagementOldComponent", () => { + let fixture: ComponentFixture; let messageSubject: Subject; 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); }); diff --git a/apps/web/src/app/auth/settings/security/device-management.component.ts b/apps/web/src/app/auth/settings/security/device-management-old.component.ts similarity index 99% rename from apps/web/src/app/auth/settings/security/device-management.component.ts rename to apps/web/src/app/auth/settings/security/device-management-old.component.ts index 854a13faa99..556ba381acc 100644 --- a/apps/web/src/app/auth/settings/security/device-management.component.ts +++ b/apps/web/src/app/auth/settings/security/device-management-old.component.ts @@ -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(); protected currentDevice: DeviceView | undefined; protected loading = true; diff --git a/apps/web/src/app/auth/settings/security/security-routing.module.ts b/apps/web/src/app/auth/settings/security/security-routing.module.ts index 14d4aab8a36..2ec1be5cb7f 100644 --- a/apps/web/src/app/auth/settings/security/security-routing.module.ts +++ b/apps/web/src/app/auth/settings/security/security-routing.module.ts @@ -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" }, + }, + }), ], }, ]; diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 9cfe3117d40..d98a2ee8cf2 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -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({ diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a58126adac5..475fb004033 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -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": { diff --git a/libs/angular/src/auth/device-management/default-device-management-component.service.ts b/libs/angular/src/auth/device-management/default-device-management-component.service.ts new file mode 100644 index 00000000000..5089ba259a5 --- /dev/null +++ b/libs/angular/src/auth/device-management/default-device-management-component.service.ts @@ -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; + } +} diff --git a/libs/angular/src/auth/device-management/device-management-component.service.abstraction.ts b/libs/angular/src/auth/device-management/device-management-component.service.abstraction.ts new file mode 100644 index 00000000000..02834908658 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-component.service.abstraction.ts @@ -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; +} diff --git a/libs/angular/src/auth/device-management/device-management-item-group.component.html b/libs/angular/src/auth/device-management/device-management-item-group.component.html new file mode 100644 index 00000000000..b47408059a2 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-item-group.component.html @@ -0,0 +1,63 @@ + + + @if (device.pendingAuthRequest) { + + } @else { + + + {{ device.displayName }} + + +
+ + {{ "currentSession" | i18n }} + +
+ + +
+ @if (device.isTrusted) { + {{ "trusted" | i18n }} + } @else { +
+ } + +
+ {{ "firstLogin" | i18n }}: + {{ device.firstLogin | date: "medium" }} +
+
+
+ } +
+
diff --git a/libs/angular/src/auth/device-management/device-management-item-group.component.ts b/libs/angular/src/auth/device-management/device-management-item-group.component.ts new file mode 100644 index 00000000000..62468a18225 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-item-group.component.ts @@ -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); + } + } +} diff --git a/libs/angular/src/auth/device-management/device-management-table.component.html b/libs/angular/src/auth/device-management/device-management-table.component.html new file mode 100644 index 00000000000..febb0a96a4e --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-table.component.html @@ -0,0 +1,62 @@ + + + + + {{ column.title }} + + + + + + + +
+ +
+ +
+ @if (device.pendingAuthRequest) { + + {{ device.displayName }} + +
+ {{ "needsApproval" | i18n }} +
+ } @else { + {{ device.displayName }} +
+ {{ "trusted" | i18n }} +
+ } +
+ + + + +
+ + {{ "currentSession" | i18n }} + + + {{ "requestPending" | i18n }} + +
+ + + + {{ device.firstLogin | date: "medium" }} +
+
diff --git a/libs/angular/src/auth/device-management/device-management-table.component.ts b/libs/angular/src/auth/device-management/device-management-table.component.ts new file mode 100644 index 00000000000..1d20e54deec --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management-table.component.ts @@ -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(); + + 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, + ); + } + } +} diff --git a/libs/angular/src/auth/device-management/device-management.component.html b/libs/angular/src/auth/device-management/device-management.component.html new file mode 100644 index 00000000000..8b82140a508 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management.component.html @@ -0,0 +1,40 @@ +
+
+

{{ "devices" | i18n }}

+ + + + +

{{ "aDeviceIs" | i18n }}

+
+
+ +

+ {{ "deviceListDescriptionTemp" | i18n }} +

+
+ +@if (initializing) { +
+ +
+} @else { + + + + + +} diff --git a/libs/angular/src/auth/device-management/device-management.component.ts b/libs/angular/src/auth/device-management/device-management.component.ts new file mode 100644 index 00000000000..dc7700a9410 --- /dev/null +++ b/libs/angular/src/auth/device-management/device-management.component.ts @@ -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 = { + 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; + } +} diff --git a/libs/angular/src/auth/device-management/resort-devices.helper.ts b/libs/angular/src/auth/device-management/resort-devices.helper.ts new file mode 100644 index 00000000000..e739e943ee8 --- /dev/null +++ b/libs/angular/src/auth/device-management/resort-devices.helper.ts @@ -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; +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 391c20b30d6..0ad7b57d9b3 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1188,7 +1188,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: DevicesServiceAbstraction, useClass: DevicesServiceImplementation, - deps: [DevicesApiServiceAbstraction, AppIdServiceAbstraction], + deps: [AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction], }), safeProvider({ provide: AuthRequestApiServiceAbstraction, diff --git a/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts b/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts index ba6890947c1..8c1fa61322b 100644 --- a/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/devices/devices.service.abstraction.ts @@ -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; abstract deactivateDevice$(deviceId: string): Observable; abstract getCurrentDevice$(): Observable; + abstract getReadableDeviceTypeName(deviceType: DeviceType): string; } diff --git a/libs/common/src/auth/services/devices/devices.service.implementation.ts b/libs/common/src/auth/services/devices/devices.service.implementation.ts index cdaa7a9fc4e..ba9b376576e 100644 --- a/libs/common/src/auth/services/devices/devices.service.implementation.ts +++ b/libs/common/src/auth/services/devices/devices.service.implementation.ts @@ -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; + } } diff --git a/libs/common/src/enums/device-type.enum.ts b/libs/common/src/enums/device-type.enum.ts index c462081140e..f7215ac7446 100644 --- a/libs/common/src/enums/device-type.enum.ts +++ b/libs/common/src/enums/device-type.enum.ts @@ -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.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" },