From a7fe4877d7dea66780a1c18c952a677c84f33235 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Fri, 4 Apr 2025 13:42:44 -0700 Subject: [PATCH] [PM-17563] Security task background synchronization (#14086) * [PM-17563] Implement listenForTaskNotifications in default-task.service.ts * [PM-17563] Update syncService to include userId in syncCompleted message payload * [PM-17563] Update default-task.service to react to both pending task notifications and completed syncs * [PM-17563] Add unit tests around task notification listening * [PM-17563] Only check for at risk password tasks if tasks are enabled * [PM-17563] Make userId required even if undefined * [PM-17563] Use abstract TaskService instead of default implementation in MainBackground * [PM-17563] Cleanup userId filtering --- .../browser/src/background/main.background.ts | 17 ++ .../at-risk-password-callout.component.ts | 29 +- .../src/services/jslib-services.module.ts | 10 +- .../src/enums/notification-type.enum.ts | 2 + .../src/platform/sync/core-sync.service.ts | 32 +-- .../src/platform/sync/default-sync.service.ts | 14 +- .../vault/tasks/abstractions/task.service.ts | 7 +- .../services/default-task.service.spec.ts | 249 +++++++++++++++++- .../tasks/services/default-task.service.ts | 79 +++++- 9 files changed, 400 insertions(+), 39 deletions(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index ffce7827358..663fc863f5e 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -200,6 +200,7 @@ import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service"; +import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks"; import { legacyPasswordGenerationServiceFactory, legacyUsernameGenerationServiceFactory, @@ -400,6 +401,7 @@ export default class MainBackground { sdkLoadService: SdkLoadService; cipherAuthorizationService: CipherAuthorizationService; inlineMenuFieldQualificationService: InlineMenuFieldQualificationService; + taskService: TaskService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -1296,6 +1298,16 @@ export default class MainBackground { this.configService, ); + this.taskService = new DefaultTaskService( + this.stateProvider, + this.apiService, + this.organizationService, + this.configService, + this.authService, + this.notificationsService, + messageListener, + ); + this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); } @@ -1377,6 +1389,11 @@ export default class MainBackground { await this.fullSync(false); this.backgroundSyncService.init(); this.notificationsService.startListening(); + + if (await this.configService.getFeatureFlag(FeatureFlag.SecurityTasks)) { + this.taskService.listenForTaskNotifications(); + } + resolve(); }, 500); }); diff --git a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts index eb5cd459111..fa4137d9849 100644 --- a/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-callout/at-risk-password-callout.component.ts @@ -1,16 +1,14 @@ import { CommonModule } from "@angular/common"; import { Component, inject } from "@angular/core"; import { RouterModule } from "@angular/router"; -import { map, switchMap } from "rxjs"; +import { map, of, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; -import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -// TODO: This component will need to be reworked to use the new EndUserNotificationService in PM-10609 - @Component({ selector: "vault-at-risk-password-callout", standalone: true, @@ -19,15 +17,24 @@ import { I18nPipe } from "@bitwarden/ui-common"; }) export class AtRiskPasswordCalloutComponent { private taskService = inject(TaskService); - private activeAccount$ = inject(AccountService).activeAccount$.pipe(filterOutNullish()); + private activeAccount$ = inject(AccountService).activeAccount$.pipe(getUserId); protected pendingTasks$ = this.activeAccount$.pipe( - switchMap((user) => - this.taskService - .pendingTasks$(user.id) - .pipe( - map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)), - ), + switchMap((userId) => + this.taskService.tasksEnabled$(userId).pipe( + switchMap((enabled) => { + if (!enabled) { + return of([]); + } + return this.taskService + .pendingTasks$(userId) + .pipe( + map((tasks) => + tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential), + ), + ); + }), + ), ), ); } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index fc8be749c94..42fca029ec5 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1475,7 +1475,15 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: TaskService, useClass: DefaultTaskService, - deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService], + deps: [ + StateProvider, + ApiServiceAbstraction, + OrganizationServiceAbstraction, + ConfigService, + AuthServiceAbstraction, + NotificationsService, + MessageListener, + ], }), safeProvider({ provide: EndUserNotificationService, diff --git a/libs/common/src/enums/notification-type.enum.ts b/libs/common/src/enums/notification-type.enum.ts index c366af1eb61..0e4d0bfee3d 100644 --- a/libs/common/src/enums/notification-type.enum.ts +++ b/libs/common/src/enums/notification-type.enum.ts @@ -26,4 +26,6 @@ export enum NotificationType { SyncOrganizationCollectionSettingChanged = 19, Notification = 20, NotificationStatus = 21, + + PendingSecurityTasks = 22, } diff --git a/libs/common/src/platform/sync/core-sync.service.ts b/libs/common/src/platform/sync/core-sync.service.ts index 92a10baf6d2..1865ffb852f 100644 --- a/libs/common/src/platform/sync/core-sync.service.ts +++ b/libs/common/src/platform/sync/core-sync.service.ts @@ -105,14 +105,14 @@ export abstract class CoreSyncService implements SyncService { if (remoteFolder != null) { await this.folderService.upsert(new FolderData(remoteFolder), userId); this.messageSender.send("syncedUpsertedFolder", { folderId: notification.id }); - return this.syncCompleted(true); + return this.syncCompleted(true, userId); } } } catch (e) { this.logService.error(e); } } - return this.syncCompleted(false); + return this.syncCompleted(false, userId); } async syncDeleteFolder(notification: SyncFolderNotification, userId: UserId): Promise { @@ -123,10 +123,10 @@ export abstract class CoreSyncService implements SyncService { if (authStatus >= AuthenticationStatus.Locked) { await this.folderService.delete(notification.id, userId); this.messageSender.send("syncedDeletedFolder", { folderId: notification.id }); - this.syncCompleted(true); + this.syncCompleted(true, userId); return true; } - return this.syncCompleted(false); + return this.syncCompleted(false, userId); } async syncUpsertCipher( @@ -183,18 +183,18 @@ export abstract class CoreSyncService implements SyncService { if (remoteCipher != null) { await this.cipherService.upsert(new CipherData(remoteCipher)); this.messageSender.send("syncedUpsertedCipher", { cipherId: notification.id }); - return this.syncCompleted(true); + return this.syncCompleted(true, userId); } } } catch (e) { if (e != null && e.statusCode === 404 && isEdit) { await this.cipherService.delete(notification.id, userId); this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id }); - return this.syncCompleted(true); + return this.syncCompleted(true, userId); } } } - return this.syncCompleted(false); + return this.syncCompleted(false, userId); } async syncDeleteCipher(notification: SyncCipherNotification, userId: UserId): Promise { @@ -204,9 +204,9 @@ export abstract class CoreSyncService implements SyncService { if (authStatus >= AuthenticationStatus.Locked) { await this.cipherService.delete(notification.id, userId); this.messageSender.send("syncedDeletedCipher", { cipherId: notification.id }); - return this.syncCompleted(true); + return this.syncCompleted(true, userId); } - return this.syncCompleted(false); + return this.syncCompleted(false, userId); } async syncUpsertSend(notification: SyncSendNotification, isEdit: boolean): Promise { @@ -234,14 +234,15 @@ export abstract class CoreSyncService implements SyncService { if (remoteSend != null) { await this.sendService.upsert(new SendData(remoteSend)); this.messageSender.send("syncedUpsertedSend", { sendId: notification.id }); - return this.syncCompleted(true); + return this.syncCompleted(true, activeUserId); } } } catch (e) { this.logService.error(e); } } - return this.syncCompleted(false); + // TODO: Update syncCompleted userId when send service allows modification of non-active users + return this.syncCompleted(false, undefined); } async syncDeleteSend(notification: SyncSendNotification): Promise { @@ -249,10 +250,11 @@ export abstract class CoreSyncService implements SyncService { if (await this.stateService.getIsAuthenticated()) { await this.sendService.delete(notification.id); this.messageSender.send("syncedDeletedSend", { sendId: notification.id }); - this.syncCompleted(true); + // TODO: Update syncCompleted userId when send service allows modification of non-active users + this.syncCompleted(true, undefined); return true; } - return this.syncCompleted(false); + return this.syncCompleted(false, undefined); } // Helpers @@ -262,9 +264,9 @@ export abstract class CoreSyncService implements SyncService { this.messageSender.send("syncStarted"); } - protected syncCompleted(successfully: boolean): boolean { + protected syncCompleted(successfully: boolean, userId: UserId | undefined): boolean { this.syncInProgress = false; - this.messageSender.send("syncCompleted", { successfully: successfully }); + this.messageSender.send("syncCompleted", { successfully: successfully, userId }); return successfully; } } diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index 30a59e9c165..a6b1b974645 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -3,9 +3,9 @@ import { firstValueFrom, map } from "rxjs"; import { - CollectionService, CollectionData, CollectionDetailsResponse, + CollectionService, } from "@bitwarden/admin-console/common"; import { KeyService } from "@bitwarden/key-management"; @@ -107,7 +107,7 @@ export class DefaultSyncService extends CoreSyncService { this.syncStarted(); const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId)); if (authStatus === AuthenticationStatus.LoggedOut) { - return this.syncCompleted(false); + return this.syncCompleted(false, userId); } const now = new Date(); @@ -116,14 +116,14 @@ export class DefaultSyncService extends CoreSyncService { needsSync = await this.needsSyncing(forceSync); } catch (e) { if (allowThrowOnError) { - this.syncCompleted(false); + this.syncCompleted(false, userId); throw e; } } if (!needsSync) { await this.setLastSync(now, userId); - return this.syncCompleted(false); + return this.syncCompleted(false, userId); } try { @@ -139,13 +139,13 @@ export class DefaultSyncService extends CoreSyncService { await this.syncPolicies(response.policies, response.profile.id); await this.setLastSync(now, userId); - return this.syncCompleted(true); + return this.syncCompleted(true, userId); } catch (e) { if (allowThrowOnError) { - this.syncCompleted(false); + this.syncCompleted(false, userId); throw e; } else { - return this.syncCompleted(false); + return this.syncCompleted(false, userId); } } } diff --git a/libs/common/src/vault/tasks/abstractions/task.service.ts b/libs/common/src/vault/tasks/abstractions/task.service.ts index 4a0c086330e..79cefff0b71 100644 --- a/libs/common/src/vault/tasks/abstractions/task.service.ts +++ b/libs/common/src/vault/tasks/abstractions/task.service.ts @@ -1,4 +1,4 @@ -import { Observable } from "rxjs"; +import { Observable, Subscription } from "rxjs"; import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid"; @@ -43,4 +43,9 @@ export abstract class TaskService { * @param userId - The user who is completing the task. */ abstract markAsComplete(taskId: SecurityTaskId, userId: UserId): Promise; + + /** + * Creates a subscription for pending security task notifications or completed syncs for unlocked users. + */ + abstract listenForTaskNotifications(): Subscription; } diff --git a/libs/common/src/vault/tasks/services/default-task.service.spec.ts b/libs/common/src/vault/tasks/services/default-task.service.spec.ts index c38fa0e9e72..4d468d09766 100644 --- a/libs/common/src/vault/tasks/services/default-task.service.spec.ts +++ b/libs/common/src/vault/tasks/services/default-task.service.spec.ts @@ -1,9 +1,15 @@ -import { BehaviorSubject, firstValueFrom } from "rxjs"; +import { BehaviorSubject, firstValueFrom, Subject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +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 { NotificationResponse } from "@bitwarden/common/models/response/notification.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { Message, MessageListener } from "@bitwarden/common/platform/messaging"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid"; import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; @@ -16,10 +22,13 @@ import { DefaultTaskService } from "./default-task.service"; describe("Default task service", () => { let fakeStateProvider: FakeStateProvider; + const userId = "user-id" as UserId; const mockApiSend = jest.fn(); const mockGetAllOrgs$ = jest.fn(); const mockGetFeatureFlag$ = jest.fn(); - + const mockAuthStatuses$ = new BehaviorSubject>({}); + const mockNotifications$ = new Subject(); + const mockMessages$ = new Subject>>(); let service: DefaultTaskService; beforeEach(async () => { @@ -27,12 +36,15 @@ describe("Default task service", () => { mockGetAllOrgs$.mockClear(); mockGetFeatureFlag$.mockClear(); - fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId)); + fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId)); service = new DefaultTaskService( fakeStateProvider, { send: mockApiSend } as unknown as ApiService, { organizations$: mockGetAllOrgs$ } as unknown as OrganizationService, { getFeatureFlag$: mockGetFeatureFlag$ } as unknown as ConfigService, + { authStatuses$: mockAuthStatuses$.asObservable() } as unknown as AuthService, + { notifications$: mockNotifications$.asObservable() } as unknown as NotificationsService, + { allMessages$: mockMessages$.asObservable() } as unknown as MessageListener, ); }); @@ -257,4 +269,235 @@ describe("Default task service", () => { ]); }); }); + + describe("listenForTaskNotifications()", () => { + it("should not subscribe to notifications when there are no unlocked users", () => { + mockAuthStatuses$.next({ + [userId]: AuthenticationStatus.Locked, + }); + + service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true)); + const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn()); + const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn()); + + const subscription = service.listenForTaskNotifications(); + + expect(notificationHelper$).not.toHaveBeenCalled(); + expect(syncCompletedHelper$).not.toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + it("should not subscribe to notifications when no users have tasks enabled", () => { + mockAuthStatuses$.next({ + [userId]: AuthenticationStatus.Unlocked, + }); + + service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(false)); + const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn()); + const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn()); + + const subscription = service.listenForTaskNotifications(); + + expect(notificationHelper$).not.toHaveBeenCalled(); + expect(syncCompletedHelper$).not.toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + it("should subscribe to notifications when there are unlocked users with tasks enabled", () => { + mockAuthStatuses$.next({ + [userId]: AuthenticationStatus.Unlocked, + }); + service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true)); + + const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn()); + const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn()); + + const subscription = service.listenForTaskNotifications(); + + expect(notificationHelper$).toHaveBeenCalled(); + expect(syncCompletedHelper$).toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + describe("notification handling", () => { + it("should refresh tasks when a notification is received for an allowed user", async () => { + mockAuthStatuses$.next({ + [userId]: AuthenticationStatus.Unlocked, + }); + service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true)); + + const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn( + () => new Subject(), + )); + const refreshTasks = jest.spyOn(service, "refreshTasks"); + + const subscription = service.listenForTaskNotifications(); + + const notification = { + type: NotificationType.PendingSecurityTasks, + } as NotificationResponse; + mockNotifications$.next([notification, userId]); + + await new Promise(process.nextTick); + + expect(syncCompletedHelper$).toHaveBeenCalled(); + expect(refreshTasks).toHaveBeenCalledWith(userId); + subscription.unsubscribe(); + }); + + it("should ignore notifications for other users", async () => { + mockAuthStatuses$.next({ + [userId]: AuthenticationStatus.Unlocked, + }); + service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true)); + + const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn( + () => new Subject(), + )); + const refreshTasks = jest.spyOn(service, "refreshTasks"); + + const subscription = service.listenForTaskNotifications(); + + const notification = { + type: NotificationType.PendingSecurityTasks, + } as NotificationResponse; + mockNotifications$.next([notification, "other-user-id" as UserId]); + + await new Promise(process.nextTick); + + expect(syncCompletedHelper$).toHaveBeenCalled(); + expect(refreshTasks).not.toHaveBeenCalledWith(userId); + subscription.unsubscribe(); + }); + + it("should ignore other notifications types", async () => { + mockAuthStatuses$.next({ + [userId]: AuthenticationStatus.Unlocked, + }); + service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true)); + + const syncCompletedHelper$ = (service["syncCompletedMessage$"] = jest.fn( + () => new Subject(), + )); + const refreshTasks = jest.spyOn(service, "refreshTasks"); + + const subscription = service.listenForTaskNotifications(); + + const notification = { + type: NotificationType.SyncSettings, + } as NotificationResponse; + mockNotifications$.next([notification, userId]); + + await new Promise(process.nextTick); + + expect(syncCompletedHelper$).toHaveBeenCalled(); + expect(refreshTasks).not.toHaveBeenCalledWith(userId); + subscription.unsubscribe(); + }); + }); + + describe("sync completed handling", () => { + it("should refresh tasks when a sync completed message is received for an allowed user", async () => { + mockAuthStatuses$.next({ + [userId]: AuthenticationStatus.Unlocked, + }); + service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true)); + + const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn( + () => new Subject(), + )); + const refreshTasks = jest.spyOn(service, "refreshTasks"); + + const subscription = service.listenForTaskNotifications(); + + mockMessages$.next({ + command: "syncCompleted", + userId, + successfully: true, + }); + + await new Promise(process.nextTick); + + expect(notificationHelper$).toHaveBeenCalled(); + expect(refreshTasks).toHaveBeenCalledWith(userId); + subscription.unsubscribe(); + }); + + it("should ignore non syncCompleted messages", async () => { + mockAuthStatuses$.next({ + [userId]: AuthenticationStatus.Unlocked, + }); + service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true)); + + const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn( + () => new Subject(), + )); + const refreshTasks = jest.spyOn(service, "refreshTasks"); + + const subscription = service.listenForTaskNotifications(); + + mockMessages$.next({ + command: "other-command", + }); + + await new Promise(process.nextTick); + + expect(notificationHelper$).toHaveBeenCalled(); + expect(refreshTasks).not.toHaveBeenCalledWith(userId); + subscription.unsubscribe(); + }); + + it("should ignore failed sync messages", async () => { + mockAuthStatuses$.next({ + [userId]: AuthenticationStatus.Unlocked, + }); + service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true)); + + const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn( + () => new Subject(), + )); + const refreshTasks = jest.spyOn(service, "refreshTasks"); + + const subscription = service.listenForTaskNotifications(); + + mockMessages$.next({ + command: "syncCompleted", + userId, + successfully: false, + }); + + await new Promise(process.nextTick); + + expect(notificationHelper$).toHaveBeenCalled(); + expect(refreshTasks).not.toHaveBeenCalledWith(userId); + subscription.unsubscribe(); + }); + + it("should ignore sync messages for other users", async () => { + mockAuthStatuses$.next({ + [userId]: AuthenticationStatus.Unlocked, + }); + service.tasksEnabled$ = jest.fn(() => new BehaviorSubject(true)); + + const notificationHelper$ = (service["securityTaskNotifications$"] = jest.fn( + () => new Subject(), + )); + const refreshTasks = jest.spyOn(service, "refreshTasks"); + + const subscription = service.listenForTaskNotifications(); + + mockMessages$.next({ + command: "syncCompleted", + userId: "other-user-id" as UserId, + successfully: true, + }); + + await new Promise(process.nextTick); + + expect(notificationHelper$).toHaveBeenCalled(); + expect(refreshTasks).not.toHaveBeenCalledWith(userId); + subscription.unsubscribe(); + }); + }); + }); }); diff --git a/libs/common/src/vault/tasks/services/default-task.service.ts b/libs/common/src/vault/tasks/services/default-task.service.ts index ff370229663..016eed2e7d6 100644 --- a/libs/common/src/vault/tasks/services/default-task.service.ts +++ b/libs/common/src/vault/tasks/services/default-task.service.ts @@ -1,10 +1,15 @@ -import { combineLatest, map, switchMap } from "rxjs"; +import { combineLatest, filter, map, merge, Observable, of, Subscription, switchMap } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { MessageListener } from "@bitwarden/common/platform/messaging"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; import { StateProvider } from "@bitwarden/common/platform/state"; import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid"; @@ -14,12 +19,21 @@ import { SecurityTaskStatus } from "../enums"; import { SecurityTask, SecurityTaskData, SecurityTaskResponse } from "../models"; import { SECURITY_TASKS } from "../state/security-task.state"; +const getUnlockedUserIds = map, UserId[]>((authStatuses) => + Object.entries(authStatuses ?? {}) + .filter(([, status]) => status >= AuthenticationStatus.Unlocked) + .map(([userId]) => userId as UserId), +); + export class DefaultTaskService implements TaskService { constructor( private stateProvider: StateProvider, private apiService: ApiService, private organizationService: OrganizationService, private configService: ConfigService, + private authService: AuthService, + private notificationService: NotificationsService, + private messageListener: MessageListener, ) {} tasksEnabled$ = perUserCache$((userId) => { @@ -36,6 +50,7 @@ export class DefaultTaskService implements TaskService { switchMap(async (tasks) => { if (tasks == null) { await this.fetchTasksFromApi(userId); + return null; } return tasks; }), @@ -97,4 +112,66 @@ export class DefaultTaskService implements TaskService { ): Promise { return this.taskState(userId).update(() => tasks); } + + /** + * Helper observable that filters the list of unlocked user IDs to only those with tasks enabled. + * @private + */ + private getOnlyTaskEnabledUsers = switchMap>((unlockedUserIds) => { + if (unlockedUserIds.length === 0) { + return of([]); + } + + return combineLatest( + unlockedUserIds.map((userId) => + this.tasksEnabled$(userId).pipe(map((enabled) => (enabled ? userId : null))), + ), + ).pipe(map((userIds) => userIds.filter((userId) => userId !== null) as UserId[])); + }); + + /** + * Helper observable that emits whenever a security task notification is received for a user in the provided list. + * @private + */ + private securityTaskNotifications$(filterByUserIds: UserId[]) { + return this.notificationService.notifications$.pipe( + filter( + ([notification, userId]) => + notification.type === NotificationType.PendingSecurityTasks && + filterByUserIds.includes(userId), + ), + map(([, userId]) => userId), + ); + } + + /** + * Helper observable that emits whenever a sync is completed for a user in the provided list. + */ + private syncCompletedMessage$(filterByUserIds: UserId[]) { + return this.messageListener.allMessages$.pipe( + filter((msg) => msg.command === "syncCompleted" && !!msg.successfully && !!msg.userId), + map((msg) => msg.userId as UserId), + filter((userId) => filterByUserIds.includes(userId)), + ); + } + + /** + * Creates a subscription for pending security task notifications or completed syncs for unlocked users. + */ + listenForTaskNotifications(): Subscription { + return this.authService.authStatuses$ + .pipe( + getUnlockedUserIds, + this.getOnlyTaskEnabledUsers, + filter((allowedUserIds) => allowedUserIds.length > 0), + switchMap((allowedUserIds) => + merge( + this.securityTaskNotifications$(allowedUserIds), + this.syncCompletedMessage$(allowedUserIds), + ), + ), + switchMap((userId) => this.refreshTasks(userId)), + ) + .subscribe(); + } }