1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-18 18:33:50 +00:00

merge main

This commit is contained in:
Jimmy Vo
2025-04-21 15:19:22 -04:00
30 changed files with 723 additions and 370 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,2 @@
export { EndUserNotificationService } from "./abstractions/end-user-notification.service";
export { DefaultEndUserNotificationService } from "./services/default-end-user-notification.service";

View File

@@ -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,
});

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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,
);
});
});
});

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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();
}
}
/**

View File

@@ -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>;

View File

@@ -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";

View File

@@ -1,2 +0,0 @@
export * from "./abstractions/end-user-notification.service";
export * from "./services/default-end-user-notification.service";

View File

@@ -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,
);
});
});
});

View File

@@ -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);
}
}