mirror of
https://github.com/bitwarden/browser
synced 2026-02-09 13:10:17 +00:00
merge main
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
<bit-form-control
|
||||
class="tw-pl-5"
|
||||
[disableMargin]="!((pinEnabled$ | async) || this.form.value.pin)"
|
||||
*ngIf="this.form.value.biometric && showAutoPrompt"
|
||||
*ngIf="this.form.value.biometric"
|
||||
>
|
||||
<input
|
||||
bitCheckbox
|
||||
|
||||
@@ -30,7 +30,6 @@ 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 { DeviceType } from "@bitwarden/common/enums";
|
||||
import {
|
||||
VaultTimeout,
|
||||
VaultTimeoutAction,
|
||||
@@ -110,7 +109,6 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
hasVaultTimeoutPolicy = false;
|
||||
biometricUnavailabilityReason: string;
|
||||
showChangeMasterPass = true;
|
||||
showAutoPrompt = true;
|
||||
pinEnabled$: Observable<boolean> = of(true);
|
||||
|
||||
form = this.formBuilder.group({
|
||||
@@ -147,11 +145,6 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
// Firefox popup closes when unfocused by biometrics, blocking all unlock methods
|
||||
if (this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension) {
|
||||
this.showAutoPrompt = false;
|
||||
}
|
||||
|
||||
const hasMasterPassword = await this.userVerificationService.hasMasterPassword();
|
||||
this.showMasterPasswordOnClientRestartOption = hasMasterPassword;
|
||||
const maximumVaultTimeoutPolicy = this.accountService.activeAccount$.pipe(
|
||||
|
||||
@@ -191,6 +191,10 @@ import { InternalFolderService as InternalFolderServiceAbstraction } from "@bitw
|
||||
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
DefaultEndUserNotificationService,
|
||||
EndUserNotificationService,
|
||||
} from "@bitwarden/common/vault/notifications";
|
||||
import {
|
||||
CipherAuthorizationService,
|
||||
DefaultCipherAuthorizationService,
|
||||
@@ -403,6 +407,7 @@ export default class MainBackground {
|
||||
sdkService: SdkService;
|
||||
sdkLoadService: SdkLoadService;
|
||||
cipherAuthorizationService: CipherAuthorizationService;
|
||||
endUserNotificationService: EndUserNotificationService;
|
||||
inlineMenuFieldQualificationService: InlineMenuFieldQualificationService;
|
||||
taskService: TaskService;
|
||||
|
||||
@@ -1329,6 +1334,14 @@ export default class MainBackground {
|
||||
|
||||
this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService);
|
||||
this.ipcService = new IpcBackgroundService(this.logService);
|
||||
|
||||
this.endUserNotificationService = new DefaultEndUserNotificationService(
|
||||
this.stateProvider,
|
||||
this.apiService,
|
||||
this.notificationsService,
|
||||
this.authService,
|
||||
this.logService,
|
||||
);
|
||||
}
|
||||
|
||||
async bootstrap() {
|
||||
@@ -1415,6 +1428,9 @@ export default class MainBackground {
|
||||
this.taskService.listenForTaskNotifications();
|
||||
}
|
||||
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.EndUserNotifications)) {
|
||||
this.endUserNotificationService.listenForEndUserNotifications();
|
||||
}
|
||||
resolve();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from "@bitwarden/key-management";
|
||||
import { UnlockOptions } from "@bitwarden/key-management-ui";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service";
|
||||
|
||||
import { ExtensionLockComponentService } from "./extension-lock-component.service";
|
||||
@@ -117,6 +119,62 @@ describe("ExtensionLockComponentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("popOutBrowserExtension", () => {
|
||||
let openPopoutSpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
openPopoutSpy = jest
|
||||
.spyOn(BrowserPopupUtils, "openCurrentPagePopout")
|
||||
.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("opens pop-out when the current window is neither a pop-out nor a sidebar", async () => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
|
||||
jest.spyOn(BrowserPopupUtils, "inSidebar").mockReturnValue(false);
|
||||
|
||||
await service.popOutBrowserExtension();
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith(global.window);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[true, false],
|
||||
[false, true],
|
||||
[true, true],
|
||||
])("should not open pop-out under other conditions.", async (inPopout, inSidebar) => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(inPopout);
|
||||
jest.spyOn(BrowserPopupUtils, "inSidebar").mockReturnValue(inSidebar);
|
||||
|
||||
await service.popOutBrowserExtension();
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeBrowserExtensionPopout", () => {
|
||||
let closePopupSpy: jest.SpyInstance;
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockReturnValue();
|
||||
});
|
||||
|
||||
it("closes pop-out when in pop-out", () => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
|
||||
|
||||
service.closeBrowserExtensionPopout();
|
||||
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(global.window);
|
||||
});
|
||||
|
||||
it("doesn't close pop-out when not in pop-out", () => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
|
||||
|
||||
service.closeBrowserExtensionPopout();
|
||||
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWindowVisible", () => {
|
||||
it("throws an error", async () => {
|
||||
await expect(service.isWindowVisible()).rejects.toThrow("Method not implemented.");
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management-ui";
|
||||
|
||||
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service";
|
||||
|
||||
export class ExtensionLockComponentService implements LockComponentService {
|
||||
@@ -37,6 +39,18 @@ export class ExtensionLockComponentService implements LockComponentService {
|
||||
return biometricsError.description;
|
||||
}
|
||||
|
||||
async popOutBrowserExtension(): Promise<void> {
|
||||
if (!BrowserPopupUtils.inPopout(global.window) && !BrowserPopupUtils.inSidebar(global.window)) {
|
||||
await BrowserPopupUtils.openCurrentPagePopout(global.window);
|
||||
}
|
||||
}
|
||||
|
||||
closeBrowserExtensionPopout(): void {
|
||||
if (BrowserPopupUtils.inPopout(global.window)) {
|
||||
BrowserApi.closePopup(global.window);
|
||||
}
|
||||
}
|
||||
|
||||
async isWindowVisible(): Promise<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
@@ -12,10 +12,13 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { EndUserNotificationService } from "@bitwarden/common/vault/notifications";
|
||||
import { NotificationView } from "@bitwarden/common/vault/notifications/models";
|
||||
import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import {
|
||||
@@ -66,6 +69,7 @@ describe("AtRiskPasswordsComponent", () => {
|
||||
let mockTasks$: BehaviorSubject<SecurityTask[]>;
|
||||
let mockCiphers$: BehaviorSubject<CipherView[]>;
|
||||
let mockOrgs$: BehaviorSubject<Organization[]>;
|
||||
let mockNotifications$: BehaviorSubject<NotificationView[]>;
|
||||
let mockInlineMenuVisibility$: BehaviorSubject<InlineMenuVisibilitySetting>;
|
||||
let calloutDismissed$: BehaviorSubject<boolean>;
|
||||
const setInlineMenuVisibility = jest.fn();
|
||||
@@ -73,6 +77,7 @@ describe("AtRiskPasswordsComponent", () => {
|
||||
const mockAtRiskPasswordPageService = mock<AtRiskPasswordPageService>();
|
||||
const mockChangeLoginPasswordService = mock<ChangeLoginPasswordService>();
|
||||
const mockDialogService = mock<DialogService>();
|
||||
const mockConfigService = mock<ConfigService>();
|
||||
|
||||
beforeEach(async () => {
|
||||
mockTasks$ = new BehaviorSubject<SecurityTask[]>([
|
||||
@@ -101,6 +106,7 @@ describe("AtRiskPasswordsComponent", () => {
|
||||
name: "Org 1",
|
||||
} as Organization,
|
||||
]);
|
||||
mockNotifications$ = new BehaviorSubject<NotificationView[]>([]);
|
||||
|
||||
mockInlineMenuVisibility$ = new BehaviorSubject<InlineMenuVisibilitySetting>(
|
||||
AutofillOverlayVisibility.Off,
|
||||
@@ -110,6 +116,7 @@ describe("AtRiskPasswordsComponent", () => {
|
||||
setInlineMenuVisibility.mockClear();
|
||||
mockToastService.showToast.mockClear();
|
||||
mockDialogService.open.mockClear();
|
||||
mockConfigService.getFeatureFlag.mockClear();
|
||||
mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -133,6 +140,12 @@ describe("AtRiskPasswordsComponent", () => {
|
||||
cipherViews$: () => mockCiphers$,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EndUserNotificationService,
|
||||
useValue: {
|
||||
unreadNotifications$: () => mockNotifications$,
|
||||
},
|
||||
},
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: AccountService, useValue: { activeAccount$: of({ id: "user" }) } },
|
||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||
@@ -145,6 +158,7 @@ describe("AtRiskPasswordsComponent", () => {
|
||||
},
|
||||
},
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
})
|
||||
.overrideModule(JslibModule, {
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, inject, OnInit, signal } from "@angular/core";
|
||||
import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Router } from "@angular/router";
|
||||
import { combineLatest, firstValueFrom, map, of, shareReplay, startWith, switchMap } from "rxjs";
|
||||
import {
|
||||
combineLatest,
|
||||
concat,
|
||||
concatMap,
|
||||
firstValueFrom,
|
||||
map,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
@@ -11,10 +23,13 @@ import {
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { EndUserNotificationService } from "@bitwarden/common/vault/notifications";
|
||||
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import {
|
||||
@@ -81,6 +96,9 @@ export class AtRiskPasswordsComponent implements OnInit {
|
||||
private changeLoginPasswordService = inject(ChangeLoginPasswordService);
|
||||
private platformUtilsService = inject(PlatformUtilsService);
|
||||
private dialogService = inject(DialogService);
|
||||
private endUserNotificationService = inject(EndUserNotificationService);
|
||||
private configService = inject(ConfigService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
/**
|
||||
* The cipher that is currently being launched. Used to show a loading spinner on the badge button.
|
||||
@@ -180,6 +198,36 @@ export class AtRiskPasswordsComponent implements OnInit {
|
||||
await this.atRiskPasswordPageService.dismissGettingStarted(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.EndUserNotifications)) {
|
||||
this.markTaskNotificationsAsRead();
|
||||
}
|
||||
}
|
||||
|
||||
private markTaskNotificationsAsRead() {
|
||||
this.activeUserData$
|
||||
.pipe(
|
||||
switchMap(({ tasks, userId }) => {
|
||||
return this.endUserNotificationService.unreadNotifications$(userId).pipe(
|
||||
take(1),
|
||||
map((notifications) => {
|
||||
return notifications.filter((notification) => {
|
||||
return tasks.some((task) => task.id === notification.taskId);
|
||||
});
|
||||
}),
|
||||
concatMap((unreadTaskNotifications) => {
|
||||
// TODO: Investigate creating a bulk endpoint to mark notifications as read
|
||||
return concat(
|
||||
...unreadTaskNotifications.map((n) =>
|
||||
this.endUserNotificationService.markAsRead(n.id, userId),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
async viewCipher(cipher: CipherView) {
|
||||
|
||||
@@ -104,6 +104,22 @@ describe("DesktopLockComponentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("popOutBrowserExtension", () => {
|
||||
it("throws platform not supported error", () => {
|
||||
expect(() => service.popOutBrowserExtension()).toThrow(
|
||||
"Method not supported on this platform.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeBrowserExtensionPopout", () => {
|
||||
it("throws platform not supported error", () => {
|
||||
expect(() => service.closeBrowserExtensionPopout()).toThrow(
|
||||
"Method not supported on this platform.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWindowVisible", () => {
|
||||
it("returns the window visibility", async () => {
|
||||
isWindowVisibleMock.mockReturnValue(true);
|
||||
|
||||
@@ -27,6 +27,14 @@ export class DesktopLockComponentService implements LockComponentService {
|
||||
return null;
|
||||
}
|
||||
|
||||
popOutBrowserExtension(): Promise<void> {
|
||||
throw new Error("Method not supported on this platform.");
|
||||
}
|
||||
|
||||
closeBrowserExtensionPopout(): void {
|
||||
throw new Error("Method not supported on this platform.");
|
||||
}
|
||||
|
||||
async isWindowVisible(): Promise<boolean> {
|
||||
return ipc.platform.isWindowVisible();
|
||||
}
|
||||
|
||||
@@ -52,6 +52,22 @@ describe("WebLockComponentService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("popOutBrowserExtension", () => {
|
||||
it("throws platform not supported error", () => {
|
||||
expect(() => service.popOutBrowserExtension()).toThrow(
|
||||
"Method not supported on this platform.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeBrowserExtensionPopout", () => {
|
||||
it("throws platform not supported error", () => {
|
||||
expect(() => service.closeBrowserExtensionPopout()).toThrow(
|
||||
"Method not supported on this platform.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWindowVisible", () => {
|
||||
it("throws an error", async () => {
|
||||
await expect(service.isWindowVisible()).rejects.toThrow("Method not implemented.");
|
||||
|
||||
@@ -24,6 +24,14 @@ export class WebLockComponentService implements LockComponentService {
|
||||
return null;
|
||||
}
|
||||
|
||||
popOutBrowserExtension(): Promise<void> {
|
||||
throw new Error("Method not supported on this platform.");
|
||||
}
|
||||
|
||||
closeBrowserExtensionPopout(): void {
|
||||
throw new Error("Method not supported on this platform.");
|
||||
}
|
||||
|
||||
async isWindowVisible(): Promise<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
@@ -270,6 +270,10 @@ import {
|
||||
} from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
|
||||
import {
|
||||
DefaultEndUserNotificationService,
|
||||
EndUserNotificationService,
|
||||
} from "@bitwarden/common/vault/notifications";
|
||||
import {
|
||||
CipherAuthorizationService,
|
||||
DefaultCipherAuthorizationService,
|
||||
@@ -306,12 +310,7 @@ import {
|
||||
UserAsymmetricKeysRegenerationService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { SafeInjectionToken } from "@bitwarden/ui-common";
|
||||
import {
|
||||
DefaultEndUserNotificationService,
|
||||
EndUserNotificationService,
|
||||
NewDeviceVerificationNoticeService,
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault";
|
||||
import {
|
||||
IndividualVaultExportService,
|
||||
IndividualVaultExportServiceAbstraction,
|
||||
@@ -1489,7 +1488,13 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: EndUserNotificationService,
|
||||
useClass: DefaultEndUserNotificationService,
|
||||
deps: [StateProvider, ApiServiceAbstraction, NotificationsService],
|
||||
deps: [
|
||||
StateProvider,
|
||||
ApiServiceAbstraction,
|
||||
NotificationsService,
|
||||
AuthServiceAbstraction,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DeviceTrustToastServiceAbstraction,
|
||||
|
||||
@@ -56,6 +56,7 @@ export enum FeatureFlag {
|
||||
SecurityTasks = "security-tasks",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
PhishingDetection = "phishing-detection",
|
||||
EndUserNotifications = "pm-10609-end-user-notifications",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@@ -106,6 +107,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.SecurityTasks]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
[FeatureFlag.EndUserNotifications]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { NotificationViewResponse as EndUserNotificationResponse } from "@bitwarden/common/vault/notifications/models";
|
||||
|
||||
import { NotificationType } from "../../enums";
|
||||
|
||||
import { BaseResponse } from "./base.response";
|
||||
@@ -57,6 +59,10 @@ export class NotificationResponse extends BaseResponse {
|
||||
case NotificationType.SyncOrganizationCollectionSettingChanged:
|
||||
this.payload = new OrganizationCollectionSettingChangedPushNotification(payload);
|
||||
break;
|
||||
case NotificationType.Notification:
|
||||
case NotificationType.NotificationStatus:
|
||||
this.payload = new EndUserNotificationResponse(payload);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { NotificationView } from "../models";
|
||||
|
||||
@@ -25,18 +25,23 @@ export abstract class EndUserNotificationService {
|
||||
* @param notificationId
|
||||
* @param userId
|
||||
*/
|
||||
abstract markAsRead(notificationId: any, userId: UserId): Promise<void>;
|
||||
abstract markAsRead(notificationId: NotificationId, userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Mark a notification as deleted.
|
||||
* @param notificationId
|
||||
* @param userId
|
||||
*/
|
||||
abstract markAsDeleted(notificationId: any, userId: UserId): Promise<void>;
|
||||
abstract markAsDeleted(notificationId: NotificationId, userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear all notifications from state for the given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract clearState(userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Creates a subscription to listen for end user push notifications and notification status updates.
|
||||
*/
|
||||
abstract listenForEndUserNotifications(): Subscription;
|
||||
}
|
||||
2
libs/common/src/vault/notifications/index.ts
Normal file
2
libs/common/src/vault/notifications/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { EndUserNotificationService } from "./abstractions/end-user-notification.service";
|
||||
export { DefaultEndUserNotificationService } from "./services/default-end-user-notification.service";
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { NotificationId } from "@bitwarden/common/types/guid";
|
||||
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { NotificationViewResponse } from "./notification-view.response";
|
||||
|
||||
@@ -10,6 +10,7 @@ export class NotificationViewData {
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
taskId?: SecurityTaskId;
|
||||
readDate: Date | null;
|
||||
deletedDate: Date | null;
|
||||
|
||||
@@ -19,6 +20,7 @@ export class NotificationViewData {
|
||||
this.title = response.title;
|
||||
this.body = response.body;
|
||||
this.date = response.date;
|
||||
this.taskId = response.taskId;
|
||||
this.readDate = response.readDate;
|
||||
this.deletedDate = response.deletedDate;
|
||||
}
|
||||
@@ -30,6 +32,7 @@ export class NotificationViewData {
|
||||
title: obj.title,
|
||||
body: obj.body,
|
||||
date: new Date(obj.date),
|
||||
taskId: obj.taskId,
|
||||
readDate: obj.readDate ? new Date(obj.readDate) : null,
|
||||
deletedDate: obj.deletedDate ? new Date(obj.deletedDate) : null,
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { NotificationId } from "@bitwarden/common/types/guid";
|
||||
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class NotificationViewResponse extends BaseResponse {
|
||||
id: NotificationId;
|
||||
@@ -7,6 +7,7 @@ export class NotificationViewResponse extends BaseResponse {
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
taskId?: SecurityTaskId;
|
||||
readDate: Date;
|
||||
deletedDate: Date;
|
||||
|
||||
@@ -17,6 +18,7 @@ export class NotificationViewResponse extends BaseResponse {
|
||||
this.title = this.getResponseProperty("Title");
|
||||
this.body = this.getResponseProperty("Body");
|
||||
this.date = this.getResponseProperty("Date");
|
||||
this.taskId = this.getResponseProperty("TaskId");
|
||||
this.readDate = this.getResponseProperty("ReadDate");
|
||||
this.deletedDate = this.getResponseProperty("DeletedDate");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NotificationId } from "@bitwarden/common/types/guid";
|
||||
import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class NotificationView {
|
||||
id: NotificationId;
|
||||
@@ -6,6 +6,7 @@ export class NotificationView {
|
||||
title: string;
|
||||
body: string;
|
||||
date: Date;
|
||||
taskId?: SecurityTaskId;
|
||||
readDate: Date | null;
|
||||
deletedDate: Date | null;
|
||||
|
||||
@@ -15,6 +16,7 @@ export class NotificationView {
|
||||
this.title = obj.title;
|
||||
this.body = obj.body;
|
||||
this.date = obj.date;
|
||||
this.taskId = obj.taskId;
|
||||
this.readDate = obj.readDate;
|
||||
this.deletedDate = obj.deletedDate;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { NotificationViewResponse } from "../models";
|
||||
import { NOTIFICATIONS } from "../state/end-user-notification.state";
|
||||
|
||||
import {
|
||||
DEFAULT_NOTIFICATION_PAGE_SIZE,
|
||||
DefaultEndUserNotificationService,
|
||||
} from "./default-end-user-notification.service";
|
||||
|
||||
describe("End User Notification Center Service", () => {
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
let mockApiService: jest.Mocked<ApiService>;
|
||||
let mockNotificationsService: jest.Mocked<NotificationsService>;
|
||||
let mockAuthService: jest.Mocked<AuthService>;
|
||||
let mockLogService: jest.Mocked<LogService>;
|
||||
let service: DefaultEndUserNotificationService;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
mockApiService = {
|
||||
send: jest.fn(),
|
||||
} as any;
|
||||
mockNotificationsService = {
|
||||
notifications$: of(null),
|
||||
} as any;
|
||||
mockAuthService = {
|
||||
authStatuses$: of({}),
|
||||
} as any;
|
||||
mockLogService = mock<LogService>();
|
||||
|
||||
service = new DefaultEndUserNotificationService(
|
||||
fakeStateProvider as unknown as StateProvider,
|
||||
mockApiService,
|
||||
mockNotificationsService,
|
||||
mockAuthService,
|
||||
mockLogService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("notifications$", () => {
|
||||
it("should return notifications from state when not null", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
const result = await firstValueFrom(service.notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiService.send).not.toHaveBeenCalled();
|
||||
expect(mockLogService.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return notifications API when state is null", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any);
|
||||
|
||||
const result = await firstValueFrom(service.notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(mockLogService.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log a warning if there are more notifications available", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
...new Array(DEFAULT_NOTIFICATION_PAGE_SIZE + 1).fill({ id: "notification-id" }),
|
||||
] as NotificationViewResponse[],
|
||||
continuationToken: "next-token", // Presence of continuation token indicates more data
|
||||
});
|
||||
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any);
|
||||
|
||||
const result = await firstValueFrom(service.notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(DEFAULT_NOTIFICATION_PAGE_SIZE + 1);
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(mockLogService.warning).toHaveBeenCalledWith(
|
||||
`More notifications available, but not fetched. Consider increasing the page size from ${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should share the same observable for the same user", async () => {
|
||||
const first = service.notifications$("user-id" as UserId);
|
||||
const second = service.notifications$("user-id" as UserId);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreadNotifications$", () => {
|
||||
it("should return unread notifications from state when read value is null", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
readDate: null as any,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
const result = await firstValueFrom(service.unreadNotifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNotifications", () => {
|
||||
it("should call getNotifications returning notifications from API", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
await service.refreshNotifications("user-id" as UserId);
|
||||
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("should update local state when notifications are updated", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
const mock = fakeStateProvider.singleUser.mockFor(
|
||||
"user-id" as UserId,
|
||||
NOTIFICATIONS,
|
||||
null as any,
|
||||
);
|
||||
|
||||
await service.refreshNotifications("user-id" as UserId);
|
||||
|
||||
expect(mock.nextMock).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear the local notification state for the user", async () => {
|
||||
const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
await service.clearState("user-id" as UserId);
|
||||
|
||||
expect(mock.nextMock).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAsDeleted", () => {
|
||||
it("should send an API request to mark the notification as deleted", async () => {
|
||||
await service.markAsDeleted("notification-id" as NotificationId, "user-id" as UserId);
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"DELETE",
|
||||
"/notifications/notification-id/delete",
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAsRead", () => {
|
||||
it("should send an API request to mark the notification as read", async () => {
|
||||
await service.markAsRead("notification-id" as NotificationId, "user-id" as UserId);
|
||||
expect(mockApiService.send).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
"/notifications/notification-id/read",
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { concatMap, EMPTY, filter, map, Observable, Subscription, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { NotificationType } from "@bitwarden/common/enums";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
filterOutNullish,
|
||||
perUserCache$,
|
||||
} from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
|
||||
import { EndUserNotificationService } from "../abstractions/end-user-notification.service";
|
||||
import { NotificationView, NotificationViewData, NotificationViewResponse } from "../models";
|
||||
import { NOTIFICATIONS } from "../state/end-user-notification.state";
|
||||
|
||||
/**
|
||||
* The default number of notifications to fetch from the API.
|
||||
*/
|
||||
export const DEFAULT_NOTIFICATION_PAGE_SIZE = 50;
|
||||
|
||||
const getLoggedInUserIds = map<Record<UserId, AuthenticationStatus>, UserId[]>((authStatuses) =>
|
||||
Object.entries(authStatuses ?? {})
|
||||
.filter(([, status]) => status >= AuthenticationStatus.Locked)
|
||||
.map(([userId]) => userId as UserId),
|
||||
);
|
||||
|
||||
/**
|
||||
* A service for retrieving and managing notifications for end users.
|
||||
*/
|
||||
export class DefaultEndUserNotificationService implements EndUserNotificationService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private apiService: ApiService,
|
||||
private notificationService: NotificationsService,
|
||||
private authService: AuthService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
notifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
|
||||
return this.notificationState(userId).state$.pipe(
|
||||
switchMap(async (notifications) => {
|
||||
if (notifications == null) {
|
||||
await this.fetchNotificationsFromApi(userId);
|
||||
return null;
|
||||
}
|
||||
return notifications;
|
||||
}),
|
||||
filterOutNullish(),
|
||||
map((notifications) =>
|
||||
notifications.map((notification) => new NotificationView(notification)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
unreadNotifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
|
||||
return this.notifications$(userId).pipe(
|
||||
map((notifications) => notifications.filter((notification) => notification.readDate == null)),
|
||||
);
|
||||
});
|
||||
|
||||
async markAsRead(notificationId: NotificationId, userId: UserId): Promise<void> {
|
||||
await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false);
|
||||
await this.notificationState(userId).update((current) => {
|
||||
const notification = current?.find((n) => n.id === notificationId);
|
||||
if (notification) {
|
||||
notification.readDate = new Date();
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
async markAsDeleted(notificationId: NotificationId, userId: UserId): Promise<void> {
|
||||
await this.apiService.send(
|
||||
"DELETE",
|
||||
`/notifications/${notificationId}/delete`,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
await this.notificationState(userId).update((current) => {
|
||||
const notification = current?.find((n) => n.id === notificationId);
|
||||
if (notification) {
|
||||
notification.deletedDate = new Date();
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
async clearState(userId: UserId): Promise<void> {
|
||||
await this.replaceNotificationState(userId, []);
|
||||
}
|
||||
|
||||
async refreshNotifications(userId: UserId) {
|
||||
await this.fetchNotificationsFromApi(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper observable to filter notifications by the notification type and user ids
|
||||
* Returns EMPTY if no user ids are provided
|
||||
* @param userIds
|
||||
* @private
|
||||
*/
|
||||
private filteredEndUserNotifications$(userIds: UserId[]) {
|
||||
if (userIds.length == 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.notificationService.notifications$.pipe(
|
||||
filter(
|
||||
([{ type }, userId]) =>
|
||||
(type === NotificationType.Notification ||
|
||||
type === NotificationType.NotificationStatus) &&
|
||||
userIds.includes(userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription to listen for end user push notifications and notification status updates.
|
||||
*/
|
||||
listenForEndUserNotifications(): Subscription {
|
||||
return this.authService.authStatuses$
|
||||
.pipe(
|
||||
getLoggedInUserIds,
|
||||
switchMap((userIds) => this.filteredEndUserNotifications$(userIds)),
|
||||
concatMap(([notification, userId]) =>
|
||||
this.upsertNotification(
|
||||
userId,
|
||||
new NotificationViewData(notification.payload as NotificationViewResponse),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the notifications from the API and updates the local state
|
||||
* @param userId
|
||||
* @private
|
||||
*/
|
||||
private async fetchNotificationsFromApi(userId: UserId): Promise<void> {
|
||||
const res = await this.apiService.send(
|
||||
"GET",
|
||||
`/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
const response = new ListResponse(res, NotificationViewResponse);
|
||||
|
||||
if (response.continuationToken != null) {
|
||||
this.logService.warning(
|
||||
`More notifications available, but not fetched. Consider increasing the page size from ${DEFAULT_NOTIFICATION_PAGE_SIZE}`,
|
||||
);
|
||||
}
|
||||
|
||||
const notificationData = response.data.map((n) => new NotificationViewData(n));
|
||||
await this.replaceNotificationState(userId, notificationData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the local state with notifications and returns the updated state
|
||||
* @param userId
|
||||
* @param notifications
|
||||
* @private
|
||||
*/
|
||||
private replaceNotificationState(
|
||||
userId: UserId,
|
||||
notifications: NotificationViewData[],
|
||||
): Promise<NotificationViewData[] | null> {
|
||||
return this.notificationState(userId).update(() => notifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the local state adding the new notification or updates an existing one with the same id
|
||||
* Returns the entire updated notifications state
|
||||
* @param userId
|
||||
* @param notification
|
||||
* @private
|
||||
*/
|
||||
private async upsertNotification(
|
||||
userId: UserId,
|
||||
notification: NotificationViewData,
|
||||
): Promise<NotificationViewData[] | null> {
|
||||
return this.notificationState(userId).update((current) => {
|
||||
current ??= [];
|
||||
|
||||
const existingIndex = current.findIndex((n) => n.id === notification.id);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
current.push(notification);
|
||||
} else {
|
||||
current[existingIndex] = notification;
|
||||
}
|
||||
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local state for notifications
|
||||
* @param userId
|
||||
* @private
|
||||
*/
|
||||
private notificationState(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, NOTIFICATIONS);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<ng-template #loading>
|
||||
<div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
|
||||
<ng-template #spinner>
|
||||
<div class="tw-flex tw-items-center tw-justify-center">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-container *ngIf="unlockOptions; else loading">
|
||||
<ng-container *ngIf="unlockOptions && !loading; else spinner">
|
||||
<!-- Biometrics Unlock -->
|
||||
<ng-container *ngIf="activeUnlockOption === UnlockOption.Biometrics">
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angula
|
||||
import { Router } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
interval,
|
||||
mergeMap,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -88,6 +90,7 @@ const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000;
|
||||
})
|
||||
export class LockComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
protected loading = true;
|
||||
|
||||
activeAccount: Account | null = null;
|
||||
|
||||
@@ -122,6 +125,9 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
|
||||
formGroup: FormGroup | null = null;
|
||||
|
||||
// Browser extension properties:
|
||||
private shouldClosePopout = false;
|
||||
|
||||
// Desktop properties:
|
||||
private deferFocus: boolean | null = null;
|
||||
private biometricAsked = false;
|
||||
@@ -228,22 +234,22 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
private listenForActiveAccountChanges() {
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
switchMap((account) => {
|
||||
return this.handleActiveAccountChange(account);
|
||||
tap((account) => {
|
||||
this.loading = true;
|
||||
this.activeAccount = account;
|
||||
this.resetDataOnActiveAccountChange();
|
||||
}),
|
||||
filter((account): account is Account => account != null),
|
||||
switchMap(async (account) => {
|
||||
await this.handleActiveAccountChange(account);
|
||||
this.loading = false;
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private async handleActiveAccountChange(activeAccount: Account | null) {
|
||||
this.activeAccount = activeAccount;
|
||||
|
||||
this.resetDataOnActiveAccountChange();
|
||||
|
||||
if (activeAccount == null) {
|
||||
return;
|
||||
}
|
||||
private async handleActiveAccountChange(activeAccount: Account) {
|
||||
// this account may be unlocked, prevent any prompts so we can redirect to vault
|
||||
if (await this.keyService.hasUserKeyInMemory(activeAccount.id)) {
|
||||
return;
|
||||
@@ -300,16 +306,12 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
// desktop and extension.
|
||||
if (this.clientType === "desktop") {
|
||||
if (autoPromptBiometrics) {
|
||||
this.loading = false;
|
||||
await this.desktopAutoPromptBiometrics();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.clientType === "browser") {
|
||||
// Firefox closes the popup when unfocused, so this would block all unlock methods
|
||||
if (this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
this.unlockOptions?.biometrics.enabled &&
|
||||
autoPromptBiometrics &&
|
||||
@@ -323,6 +325,12 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
isNaN(lastProcessReload.getTime()) ||
|
||||
Date.now() - lastProcessReload.getTime() > AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY
|
||||
) {
|
||||
// Firefox extension closes the popup when unfocused during biometric unlock, pop out the window to prevent infinite loop.
|
||||
if (this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension) {
|
||||
await this.lockComponentService.popOutBrowserExtension();
|
||||
this.shouldClosePopout = true;
|
||||
}
|
||||
this.loading = false;
|
||||
await this.unlockViaBiometrics();
|
||||
}
|
||||
}
|
||||
@@ -637,6 +645,13 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
const successRoute = clientTypeToSuccessRouteRecord[this.clientType];
|
||||
await this.router.navigate([successRoute]);
|
||||
}
|
||||
|
||||
if (
|
||||
this.shouldClosePopout &&
|
||||
this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension
|
||||
) {
|
||||
this.lockComponentService.closeBrowserExtensionPopout();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,18 @@ export abstract class LockComponentService {
|
||||
// Extension
|
||||
abstract getBiometricsError(error: any): string | null;
|
||||
abstract getPreviousUrl(): string | null;
|
||||
/**
|
||||
* Opens the current page in a popout window if not already in a popout or the sidebar.
|
||||
* If already in a popout or sidebar, does nothing.
|
||||
* @throws Error if execution context is not a browser extension.
|
||||
*/
|
||||
abstract popOutBrowserExtension(): Promise<void>;
|
||||
/**
|
||||
* Closes the current popout window if in a popout.
|
||||
* If not in a popout, does nothing.
|
||||
* @throws Error if execution context is not a browser extension.
|
||||
*/
|
||||
abstract closeBrowserExtensionPopout(): void;
|
||||
|
||||
// Desktop only
|
||||
abstract isWindowVisible(): Promise<boolean>;
|
||||
|
||||
@@ -23,7 +23,6 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon
|
||||
export * from "./components/carousel";
|
||||
|
||||
export * as VaultIcons from "./icons";
|
||||
export * from "./notifications";
|
||||
export * from "./services/vault-nudges.service";
|
||||
|
||||
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./abstractions/end-user-notification.service";
|
||||
export * from "./services/default-end-user-notification.service";
|
||||
@@ -1,200 +0,0 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { DefaultEndUserNotificationService } from "@bitwarden/vault";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../common/spec";
|
||||
import { NotificationViewResponse } from "../models";
|
||||
import { NOTIFICATIONS } from "../state/end-user-notification.state";
|
||||
|
||||
describe("End User Notification Center Service", () => {
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
|
||||
const mockApiSend = jest.fn();
|
||||
|
||||
let testBed: TestBed;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockApiSend.mockClear();
|
||||
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
|
||||
testBed = TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
providers: [
|
||||
DefaultEndUserNotificationService,
|
||||
{
|
||||
provide: StateProvider,
|
||||
useValue: fakeStateProvider,
|
||||
},
|
||||
{
|
||||
provide: ApiService,
|
||||
useValue: {
|
||||
send: mockApiSend,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: NotificationsService,
|
||||
useValue: {
|
||||
notifications$: of(null),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifications$", () => {
|
||||
it("should return notifications from state when not null", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
const { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const result = await firstValueFrom(notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return notifications API when state is null", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any);
|
||||
|
||||
const { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const result = await firstValueFrom(notifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true);
|
||||
});
|
||||
|
||||
it("should share the same observable for the same user", async () => {
|
||||
const { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const first = notifications$("user-id" as UserId);
|
||||
const second = notifications$("user-id" as UserId);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreadNotifications$", () => {
|
||||
it("should return unread notifications from state when read value is null", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
readDate: null as any,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
const { unreadNotifications$ } = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
const result = await firstValueFrom(unreadNotifications$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNotifications", () => {
|
||||
it("should call getNotifications returning notifications from API", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.getNotifications("user-id" as UserId);
|
||||
|
||||
expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true);
|
||||
});
|
||||
});
|
||||
it("should update local state when notifications are updated", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "notification-id",
|
||||
},
|
||||
] as NotificationViewResponse[],
|
||||
});
|
||||
|
||||
const mock = fakeStateProvider.singleUser.mockFor(
|
||||
"user-id" as UserId,
|
||||
NOTIFICATIONS,
|
||||
null as any,
|
||||
);
|
||||
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.getNotifications("user-id" as UserId);
|
||||
|
||||
expect(mock.nextMock).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse),
|
||||
]);
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should clear the local notification state for the user", async () => {
|
||||
const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
} as NotificationViewResponse,
|
||||
]);
|
||||
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.clearState("user-id" as UserId);
|
||||
|
||||
expect(mock.nextMock).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAsDeleted", () => {
|
||||
it("should send an API request to mark the notification as deleted", async () => {
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.markAsDeleted("notification-id" as NotificationId, "user-id" as UserId);
|
||||
expect(mockApiSend).toHaveBeenCalledWith(
|
||||
"DELETE",
|
||||
"/notifications/notification-id/delete",
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAsRead", () => {
|
||||
it("should send an API request to mark the notification as read", async () => {
|
||||
const service = testBed.inject(DefaultEndUserNotificationService);
|
||||
|
||||
await service.markAsRead("notification-id" as NotificationId, "user-id" as UserId);
|
||||
expect(mockApiSend).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
"/notifications/notification-id/read",
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { concatMap, filter, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { NotificationType } from "@bitwarden/common/enums";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
filterOutNullish,
|
||||
perUserCache$,
|
||||
} from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
|
||||
import { EndUserNotificationService } from "../abstractions/end-user-notification.service";
|
||||
import { NotificationView, NotificationViewData, NotificationViewResponse } from "../models";
|
||||
import { NOTIFICATIONS } from "../state/end-user-notification.state";
|
||||
|
||||
/**
|
||||
* A service for retrieving and managing notifications for end users.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class DefaultEndUserNotificationService implements EndUserNotificationService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private apiService: ApiService,
|
||||
private defaultNotifications: NotificationsService,
|
||||
) {
|
||||
this.defaultNotifications.notifications$
|
||||
.pipe(
|
||||
filter(
|
||||
([notification]) =>
|
||||
notification.type === NotificationType.Notification ||
|
||||
notification.type === NotificationType.NotificationStatus,
|
||||
),
|
||||
concatMap(([notification, userId]) =>
|
||||
this.updateNotificationState(userId, [
|
||||
new NotificationViewData(notification.payload as NotificationViewResponse),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
notifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
|
||||
return this.notificationState(userId).state$.pipe(
|
||||
switchMap(async (notifications) => {
|
||||
if (notifications == null) {
|
||||
await this.fetchNotificationsFromApi(userId);
|
||||
}
|
||||
return notifications;
|
||||
}),
|
||||
filterOutNullish(),
|
||||
map((notifications) =>
|
||||
notifications.map((notification) => new NotificationView(notification)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
unreadNotifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
|
||||
return this.notifications$(userId).pipe(
|
||||
map((notifications) => notifications.filter((notification) => notification.readDate == null)),
|
||||
);
|
||||
});
|
||||
|
||||
async markAsRead(notificationId: any, userId: UserId): Promise<void> {
|
||||
await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false);
|
||||
await this.getNotifications(userId);
|
||||
}
|
||||
|
||||
async markAsDeleted(notificationId: any, userId: UserId): Promise<void> {
|
||||
await this.apiService.send(
|
||||
"DELETE",
|
||||
`/notifications/${notificationId}/delete`,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
await this.getNotifications(userId);
|
||||
}
|
||||
|
||||
async clearState(userId: UserId): Promise<void> {
|
||||
await this.updateNotificationState(userId, []);
|
||||
}
|
||||
|
||||
async getNotifications(userId: UserId) {
|
||||
await this.fetchNotificationsFromApi(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the notifications from the API and updates the local state
|
||||
* @param userId
|
||||
* @private
|
||||
*/
|
||||
private async fetchNotificationsFromApi(userId: UserId): Promise<void> {
|
||||
const res = await this.apiService.send("GET", "/notifications", null, true, true);
|
||||
const response = new ListResponse(res, NotificationViewResponse);
|
||||
const notificationData = response.data.map((n) => new NotificationView(n));
|
||||
await this.updateNotificationState(userId, notificationData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the local state with notifications and returns the updated state
|
||||
* @param userId
|
||||
* @param notifications
|
||||
* @private
|
||||
*/
|
||||
private updateNotificationState(
|
||||
userId: UserId,
|
||||
notifications: NotificationViewData[],
|
||||
): Promise<NotificationViewData[] | null> {
|
||||
return this.notificationState(userId).update(() => notifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local state for notifications
|
||||
* @param userId
|
||||
* @private
|
||||
*/
|
||||
private notificationState(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, NOTIFICATIONS);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user