1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-05 11:13:44 +00:00

feat(inactive-user-server-notification): [PM-25130] Inactive User Server Notify - Added feature flag.

This commit is contained in:
Patrick Pimentel
2025-08-25 16:24:04 -04:00
parent aa7984cfb7
commit 6f1661e247
5 changed files with 76 additions and 31 deletions

View File

@@ -1139,6 +1139,7 @@ export default class MainBackground {
new SignalRConnectionService(this.apiService, this.logService),
this.authService,
this.webPushConnectionService,
this.configService,
);
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);

View File

@@ -53,6 +53,7 @@ export enum FeatureFlag {
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
InactiveUserServerNotification = "pm-25130-receive-push-notifications-for-inactive-users",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -112,6 +113,7 @@ export const DefaultFeatureFlagValue = {
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,
[FeatureFlag.InactiveUserServerNotification]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -3,6 +3,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "@bitwarden/auth/common";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
// TODO: When PM-14943 goes in, uncomment
// import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
@@ -34,7 +35,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
let webPushNotificationConnectionService: MockProxy<WebPushConnectionService>;
// TODO: When PM-14943 goes in, uncomment
// let authRequestAnsweringService: MockProxy<AuthRequestAnsweringServiceAbstraction>;
let configurationService: MockProxy<ConfigService>;
let configService: MockProxy<ConfigService>;
let activeUserAccount$: BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>;
let userAccounts$: BehaviorSubject<ObservedValueOf<AccountService["accounts$"]>>;
@@ -89,7 +90,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
signalRNotificationConnectionService = mock<SignalRConnectionService>();
connectionSubjectByUser = new Map();
(signalRNotificationConnectionService.connect$ as unknown as jest.Mock).mockImplementation(
signalRNotificationConnectionService.connect$.mockImplementation(
(userId: UserId, _url: string) => {
if (!connectionSubjectByUser.has(userId)) {
connectionSubjectByUser.set(userId, new Subject<any>());
@@ -100,7 +101,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
authService = mock<AuthService>();
authenticationStatusByUser = new Map();
(authService.authStatusFor$ as unknown as jest.Mock).mockImplementation((userId: UserId) => {
authService.authStatusFor$.mockImplementation((userId: UserId) => {
if (!authenticationStatusByUser.has(userId)) {
authenticationStatusByUser.set(
userId,
@@ -112,9 +113,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
webPushNotificationConnectionService = mock<WebPushConnectionService>();
webPushSupportStatusByUser = new Map();
(
webPushNotificationConnectionService.supportStatus$ as unknown as jest.Mock
).mockImplementation((userId: UserId) => {
webPushNotificationConnectionService.supportStatus$.mockImplementation((userId: UserId) => {
if (!webPushSupportStatusByUser.has(userId)) {
webPushSupportStatusByUser.set(
userId,
@@ -127,8 +126,13 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
// TODO: When PM-14943 goes in, uncomment
// authRequestAnsweringService = mock<AuthRequestAnsweringServiceAbstraction>();
configurationService = mock<ConfigService>();
configurationService.getFeatureFlag$.mockReturnValue(new BehaviorSubject(true) as any);
configService = mock<ConfigService>();
configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => {
const flagValueByFlag: Partial<Record<FeatureFlag, boolean>> = {
[FeatureFlag.InactiveUserServerNotification]: true,
};
return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any;
});
defaultServerNotificationsService = new DefaultServerNotificationsService(
mock<LogService>(),
@@ -142,7 +146,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
authService,
webPushNotificationConnectionService,
// authRequestAnsweringService,
// configurationService,
configService,
);
});
@@ -254,15 +258,13 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
const subscription = defaultServerNotificationsService.startListening();
// Emit via SignalR connect$ for user2
connectionSubjectByUser
.get(mockUserId2)!
.next({
type: "ReceiveMessage",
message: new NotificationResponse({
type: NotificationType.AuthRequest,
payload: { id: "auth-id-2", userId: mockUserId2 },
}),
});
connectionSubjectByUser.get(mockUserId2)!.next({
type: "ReceiveMessage",
message: new NotificationResponse({
type: NotificationType.AuthRequest,
payload: { id: "auth-id-2", userId: mockUserId2 },
}),
});
// allow async queue to drain
await new Promise((resolve) => setTimeout(resolve, 0));

View File

@@ -4,6 +4,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "@bitwarden/auth/common";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { awaitAsync } from "../../../../spec";
import { Matrix } from "../../../../spec/matrix";
@@ -14,6 +15,7 @@ import { NotificationType } from "../../../enums";
import { NotificationResponse } from "../../../models/response/notification.response";
import { UserId } from "../../../types/guid";
import { AppIdService } from "../../abstractions/app-id.service";
import { ConfigService } from "../../abstractions/config/config.service";
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service";
import { MessageSender } from "../../messaging";
@@ -38,6 +40,7 @@ describe("NotificationsService", () => {
let signalRNotificationConnectionService: MockProxy<SignalRConnectionService>;
let authService: MockProxy<AuthService>;
let webPushNotificationConnectionService: MockProxy<WebPushConnectionService>;
let configService: MockProxy<ConfigService>;
let activeAccount: BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>;
let accounts: BehaviorSubject<ObservedValueOf<AccountService["accounts$"]>>;
@@ -65,6 +68,14 @@ describe("NotificationsService", () => {
signalRNotificationConnectionService = mock<SignalRConnectionService>();
authService = mock<AuthService>();
webPushNotificationConnectionService = mock<WorkerWebPushConnectionService>();
configService = mock<ConfigService>();
// For these tests, use the active-user implementation (feature flag disabled)
configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => {
const flagValueByFlag: Partial<Record<FeatureFlag, boolean>> = {
[FeatureFlag.InactiveUserServerNotification]: false,
};
return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any;
});
activeAccount = new BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>(null);
accountService.activeAccount$ = activeAccount.asObservable();
@@ -108,6 +119,7 @@ describe("NotificationsService", () => {
signalRNotificationConnectionService,
authService,
webPushNotificationConnectionService,
configService,
);
});

View File

@@ -16,6 +16,8 @@ import {
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { LogoutReason } from "@bitwarden/auth/common";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
@@ -57,22 +59,48 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
private readonly signalRConnectionService: SignalRConnectionService,
private readonly authService: AuthService,
private readonly webPushConnectionService: WebPushConnectionService,
private readonly configService: ConfigService,
) {
this.notifications$ = this.accountService.accounts$.pipe(
map((accounts) => Object.keys(accounts) as UserId[]),
switchMap((userIds) => {
if (userIds.length === 0) {
return EMPTY;
}
this.notifications$ = this.configService
.getFeatureFlag$(FeatureFlag.InactiveUserServerNotification)
.pipe(
switchMap((inactiveUserServerNotificationEnabled) => {
if (inactiveUserServerNotificationEnabled) {
return this.accountService.accounts$.pipe(
map((accounts) => Object.keys(accounts) as UserId[]),
switchMap((userIds) => {
if (userIds.length === 0) {
return EMPTY;
}
const streams = userIds.map((id) =>
this.userNotifications$(id).pipe(map((notification) => [notification, id] as const)),
);
const streams = userIds.map((id) =>
this.userNotifications$(id).pipe(
map((notification) => [notification, id] as const),
),
);
return merge(...streams);
}),
share(), // Multiple subscribers should only create a single connection to the server per subscriber
);
return merge(...streams);
}),
);
}
return this.accountService.activeAccount$.pipe(
map((account) => account?.id),
distinctUntilChanged(),
switchMap((activeAccountId) => {
if (activeAccountId == null) {
// We don't emit server-notifications for inactive accounts currently
return EMPTY;
}
return this.userNotifications$(activeAccountId).pipe(
map((notification) => [notification, activeAccountId] as const),
);
}),
);
}),
share(), // Multiple subscribers should only create a single connection to the server
);
}
/**