diff --git a/apps/web/src/app/auth/settings/security/device-management.component.html b/apps/web/src/app/auth/settings/security/device-management.component.html
index 743414aac41..37827a33afe 100644
--- a/apps/web/src/app/auth/settings/security/device-management.component.html
+++ b/apps/web/src/app/auth/settings/security/device-management.component.html
@@ -48,17 +48,31 @@
- {{ row.displayName }}
-
- {{ "trusted" | i18n }}
-
+
+
+ {{ row.displayName }}
+
+
+
+ {{ "needsApproval" | i18n }}
+
+
+
+ {{ row.displayName }}
+
+ {{ "trusted" | i18n }}
+
+
{{
"currentSession" | i18n
}}
- {{
+ {{
"requestPending" | i18n
}}
|
diff --git a/apps/web/src/app/auth/settings/security/device-management.component.ts b/apps/web/src/app/auth/settings/security/device-management.component.ts
index 65f2afc250e..e22122ad9ae 100644
--- a/apps/web/src/app/auth/settings/security/device-management.component.ts
+++ b/apps/web/src/app/auth/settings/security/device-management.component.ts
@@ -1,10 +1,14 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
-import { firstValueFrom } from "rxjs";
-import { switchMap } from "rxjs/operators";
+import { combineLatest, firstValueFrom } from "rxjs";
+import { LoginApprovalComponent } from "@bitwarden/auth/angular";
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";
@@ -26,7 +30,8 @@ interface DeviceTableData {
loginStatus: string;
firstLogin: Date;
trusted: boolean;
- devicePendingAuthRequest: object | null;
+ devicePendingAuthRequest: DevicePendingAuthRequest | null;
+ hasPendingAuthRequest: boolean;
}
/**
@@ -52,28 +57,25 @@ export class DeviceManagementComponent {
private toastService: ToastService,
private validationService: ValidationService,
) {
- this.devicesService
- .getCurrentDevice$()
- .pipe(
- takeUntilDestroyed(),
- switchMap((currentDevice) => {
- this.currentDevice = new DeviceView(currentDevice);
- return this.devicesService.getDevices$();
- }),
- )
+ combineLatest([this.devicesService.getCurrentDevice$(), this.devicesService.getDevices$()])
+ .pipe(takeUntilDestroyed())
.subscribe({
- next: (devices) => {
- this.dataSource.data = devices.map((device) => {
+ next: ([currentDevice, devices]: [DeviceResponse, Array]) => {
+ this.currentDevice = new DeviceView(currentDevice);
+
+ this.dataSource.data = devices.map((device: DeviceView): DeviceTableData => {
return {
id: device.id,
type: device.type,
displayName: this.getHumanReadableDeviceType(device.type),
loginStatus: this.getLoginStatus(device),
- devicePendingAuthRequest: device.response.devicePendingAuthRequest,
firstLogin: new Date(device.creationDate),
trusted: device.response.isTrusted,
+ devicePendingAuthRequest: device.response.devicePendingAuthRequest,
+ hasPendingAuthRequest: this.hasPendingAuthRequest(device.response),
};
});
+
this.loading = false;
},
error: () => {
@@ -176,15 +178,36 @@ export class DeviceManagementComponent {
/**
* Check if a device has a pending auth request
- * @param device - The device
+ * @param device - The device response
* @returns True if the device has a pending auth request, false otherwise
*/
- protected hasPendingAuthRequest(device: DeviceTableData): boolean {
+ private hasPendingAuthRequest(device: DeviceResponse): boolean {
return (
device.devicePendingAuthRequest !== undefined && device.devicePendingAuthRequest !== null
);
}
+ /**
+ * Open a dialog to approve or deny a pending auth request for a device
+ */
+ async managePendingAuthRequest(device: DeviceTableData) {
+ if (device.devicePendingAuthRequest === undefined || device.devicePendingAuthRequest === null) {
+ return;
+ }
+
+ const dialogRef = LoginApprovalComponent.open(this.dialogService, {
+ notificationId: device.devicePendingAuthRequest.id,
+ });
+
+ const result = await firstValueFrom(dialogRef.closed);
+
+ if (result !== undefined && typeof result === "boolean") {
+ // auth request approved or denied so reset
+ device.devicePendingAuthRequest = null;
+ device.hasPendingAuthRequest = false;
+ }
+ }
+
/**
* Remove a device
* @param device - The device
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 456981fe9a6..2779c0470e7 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -3813,6 +3813,67 @@
"trusted": {
"message": "Trusted"
},
+ "needsApproval": {
+ "message": "Needs approval"
+ },
+ "areYouTryingtoLogin": {
+ "message": "Are you trying to log in?"
+ },
+ "logInAttemptBy": {
+ "message": "Login attempt by $EMAIL$",
+ "placeholders": {
+ "email": {
+ "content": "$1",
+ "example": "name@example.com"
+ }
+ }
+ },
+ "deviceType": {
+ "message": "Device Type"
+ },
+ "ipAddress": {
+ "message": "IP Address"
+ },
+ "confirmLogIn": {
+ "message": "Confirm login"
+ },
+ "denyLogIn": {
+ "message": "Deny login"
+ },
+ "thisRequestIsNoLongerValid": {
+ "message": "This request is no longer valid."
+ },
+ "logInConfirmedForEmailOnDevice": {
+ "message": "Login confirmed for $EMAIL$ on $DEVICE$",
+ "placeholders": {
+ "email": {
+ "content": "$1",
+ "example": "name@example.com"
+ },
+ "device": {
+ "content": "$2",
+ "example": "iOS"
+ }
+ }
+ },
+ "youDeniedALogInAttemptFromAnotherDevice": {
+ "message": "You denied a login attempt from another device. If this really was you, try to log in with the device again."
+ },
+ "loginRequestHasAlreadyExpired": {
+ "message": "Login request has already expired."
+ },
+ "justNow": {
+ "message": "Just now"
+ },
+ "requestedXMinutesAgo": {
+ "message": "Requested $MINUTES$ minutes ago",
+ "placeholders": {
+ "minutes": {
+ "content": "$1",
+ "example": "5"
+ }
+ }
+ },
"creatingAccountOn": {
"message": "Creating account on"
},
diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts
index 93d25af1a53..803808612cf 100644
--- a/libs/angular/src/services/jslib-services.module.ts
+++ b/libs/angular/src/services/jslib-services.module.ts
@@ -20,6 +20,7 @@ import {
DefaultLoginComponentService,
LoginDecryptionOptionsService,
DefaultLoginDecryptionOptionsService,
+ DefaultLoginApprovalComponentService,
} from "@bitwarden/auth/angular";
import {
AuthRequestServiceAbstraction,
@@ -39,6 +40,7 @@ import {
DefaultAuthRequestApiService,
DefaultLoginSuccessHandlerService,
LoginSuccessHandlerService,
+ LoginApprovalComponentServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@@ -1405,6 +1407,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultAuthRequestApiService,
deps: [ApiServiceAbstraction, LogService],
}),
+ safeProvider({
+ provide: LoginApprovalComponentServiceAbstraction,
+ useClass: DefaultLoginApprovalComponentService,
+ deps: [],
+ }),
safeProvider({
provide: LoginDecryptionOptionsService,
useClass: DefaultLoginDecryptionOptionsService,
diff --git a/libs/auth/src/angular/login-approval/login-approval.component.html b/libs/auth/src/angular/login-approval/login-approval.component.html
index ddbc48d71a3..c0cb9b9caf4 100644
--- a/libs/auth/src/angular/login-approval/login-approval.component.html
+++ b/libs/auth/src/angular/login-approval/login-approval.component.html
@@ -1,23 +1,31 @@
{{ "areYouTryingtoLogin" | i18n }}
- {{ "logInAttemptBy" | i18n: email }}
-
-
{{ "fingerprintPhraseHeader" | i18n }}
-
{{ fingerprintPhrase }}
-
-
-
{{ "deviceType" | i18n }}
-
{{ authRequestResponse?.requestDeviceType }}
-
-
-
{{ "ipAddress" | i18n }}
-
{{ authRequestResponse?.requestIpAddress }}
-
-
-
{{ "time" | i18n }}
-
{{ requestTimeText }}
-
+
+
+
+
+
+
+
+ {{ "logInAttemptBy" | i18n: email }}
+
+
{{ "fingerprintPhraseHeader" | i18n }}
+
{{ fingerprintPhrase }}
+
+
+
{{ "deviceType" | i18n }}
+
{{ authRequestResponse?.requestDeviceType }}
+
+
+
{{ "ipAddress" | i18n }}
+
{{ authRequestResponse?.requestIpAddress }}
+
+
+
{{ "time" | i18n }}
+
{{ requestTimeText }}
+
+
@@ -34,7 +42,7 @@
type="button"
buttonType="secondary"
[bitAction]="denyLogin"
- [bitDialogClose]="true"
+ [disabled]="loading"
>
{{ "denyLogIn" | i18n }}
diff --git a/libs/auth/src/angular/login-approval/login-approval.component.spec.ts b/libs/auth/src/angular/login-approval/login-approval.component.spec.ts
index ff598bdeb91..da30df62fff 100644
--- a/libs/auth/src/angular/login-approval/login-approval.component.spec.ts
+++ b/libs/auth/src/angular/login-approval/login-approval.component.spec.ts
@@ -13,6 +13,7 @@ import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
@@ -29,6 +30,7 @@ describe("LoginApprovalComponent", () => {
let i18nService: MockProxy;
let dialogRef: MockProxy;
let toastService: MockProxy;
+ let validationService: MockProxy;
const testNotificationId = "test-notification-id";
const testEmail = "test@bitwarden.com";
@@ -41,6 +43,7 @@ describe("LoginApprovalComponent", () => {
i18nService = mock();
dialogRef = mock();
toastService = mock();
+ validationService = mock();
accountService.activeAccount$ = of({
email: testEmail,
@@ -62,6 +65,7 @@ describe("LoginApprovalComponent", () => {
{ provide: KeyService, useValue: mock() },
{ provide: DialogRef, useValue: dialogRef },
{ provide: ToastService, useValue: toastService },
+ { provide: ValidationService, useValue: validationService },
{
provide: LoginApprovalComponentServiceAbstraction,
useValue: mock(),
diff --git a/libs/auth/src/angular/login-approval/login-approval.component.ts b/libs/auth/src/angular/login-approval/login-approval.component.ts
index 5192334a0ca..3b44f545abb 100644
--- a/libs/auth/src/angular/login-approval/login-approval.component.ts
+++ b/libs/auth/src/angular/login-approval/login-approval.component.ts
@@ -16,6 +16,7 @@ import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
+import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
AsyncActionsModule,
@@ -40,6 +41,8 @@ export interface LoginApprovalDialogParams {
imports: [CommonModule, AsyncActionsModule, ButtonModule, DialogModule, JslibModule],
})
export class LoginApprovalComponent implements OnInit, OnDestroy {
+ loading = true;
+
notificationId: string;
private destroy$ = new Subject();
@@ -62,25 +65,25 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
private dialogRef: DialogRef,
private toastService: ToastService,
private loginApprovalComponentService: LoginApprovalComponentService,
+ private validationService: ValidationService,
) {
this.notificationId = params.notificationId;
}
async ngOnDestroy(): Promise {
clearInterval(this.interval);
- const closedWithButton = await firstValueFrom(this.dialogRef.closed);
- if (!closedWithButton) {
- // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this.retrieveAuthRequestAndRespond(false);
- }
this.destroy$.next();
this.destroy$.complete();
}
async ngOnInit() {
if (this.notificationId != null) {
- this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
+ try {
+ this.authRequestResponse = await this.apiService.getAuthRequest(this.notificationId);
+ } catch (error) {
+ this.validationService.showError(error);
+ }
+
const publicKey = Utils.fromB64ToArray(this.authRequestResponse.publicKey);
this.email = await await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
@@ -96,6 +99,8 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
}, RequestTimeUpdate);
this.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email);
+
+ this.loading = false;
}
}
@@ -131,6 +136,8 @@ export class LoginApprovalComponent implements OnInit, OnDestroy {
);
this.showResultToast(loginResponse);
}
+
+ this.dialogRef.close(approve);
}
showResultToast(loginResponse: AuthRequestResponse) {
diff --git a/libs/common/src/auth/abstractions/devices/responses/device.response.ts b/libs/common/src/auth/abstractions/devices/responses/device.response.ts
index 707616744ad..84a2fb03c28 100644
--- a/libs/common/src/auth/abstractions/devices/responses/device.response.ts
+++ b/libs/common/src/auth/abstractions/devices/responses/device.response.ts
@@ -1,6 +1,11 @@
import { DeviceType } from "../../../../enums";
import { BaseResponse } from "../../../../models/response/base.response";
+export interface DevicePendingAuthRequest {
+ id: string;
+ creationDate: string;
+}
+
export class DeviceResponse extends BaseResponse {
id: string;
userId: string;
@@ -10,7 +15,7 @@ export class DeviceResponse extends BaseResponse {
creationDate: string;
revisionDate: string;
isTrusted: boolean;
- devicePendingAuthRequest: { id: string; creationDate: string } | null;
+ devicePendingAuthRequest: DevicePendingAuthRequest | null;
constructor(response: any) {
super(response);