diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 2b6827aafa4..3066ef5eef5 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -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, @@ -402,6 +406,7 @@ export default class MainBackground { sdkService: SdkService; sdkLoadService: SdkLoadService; cipherAuthorizationService: CipherAuthorizationService; + endUserNotificationService: EndUserNotificationService; inlineMenuFieldQualificationService: InlineMenuFieldQualificationService; taskService: TaskService; @@ -1320,6 +1325,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() { @@ -1406,6 +1419,9 @@ export default class MainBackground { this.taskService.listenForTaskNotifications(); } + if (await this.configService.getFeatureFlag(FeatureFlag.EndUserNotifications)) { + this.endUserNotificationService.listenForEndUserNotifications(); + } resolve(); }, 500); }); diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts index 25bf3ce3716..ff583061684 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts @@ -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; let mockCiphers$: BehaviorSubject; let mockOrgs$: BehaviorSubject; + let mockNotifications$: BehaviorSubject; let mockInlineMenuVisibility$: BehaviorSubject; let calloutDismissed$: BehaviorSubject; const setInlineMenuVisibility = jest.fn(); @@ -73,6 +77,7 @@ describe("AtRiskPasswordsComponent", () => { const mockAtRiskPasswordPageService = mock(); const mockChangeLoginPasswordService = mock(); const mockDialogService = mock(); + const mockConfigService = mock(); beforeEach(async () => { mockTasks$ = new BehaviorSubject([ @@ -101,6 +106,7 @@ describe("AtRiskPasswordsComponent", () => { name: "Org 1", } as Organization, ]); + mockNotifications$ = new BehaviorSubject([]); mockInlineMenuVisibility$ = new BehaviorSubject( 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() }, @@ -145,6 +158,7 @@ describe("AtRiskPasswordsComponent", () => { }, }, { provide: ToastService, useValue: mockToastService }, + { provide: ConfigService, useValue: mockConfigService }, ], }) .overrideModule(JslibModule, { diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts index 37c445f6c30..1b43151193a 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts @@ -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) { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 8e2b3409593..1cc2b591412 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -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, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 1d0b1521db6..08906c792fb 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -55,6 +55,7 @@ export enum FeatureFlag { VaultBulkManagementAction = "vault-bulk-management-action", SecurityTasks = "security-tasks", CipherKeyEncryption = "cipher-key-encryption", + EndUserNotifications = "pm-10609-end-user-notifications", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -105,6 +106,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.VaultBulkManagementAction]: FALSE, [FeatureFlag.SecurityTasks]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE, + [FeatureFlag.EndUserNotifications]: FALSE, /* Auth */ [FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE, diff --git a/libs/common/src/models/response/notification.response.ts b/libs/common/src/models/response/notification.response.ts index aa0ecc97b58..d1bf96b1956 100644 --- a/libs/common/src/models/response/notification.response.ts +++ b/libs/common/src/models/response/notification.response.ts @@ -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; } diff --git a/libs/vault/src/notifications/abstractions/end-user-notification.service.ts b/libs/common/src/vault/notifications/abstractions/end-user-notification.service.ts similarity index 64% rename from libs/vault/src/notifications/abstractions/end-user-notification.service.ts rename to libs/common/src/vault/notifications/abstractions/end-user-notification.service.ts index fe2852994f7..bc5dd4d97a4 100644 --- a/libs/vault/src/notifications/abstractions/end-user-notification.service.ts +++ b/libs/common/src/vault/notifications/abstractions/end-user-notification.service.ts @@ -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; + abstract markAsRead(notificationId: NotificationId, userId: UserId): Promise; /** * Mark a notification as deleted. * @param notificationId * @param userId */ - abstract markAsDeleted(notificationId: any, userId: UserId): Promise; + abstract markAsDeleted(notificationId: NotificationId, userId: UserId): Promise; /** * Clear all notifications from state for the given user. * @param userId */ abstract clearState(userId: UserId): Promise; + + /** + * Creates a subscription to listen for end user push notifications and notification status updates. + */ + abstract listenForEndUserNotifications(): Subscription; } diff --git a/libs/common/src/vault/notifications/index.ts b/libs/common/src/vault/notifications/index.ts new file mode 100644 index 00000000000..768262be943 --- /dev/null +++ b/libs/common/src/vault/notifications/index.ts @@ -0,0 +1,2 @@ +export { EndUserNotificationService } from "./abstractions/end-user-notification.service"; +export { DefaultEndUserNotificationService } from "./services/default-end-user-notification.service"; diff --git a/libs/vault/src/notifications/models/index.ts b/libs/common/src/vault/notifications/models/index.ts similarity index 100% rename from libs/vault/src/notifications/models/index.ts rename to libs/common/src/vault/notifications/models/index.ts diff --git a/libs/vault/src/notifications/models/notification-view.data.ts b/libs/common/src/vault/notifications/models/notification-view.data.ts similarity index 85% rename from libs/vault/src/notifications/models/notification-view.data.ts rename to libs/common/src/vault/notifications/models/notification-view.data.ts index 07c147052ad..60314a44684 100644 --- a/libs/vault/src/notifications/models/notification-view.data.ts +++ b/libs/common/src/vault/notifications/models/notification-view.data.ts @@ -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, }); diff --git a/libs/vault/src/notifications/models/notification-view.response.ts b/libs/common/src/vault/notifications/models/notification-view.response.ts similarity index 81% rename from libs/vault/src/notifications/models/notification-view.response.ts rename to libs/common/src/vault/notifications/models/notification-view.response.ts index bbebf25bd4e..b4b7d8d94cc 100644 --- a/libs/vault/src/notifications/models/notification-view.response.ts +++ b/libs/common/src/vault/notifications/models/notification-view.response.ts @@ -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"); } diff --git a/libs/vault/src/notifications/models/notification-view.ts b/libs/common/src/vault/notifications/models/notification-view.ts similarity index 75% rename from libs/vault/src/notifications/models/notification-view.ts rename to libs/common/src/vault/notifications/models/notification-view.ts index b577a889d05..21d55ec0aed 100644 --- a/libs/vault/src/notifications/models/notification-view.ts +++ b/libs/common/src/vault/notifications/models/notification-view.ts @@ -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; } diff --git a/libs/common/src/vault/notifications/services/default-end-user-notification.service.spec.ts b/libs/common/src/vault/notifications/services/default-end-user-notification.service.spec.ts new file mode 100644 index 00000000000..89a78d6f7d2 --- /dev/null +++ b/libs/common/src/vault/notifications/services/default-end-user-notification.service.spec.ts @@ -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; + let mockNotificationsService: jest.Mocked; + let mockAuthService: jest.Mocked; + let mockLogService: jest.Mocked; + 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(); + + 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, + ); + }); + }); +}); diff --git a/libs/common/src/vault/notifications/services/default-end-user-notification.service.ts b/libs/common/src/vault/notifications/services/default-end-user-notification.service.ts new file mode 100644 index 00000000000..87f97b48c27 --- /dev/null +++ b/libs/common/src/vault/notifications/services/default-end-user-notification.service.ts @@ -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, 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 => { + 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 => { + return this.notifications$(userId).pipe( + map((notifications) => notifications.filter((notification) => notification.readDate == null)), + ); + }); + + async markAsRead(notificationId: NotificationId, userId: UserId): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/libs/vault/src/notifications/state/end-user-notification.state.ts b/libs/common/src/vault/notifications/state/end-user-notification.state.ts similarity index 100% rename from libs/vault/src/notifications/state/end-user-notification.state.ts rename to libs/common/src/vault/notifications/state/end-user-notification.state.ts diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 0ab85f47252..f3658046a3d 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -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"; diff --git a/libs/vault/src/notifications/index.ts b/libs/vault/src/notifications/index.ts deleted file mode 100644 index 0c9d5c0d16b..00000000000 --- a/libs/vault/src/notifications/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./abstractions/end-user-notification.service"; -export * from "./services/default-end-user-notification.service"; diff --git a/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts b/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts deleted file mode 100644 index 1d7b2e5aa19..00000000000 --- a/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts +++ /dev/null @@ -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, - ); - }); - }); -}); diff --git a/libs/vault/src/notifications/services/default-end-user-notification.service.ts b/libs/vault/src/notifications/services/default-end-user-notification.service.ts deleted file mode 100644 index 471ed0e5856..00000000000 --- a/libs/vault/src/notifications/services/default-end-user-notification.service.ts +++ /dev/null @@ -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 => { - 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 => { - return this.notifications$(userId).pipe( - map((notifications) => notifications.filter((notification) => notification.readDate == null)), - ); - }); - - async markAsRead(notificationId: any, userId: UserId): Promise { - await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false); - await this.getNotifications(userId); - } - - async markAsDeleted(notificationId: any, userId: UserId): Promise { - await this.apiService.send( - "DELETE", - `/notifications/${notificationId}/delete`, - null, - true, - false, - ); - await this.getNotifications(userId); - } - - async clearState(userId: UserId): Promise { - 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 { - 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 { - 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); - } -}