1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-09 13:10:17 +00:00

feat(extension-notification-demo): Demo using the notification api to display a clickable notification.

This commit is contained in:
Patrick Pimentel
2025-04-16 09:40:42 -04:00
parent f1a2acb0b9
commit dbbea4ff9e
10 changed files with 264 additions and 1 deletions

View File

@@ -43,6 +43,10 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
import {
DeviceManagementApprovalService,
DevicesManagementApprovalAbstraction,
} from "@bitwarden/common/auth/services/devices/device-management-approval.service";
import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation";
import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation";
import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service";
@@ -110,6 +114,7 @@ import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { SystemNotificationServiceAbstraction } from "@bitwarden/common/platform/abstractions/system-notification-service.abstraction";
import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { IpcService } from "@bitwarden/common/platform/ipc";
@@ -130,6 +135,7 @@ import {
WorkerWebPushConnectionService,
} from "@bitwarden/common/platform/notifications/internal";
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
import { ChromeExtensionSystemNotificationService } from "@bitwarden/common/platform/services/chrome-extension-system-notification.service";
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
@@ -331,6 +337,8 @@ export default class MainBackground {
exportService: VaultExportServiceAbstraction;
searchService: SearchServiceAbstraction;
notificationsService: NotificationsService;
deviceManagementApprovalService: DevicesManagementApprovalAbstraction;
systemNotificationService: SystemNotificationServiceAbstraction;
stateService: StateServiceAbstraction;
userNotificationSettingsService: UserNotificationSettingsServiceAbstraction;
autofillSettingsService: AutofillSettingsServiceAbstraction;
@@ -1078,6 +1086,17 @@ export default class MainBackground {
this.webPushConnectionService = new UnsupportedWebPushConnectionService();
}
this.systemNotificationService = new ChromeExtensionSystemNotificationService(
this.logService,
this.platformUtilsService,
);
this.deviceManagementApprovalService = new DeviceManagementApprovalService(
this.platformUtilsService,
this.logService,
this.systemNotificationService,
);
this.notificationsService = new DefaultNotificationsService(
this.logService,
this.syncService,
@@ -1089,6 +1108,7 @@ export default class MainBackground {
new SignalRConnectionService(this.apiService, this.logService),
this.authService,
this.webPushConnectionService,
this.deviceManagementApprovalService,
);
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);

View File

@@ -56,7 +56,8 @@
"unlimitedStorage",
"webNavigation",
"webRequest",
"webRequestBlocking"
"webRequestBlocking",
"notifications"
],
"__safari__permissions": [
"<all_urls>",

View File

@@ -361,4 +361,8 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return "";
}
async openPopupToPath() {
await chrome.action.openPopup();
}
}

View File

@@ -195,4 +195,8 @@ export class WebPlatformUtilsService implements PlatformUtilsService {
getAutofillKeyboardShortcut(): Promise<string> {
return null;
}
openPopupToPath(url: string): Promise<void> {
return Promise.resolve(undefined);
}
}

View File

@@ -105,6 +105,10 @@ import { AccountServiceImplementation } from "@bitwarden/common/auth/services/ac
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
import {
DeviceManagementApprovalService,
DevicesManagementApprovalAbstraction,
} from "@bitwarden/common/auth/services/devices/device-management-approval.service";
import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation";
import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation";
import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation";
@@ -182,6 +186,7 @@ import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sd
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { SystemNotificationServiceAbstraction } from "@bitwarden/common/platform/abstractions/system-notification-service.abstraction";
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
@@ -205,6 +210,7 @@ import {
TaskSchedulerService,
} from "@bitwarden/common/platform/scheduling";
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
import { ChromeExtensionSystemNotificationService } from "@bitwarden/common/platform/services/chrome-extension-system-notification.service";
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
@@ -892,6 +898,16 @@ const safeProviders: SafeProvider[] = [
useClass: UnsupportedWebPushConnectionService,
deps: [],
}),
safeProvider({
provide: SystemNotificationServiceAbstraction,
useClass: ChromeExtensionSystemNotificationService,
deps: [LogService, PlatformUtilsServiceAbstraction],
}),
safeProvider({
provide: DevicesManagementApprovalAbstraction,
useClass: DeviceManagementApprovalService,
deps: [PlatformUtilsServiceAbstraction, LogService, SystemNotificationServiceAbstraction],
}),
safeProvider({
provide: NotificationsService,
useClass: devFlagEnabled("noopNotifications")
@@ -908,6 +924,7 @@ const safeProviders: SafeProvider[] = [
SignalRConnectionService,
AuthServiceAbstraction,
WebPushConnectionService,
DevicesManagementApprovalAbstraction,
],
}),
safeProvider({

View File

@@ -0,0 +1,88 @@
import { filter, mergeMap } from "rxjs/operators";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
ButtonActions,
SystemNotificationServiceAbstraction,
SystemNotificationEvent,
ButtonLocation,
} from "@bitwarden/common/platform/abstractions/system-notification-service.abstraction";
export abstract class DevicesManagementApprovalAbstraction {
abstract receivedPendingAuthRequest(notificationId: string): Promise<void>;
abstract checkForPendingAuthRequestsToApprove(notificationId: string): Promise<void>;
}
export class DeviceManagementApprovalService implements DevicesManagementApprovalAbstraction {
constructor(
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private systemNotificationService: SystemNotificationServiceAbstraction,
) {
this.systemNotificationService.systemNotificationClicked$
.pipe(
filter(
(event: SystemNotificationEvent) => event.type === ButtonActions.AuthRequestNotification,
),
mergeMap((event: SystemNotificationEvent) =>
this.handleAuthRequestNotificationClicked(event),
),
)
.subscribe();
}
async receivedPendingAuthRequest(notificationId: string): Promise<void> {
// if popup is open, open dialog if logged in
if (await this.platformUtilsService.isViewOpen()) {
this.logService.info("Open dialog to user to approve request.");
} else {
// if not open, create a notification
// Short circuit because we don't support notifications on this client
if (!this.systemNotificationService.isSupported()) {
return;
}
await this.systemNotificationService.createOSNotification({
title: "Pending Device Request",
body: "Please view pending auth request",
type: ButtonActions.AuthRequestNotification,
id: notificationId,
buttons: [{ title: "Approve Request" }, { title: "Deny Request" }],
});
}
}
/**
* This will be used probably in a guard to route the user
* This is a function because it could cache auth requests coming
* in the background, or it could query the server for any outstanding
* requests.
*/
async checkForPendingAuthRequestsToApprove(): Promise<void> {
// STUB
}
/**
* TODO: Requests are doubling, figure out if a subscription is duplicating or something.
* @param event
* @private
*/
private async handleAuthRequestNotificationClicked(
event: SystemNotificationEvent,
): Promise<void> {
// This is the approval event. WE WILL NOT BE USING 0 or 1!
if (event.buttonIdentifier === ButtonLocation.FirstOptionalButton) {
this.logService.info("Approve the request");
} else if (event.buttonIdentifier === ButtonLocation.SecondOptionalButton) {
// This is the deny event.
this.logService.info("Deny the request");
} else if (event.buttonIdentifier === ButtonLocation.NotificationButton) {
this.logService.info("Main button clicked, open popup");
// This is unstable, to be figured out later.
await this.platformUtilsService.openPopupToPath("/device-management");
this.systemNotificationService.clearOSNotification({ id: event.id });
}
}
}

View File

@@ -22,6 +22,10 @@ export abstract class PlatformUtilsService {
abstract isVivaldi(): boolean;
abstract isSafari(): boolean;
abstract isMacAppStore(): boolean;
/**
* Can only be called from the background service.
*/
abstract isViewOpen(): Promise<boolean>;
abstract launchUri(uri: string, options?: any): void;
abstract getApplicationVersion(): Promise<string>;
@@ -45,4 +49,5 @@ export abstract class PlatformUtilsService {
abstract readFromClipboard(): Promise<string>;
abstract supportsSecureStorage(): boolean;
abstract getAutofillKeyboardShortcut(): Promise<string>;
abstract openPopupToPath(url: string): Promise<void>;
}

View File

@@ -0,0 +1,51 @@
import { Observable } from "rxjs";
export const ButtonActions = {
AuthRequestNotification: "authRequestNotification",
};
export type ButtonActionsKeys = (typeof ButtonActions)[keyof typeof ButtonActions];
// This is currently tailored for chrome extension's api, if safari works
// differently where clicking a notification button produces a different
// identifier we need to reconcile that here.
export const ButtonLocation = {
FirstOptionalButton: 0, // this is the first optional button we can set
SecondOptionalButton: 1, // this is the second optional button we can set
NotificationButton: 2, // this is when you click the notification as a whole
};
export type ButtonLocationKeys = (typeof ButtonLocation)[keyof typeof ButtonLocation];
export type SystemNotificationsButton = {
title: string;
};
export type SystemNotificationCreateInfo = {
id: string;
type: ButtonActionsKeys;
title: string;
body: string;
buttons: SystemNotificationsButton[];
};
export type SystemNotificationClearInfo = {
id: string;
};
export type SystemNotificationEvent = {
id: string;
type: string;
buttonIdentifier: number;
};
export abstract class SystemNotificationServiceAbstraction {
abstract systemNotificationClicked$: Observable<SystemNotificationEvent>;
abstract createOSNotification(createInfo: SystemNotificationCreateInfo): Promise<undefined>;
abstract clearOSNotification(clearInfo: SystemNotificationClearInfo): undefined;
/**
* Used to know if a given platform supports notifications.
*/
abstract isSupported(): boolean;
}

View File

@@ -12,6 +12,7 @@ import {
} from "rxjs";
import { LogoutReason } from "@bitwarden/auth/common";
import { DevicesManagementApprovalAbstraction } from "@bitwarden/common/auth/services/devices/device-management-approval.service";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
@@ -41,6 +42,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
notifications$: Observable<readonly [NotificationResponse, UserId]>;
private activitySubject = new BehaviorSubject<"active" | "inactive">("active");
private destroy$ = new BehaviorSubject<void>(undefined);
constructor(
private readonly logService: LogService,
@@ -53,6 +55,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
private readonly signalRConnectionService: SignalRConnectionService,
private readonly authService: AuthService,
private readonly webPushConnectionService: WebPushConnectionService,
private deviceManagementApprovalService: DevicesManagementApprovalAbstraction,
) {
this.notifications$ = this.accountService.activeAccount$.pipe(
map((account) => account?.id),
@@ -211,6 +214,9 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
this.messagingService.send("openLoginApproval", {
notificationId: notification.payload.id,
});
await this.deviceManagementApprovalService.receivedPendingAuthRequest(
notification.payload.id,
);
}
break;
case NotificationType.SyncOrganizationStatusChanged:

View File

@@ -0,0 +1,67 @@
import { Observable, Subject } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
ButtonLocation,
SystemNotificationClearInfo,
SystemNotificationCreateInfo,
SystemNotificationEvent,
SystemNotificationServiceAbstraction as SystemNotificationServiceAbstraction,
} from "@bitwarden/common/platform/abstractions/system-notification-service.abstraction";
export class ChromeExtensionSystemNotificationService
implements SystemNotificationServiceAbstraction
{
private systemNotificationClickedSubject = new Subject<SystemNotificationEvent>();
systemNotificationClicked$: Observable<SystemNotificationEvent>;
constructor(
private logService: LogService,
private platformUtilsService: PlatformUtilsService,
) {
this.systemNotificationClicked$ = this.systemNotificationClickedSubject.asObservable();
}
async createOSNotification(createInfo: SystemNotificationCreateInfo): Promise<undefined> {
chrome.notifications.create(createInfo.id, {
iconUrl: "https://avatars.githubusercontent.com/u/15990069?s=200",
message: createInfo.title,
type: "basic",
title: createInfo.title,
buttons: createInfo.buttons.map((value) => {
return { title: value.title };
}),
});
// ESLint: Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi. addListener` instead (no-restricted-syntax)
// eslint-disable-next-line no-restricted-syntax
chrome.notifications.onButtonClicked.addListener(
(notificationId: string, buttonIndex: number) => {
this.systemNotificationClickedSubject.next({
id: notificationId,
type: createInfo.type,
buttonIdentifier: buttonIndex,
});
},
);
// eslint-disable-next-line no-restricted-syntax
chrome.notifications.onClicked.addListener((notificationId: string) => {
this.systemNotificationClickedSubject.next({
id: notificationId,
type: createInfo.type,
buttonIdentifier: ButtonLocation.NotificationButton,
});
});
return;
}
clearOSNotification(clearInfo: SystemNotificationClearInfo): undefined {
chrome.notifications.clear(clearInfo.id);
}
isSupported(): boolean {
return true;
}
}