mirror of
https://github.com/bitwarden/browser
synced 2026-02-05 19:23:19 +00:00
Merge branch 'auth/pm-19877/notification-processing' into auth/pm-23620/auth-request-answering-service
This commit is contained in:
5
.github/workflows/build-cli.yml
vendored
5
.github/workflows/build-cli.yml
vendored
@@ -71,6 +71,7 @@ jobs:
|
||||
|
||||
- name: Get Node Version
|
||||
id: retrieve-node-version
|
||||
working-directory: ./
|
||||
run: |
|
||||
NODE_NVMRC=$(cat .nvmrc)
|
||||
NODE_VERSION=${NODE_NVMRC/v/''}
|
||||
@@ -104,7 +105,7 @@ jobs:
|
||||
env:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
_WIN_PKG_FETCH_VERSION: 20.11.1
|
||||
_WIN_PKG_FETCH_VERSION: 22.15.1
|
||||
_WIN_PKG_VERSION: 3.5
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -283,7 +284,7 @@ jobs:
|
||||
env:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
_WIN_PKG_FETCH_VERSION: 20.11.1
|
||||
_WIN_PKG_FETCH_VERSION: 22.15.1
|
||||
_WIN_PKG_VERSION: 3.5
|
||||
steps:
|
||||
- name: Check out repo
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2025.7.0",
|
||||
"version": "2025.7.1",
|
||||
"scripts": {
|
||||
"build": "npm run build:chrome",
|
||||
"build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack",
|
||||
|
||||
@@ -5573,5 +5573,11 @@
|
||||
"wasmNotSupported": {
|
||||
"message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.",
|
||||
"description": "'WebAssembly' is a technical term and should not be translated."
|
||||
},
|
||||
"showMore": {
|
||||
"message": "Show more"
|
||||
},
|
||||
"showLess": {
|
||||
"message": "Show less"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { CollectionView } from "../../content/components/common-types";
|
||||
@@ -17,7 +18,7 @@ interface NotificationQueueMessage {
|
||||
|
||||
interface AddChangePasswordQueueMessage extends NotificationQueueMessage {
|
||||
type: "change";
|
||||
cipherId: string;
|
||||
cipherId: CipherView["id"];
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,19 +28,12 @@ export type ModifyLoginCipherFormData = {
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
export type ModifyLoginCipherFormDataForTab = Map<
|
||||
chrome.tabs.Tab["id"],
|
||||
{ uri: string; username: string; password: string; newPassword: string }
|
||||
>;
|
||||
export type ModifyLoginCipherFormDataForTab = Map<chrome.tabs.Tab["id"], ModifyLoginCipherFormData>;
|
||||
|
||||
export type OverlayNotificationsExtensionMessage = {
|
||||
command: string;
|
||||
uri?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
newPassword?: string;
|
||||
details?: AutofillPageDetails;
|
||||
};
|
||||
} & ModifyLoginCipherFormData;
|
||||
|
||||
type OverlayNotificationsMessageParams = { message: OverlayNotificationsExtensionMessage };
|
||||
type OverlayNotificationSenderParams = { sender: chrome.runtime.MessageSender };
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
|
||||
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { EnvironmentServerConfigData } from "@bitwarden/common/platform/models/data/server-config.data";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { TaskService } from "@bitwarden/common/vault/tasks";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import AutofillField from "../models/autofill-field";
|
||||
@@ -27,9 +24,6 @@ import { OverlayNotificationsBackground } from "./overlay-notifications.backgrou
|
||||
describe("OverlayNotificationsBackground", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let notificationBackground: NotificationBackground;
|
||||
let taskService: TaskService;
|
||||
let accountService: AccountService;
|
||||
let cipherService: CipherService;
|
||||
let getEnableChangedPasswordPromptSpy: jest.SpyInstance;
|
||||
let getEnableAddedLoginPromptSpy: jest.SpyInstance;
|
||||
let overlayNotificationsBackground: OverlayNotificationsBackground;
|
||||
@@ -38,9 +32,6 @@ describe("OverlayNotificationsBackground", () => {
|
||||
jest.useFakeTimers();
|
||||
logService = mock<LogService>();
|
||||
notificationBackground = mock<NotificationBackground>();
|
||||
taskService = mock<TaskService>();
|
||||
accountService = mock<AccountService>();
|
||||
cipherService = mock<CipherService>();
|
||||
getEnableChangedPasswordPromptSpy = jest
|
||||
.spyOn(notificationBackground, "getEnableChangedPasswordPrompt")
|
||||
.mockResolvedValue(true);
|
||||
@@ -50,9 +41,6 @@ describe("OverlayNotificationsBackground", () => {
|
||||
overlayNotificationsBackground = new OverlayNotificationsBackground(
|
||||
logService,
|
||||
notificationBackground,
|
||||
taskService,
|
||||
accountService,
|
||||
cipherService,
|
||||
);
|
||||
await overlayNotificationsBackground.init();
|
||||
});
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
// @ts-strict-ignore
|
||||
import { Subject, switchMap, timer } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { CLEAR_NOTIFICATION_LOGIN_DATA_DURATION } from "@bitwarden/common/autofill/constants";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { TaskService } from "@bitwarden/common/vault/tasks";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { NotificationType, NotificationTypes } from "../notification/abstractions/notification-bar";
|
||||
@@ -31,6 +28,8 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
private notificationFallbackTimeout: number | NodeJS.Timeout | null;
|
||||
private readonly formSubmissionRequestMethods: Set<string> = new Set(["POST", "PUT", "PATCH"]);
|
||||
private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = {
|
||||
generatedPasswordFilled: ({ message, sender }) =>
|
||||
this.storeModifiedLoginFormData(message, sender),
|
||||
formFieldSubmitted: ({ message, sender }) => this.storeModifiedLoginFormData(message, sender),
|
||||
collectPageDetailsResponse: ({ message, sender }) =>
|
||||
this.handleCollectPageDetailsResponse(message, sender),
|
||||
@@ -39,9 +38,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private notificationBackground: NotificationBackground,
|
||||
private taskService: TaskService,
|
||||
private accountService: AccountService,
|
||||
private cipherService: CipherService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -442,7 +438,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
}
|
||||
}
|
||||
|
||||
this.clearCompletedWebRequest(requestId, tab);
|
||||
this.clearCompletedWebRequest(requestId, tab.id);
|
||||
return results.join(" ");
|
||||
};
|
||||
|
||||
@@ -482,11 +478,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
|
||||
*/
|
||||
private clearCompletedWebRequest = (
|
||||
requestId: chrome.webRequest.ResourceRequest["requestId"],
|
||||
tab: chrome.tabs.Tab,
|
||||
tabId: chrome.tabs.Tab["id"],
|
||||
) => {
|
||||
this.activeFormSubmissionRequests.delete(requestId);
|
||||
this.modifyLoginCipherFormData.delete(tab.id);
|
||||
this.websiteOriginsWithFields.delete(tab.id);
|
||||
this.modifyLoginCipherFormData.delete(tabId);
|
||||
this.websiteOriginsWithFields.delete(tabId);
|
||||
this.setupWebRequestsListeners();
|
||||
};
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
MAX_SUB_FRAME_DEPTH,
|
||||
RedirectFocusDirection,
|
||||
} from "../enums/autofill-overlay.enum";
|
||||
import { InlineMenuFormFieldData } from "../services/abstractions/autofill-overlay-content.service";
|
||||
import { AutofillService } from "../services/abstractions/autofill.service";
|
||||
import { InlineMenuFieldQualificationService } from "../services/inline-menu-field-qualification.service";
|
||||
import {
|
||||
@@ -71,6 +70,7 @@ import {
|
||||
triggerWebRequestOnCompletedEvent,
|
||||
} from "../spec/testing-utils";
|
||||
|
||||
import { ModifyLoginCipherFormData } from "./abstractions/overlay-notifications.background";
|
||||
import {
|
||||
FocusedFieldData,
|
||||
InlineMenuPosition,
|
||||
@@ -2076,7 +2076,7 @@ describe("OverlayBackground", () => {
|
||||
const tab = createChromeTabMock({ id: 2 });
|
||||
const sender = mock<chrome.runtime.MessageSender>({ tab, frameId: 100 });
|
||||
let focusedFieldData: FocusedFieldData;
|
||||
let formData: InlineMenuFormFieldData;
|
||||
let formData: ModifyLoginCipherFormData;
|
||||
|
||||
beforeEach(async () => {
|
||||
await initOverlayElementPorts();
|
||||
@@ -3651,6 +3651,18 @@ describe("OverlayBackground", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a message to the tab to store modify login change when a password is generated", async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
sendPortMessage(listMessageConnectorSpy, { command: "fillGeneratedPassword", portKey });
|
||||
|
||||
await flushPromises();
|
||||
jest.advanceTimersByTime(400);
|
||||
await flushPromises();
|
||||
|
||||
expect(tabsSendMessageSpy.mock.lastCall[1].command).toBe("generatedPasswordModifyLogin");
|
||||
});
|
||||
|
||||
it("filters the page details to only include the new password fields before filling", async () => {
|
||||
sendPortMessage(listMessageConnectorSpy, { command: "fillGeneratedPassword", portKey });
|
||||
await flushPromises();
|
||||
@@ -3663,31 +3675,6 @@ describe("OverlayBackground", () => {
|
||||
allowTotpAutofill: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the inline menu for fields that fill a generated password", async () => {
|
||||
jest.useFakeTimers();
|
||||
const formData = {
|
||||
uri: "https://example.com",
|
||||
username: "username",
|
||||
password: "password",
|
||||
newPassword: "newPassword",
|
||||
};
|
||||
tabsSendMessageSpy.mockImplementation((_tab, message) => {
|
||||
if (message.command === "getInlineMenuFormFieldData") {
|
||||
return Promise.resolve(formData);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
const openInlineMenuSpy = jest.spyOn(overlayBackground as any, "openInlineMenu");
|
||||
|
||||
sendPortMessage(listMessageConnectorSpy, { command: "fillGeneratedPassword", portKey });
|
||||
await flushPromises();
|
||||
jest.advanceTimersByTime(400);
|
||||
await flushPromises();
|
||||
|
||||
expect(openInlineMenuSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ import {
|
||||
MAX_SUB_FRAME_DEPTH,
|
||||
} from "../enums/autofill-overlay.enum";
|
||||
import AutofillField from "../models/autofill-field";
|
||||
import { InlineMenuFormFieldData } from "../services/abstractions/autofill-overlay-content.service";
|
||||
import { AutofillService, PageDetail } from "../services/abstractions/autofill.service";
|
||||
import { InlineMenuFieldQualificationService } from "../services/abstractions/inline-menu-field-qualifications.service";
|
||||
import {
|
||||
@@ -82,6 +81,7 @@ import {
|
||||
} from "../utils";
|
||||
|
||||
import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background";
|
||||
import { ModifyLoginCipherFormData } from "./abstractions/overlay-notifications.background";
|
||||
import {
|
||||
BuildCipherDataParams,
|
||||
CloseInlineMenuMessage,
|
||||
@@ -1813,7 +1813,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
|
||||
/**
|
||||
* Triggers a fill of the generated password into the current tab. Will trigger
|
||||
* a focus of the last focused field after filling the password.
|
||||
* a focus of the last focused field after filling the password.
|
||||
*
|
||||
* @param port - The port of the sender
|
||||
*/
|
||||
@@ -1857,10 +1857,16 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
});
|
||||
|
||||
globalThis.setTimeout(async () => {
|
||||
if (await this.shouldShowSaveLoginInlineMenuList(port.sender.tab)) {
|
||||
await this.openInlineMenu(port.sender, true);
|
||||
}
|
||||
}, 300);
|
||||
await BrowserApi.tabSendMessage(
|
||||
port.sender.tab,
|
||||
{
|
||||
command: "generatedPasswordModifyLogin",
|
||||
},
|
||||
{
|
||||
frameId: this.focusedFieldData.frameId || 0,
|
||||
},
|
||||
);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1891,7 +1897,9 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
*
|
||||
* @param tab - The tab to get the form field data from
|
||||
*/
|
||||
private async getInlineMenuFormFieldData(tab: chrome.tabs.Tab): Promise<InlineMenuFormFieldData> {
|
||||
private async getInlineMenuFormFieldData(
|
||||
tab: chrome.tabs.Tab,
|
||||
): Promise<ModifyLoginCipherFormData> {
|
||||
return await BrowserApi.tabSendMessage(
|
||||
tab,
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@ class AutofillInit implements AutofillInitInterface {
|
||||
* @param domElementVisibilityService - Used to check if an element is viewable.
|
||||
* @param autofillOverlayContentService - The autofill overlay content service, potentially undefined.
|
||||
* @param autofillInlineMenuContentService - The inline menu content service, potentially undefined.
|
||||
* @param overlayNotificationsContentService - The overlay notifications content service, potentially undefined.
|
||||
* @param overlayNotificationsContentService - The overlay server notifications content service, potentially undefined.
|
||||
*/
|
||||
constructor(
|
||||
domQueryService: DomQueryService,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ModifyLoginCipherFormData } from "../../background/abstractions/overlay-notifications.background";
|
||||
import { SubFrameOffsetData } from "../../background/abstractions/overlay.background";
|
||||
import { AutofillExtensionMessageParam } from "../../content/abstractions/autofill-init";
|
||||
import AutofillField from "../../models/autofill-field";
|
||||
@@ -8,13 +9,6 @@ export type SubFrameDataFromWindowMessage = SubFrameOffsetData & {
|
||||
subFrameDepth: number;
|
||||
};
|
||||
|
||||
export type InlineMenuFormFieldData = {
|
||||
uri: string;
|
||||
username: string;
|
||||
password: string;
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
export type AutofillOverlayContentExtensionMessageHandlers = {
|
||||
[key: string]: CallableFunction;
|
||||
addNewVaultItemFromOverlay: ({ message }: AutofillExtensionMessageParam) => void;
|
||||
@@ -32,7 +26,7 @@ export type AutofillOverlayContentExtensionMessageHandlers = {
|
||||
destroyAutofillInlineMenuListeners: () => void;
|
||||
getInlineMenuFormFieldData: ({
|
||||
message,
|
||||
}: AutofillExtensionMessageParam) => Promise<InlineMenuFormFieldData>;
|
||||
}: AutofillExtensionMessageParam) => Promise<ModifyLoginCipherFormData>;
|
||||
};
|
||||
|
||||
export interface AutofillOverlayContentService {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
|
||||
import { ModifyLoginCipherFormData } from "../background/abstractions/overlay-notifications.background";
|
||||
import AutofillInit from "../content/autofill-init";
|
||||
import {
|
||||
AutofillOverlayElement,
|
||||
@@ -1750,6 +1751,29 @@ describe("AutofillOverlayContentService", () => {
|
||||
});
|
||||
|
||||
describe("extension onMessage handlers", () => {
|
||||
describe("generatedPasswordModifyLogin", () => {
|
||||
it("relays a message regarding password generation to store modified login data", async () => {
|
||||
const formFieldData: ModifyLoginCipherFormData = {
|
||||
newPassword: "newPassword",
|
||||
password: "password",
|
||||
uri: "http://localhost/",
|
||||
username: "username",
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(autofillOverlayContentService as any, "getFormFieldData")
|
||||
.mockResolvedValue(formFieldData);
|
||||
|
||||
sendMockExtensionMessage({
|
||||
command: "generatedPasswordModifyLogin",
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const resolvedValue = await sendExtensionMessageSpy.mock.calls[0][1];
|
||||
expect(resolvedValue).toEqual(formFieldData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addNewVaultItemFromOverlay message handler", () => {
|
||||
it("skips sending the message if the overlay list is not visible", async () => {
|
||||
jest
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@bitwarden/common/autofill/constants";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
|
||||
import { ModifyLoginCipherFormData } from "../background/abstractions/overlay-notifications.background";
|
||||
import {
|
||||
FocusedFieldData,
|
||||
NewCardCipherData,
|
||||
@@ -48,7 +49,6 @@ import {
|
||||
import {
|
||||
AutofillOverlayContentExtensionMessageHandlers,
|
||||
AutofillOverlayContentService as AutofillOverlayContentServiceInterface,
|
||||
InlineMenuFormFieldData,
|
||||
SubFrameDataFromWindowMessage,
|
||||
} from "./abstractions/autofill-overlay-content.service";
|
||||
import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service";
|
||||
@@ -95,6 +95,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
destroyAutofillInlineMenuListeners: () => this.destroy(),
|
||||
getInlineMenuFormFieldData: ({ message }) =>
|
||||
this.handleGetInlineMenuFormFieldDataMessage(message),
|
||||
generatedPasswordModifyLogin: () => this.sendGeneratedPasswordModifyLogin(),
|
||||
};
|
||||
private readonly loginFieldQualifiers: Record<string, CallableFunction> = {
|
||||
[AutofillFieldQualifier.username]: this.inlineMenuFieldQualificationService.isUsernameField,
|
||||
@@ -235,6 +236,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* On password generation, send form field data i.e. modified login data
|
||||
*/
|
||||
sendGeneratedPasswordModifyLogin = async () => {
|
||||
await this.sendExtensionMessage("generatedPasswordFilled", this.getFormFieldData());
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats any found user filled fields for a login cipher and sends a message
|
||||
* to the background script to add a new cipher.
|
||||
@@ -637,7 +645,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
||||
/**
|
||||
* Returns the form field data used for add login and change password notifications.
|
||||
*/
|
||||
private getFormFieldData = (): InlineMenuFormFieldData => {
|
||||
private getFormFieldData = (): ModifyLoginCipherFormData => {
|
||||
return {
|
||||
uri: globalThis.document.URL,
|
||||
username: this.userFilledFields["username"]?.value || "",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
VaultTimeoutSettingsService,
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
|
||||
const IdleInterval = 60 * 5; // 5 minutes
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ import { Lazy } from "@bitwarden/common/platform/misc/lazy";
|
||||
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
// eslint-disable-next-line no-restricted-imports -- Needed for service creation
|
||||
import {
|
||||
DefaultServerNotificationsService,
|
||||
@@ -129,9 +129,7 @@ import {
|
||||
UnsupportedWebPushConnectionService,
|
||||
WebPushNotificationsApiService,
|
||||
WorkerWebPushConnectionService,
|
||||
} from "@bitwarden/common/platform/notifications/internal";
|
||||
import { SystemNotificationsService } from "@bitwarden/common/platform/notifications/system-notifications.service";
|
||||
import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/notifications/unsupported-system-notifications.service";
|
||||
} from "@bitwarden/common/platform/server-notifications/internal";
|
||||
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
||||
@@ -171,6 +169,8 @@ import { WindowStorageService } from "@bitwarden/common/platform/storage/window-
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
// eslint-disable-next-line no-restricted-imports -- Needed for service creation
|
||||
import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal";
|
||||
import { SystemNotificationsService } from "@bitwarden/common/platform/system-notifications/";
|
||||
import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/system-notifications/unsupported-system-notifications.service";
|
||||
import { DefaultThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
import { ApiService } from "@bitwarden/common/services/api.service";
|
||||
import { AuditService } from "@bitwarden/common/services/audit.service";
|
||||
@@ -1294,9 +1294,6 @@ export default class MainBackground {
|
||||
this.overlayNotificationsBackground = new OverlayNotificationsBackground(
|
||||
this.logService,
|
||||
this.notificationBackground,
|
||||
this.taskService,
|
||||
this.accountService,
|
||||
this.cipherService,
|
||||
);
|
||||
|
||||
this.autoSubmitLoginBackground = new AutoSubmitLoginBackground(
|
||||
@@ -1411,6 +1408,7 @@ export default class MainBackground {
|
||||
this.badgeService = new BadgeService(
|
||||
this.stateProvider,
|
||||
new DefaultBadgeBrowserApi(this.platformUtilsService),
|
||||
this.logService,
|
||||
);
|
||||
this.authStatusBadgeUpdaterService = new AuthStatusBadgeUpdaterService(
|
||||
this.badgeService,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "Bitwarden",
|
||||
"version": "2025.7.0",
|
||||
"version": "2025.7.1",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"minimum_chrome_version": "102.0",
|
||||
"name": "__MSG_extName__",
|
||||
"short_name": "Bitwarden",
|
||||
"version": "2025.7.0",
|
||||
"version": "2025.7.1",
|
||||
"description": "__MSG_extDesc__",
|
||||
"default_locale": "en",
|
||||
"author": "Bitwarden Inc.",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { BrowserApi } from "../browser/browser-api";
|
||||
import { fromChromeEvent } from "../browser/from-chrome-event";
|
||||
|
||||
import { BadgeIcon, IconPaths } from "./icon";
|
||||
|
||||
@@ -13,6 +16,8 @@ export interface RawBadgeState {
|
||||
}
|
||||
|
||||
export interface BadgeBrowserApi {
|
||||
activeTab$: Observable<chrome.tabs.TabActiveInfo | undefined>;
|
||||
|
||||
setState(state: RawBadgeState, tabId?: number): Promise<void>;
|
||||
getTabs(): Promise<number[]>;
|
||||
}
|
||||
@@ -21,6 +26,10 @@ export class DefaultBadgeBrowserApi implements BadgeBrowserApi {
|
||||
private badgeAction = BrowserApi.getBrowserAction();
|
||||
private sidebarAction = BrowserApi.getSidebarAction(self);
|
||||
|
||||
activeTab$ = fromChromeEvent(chrome.tabs.onActivated).pipe(
|
||||
map(([tabActiveInfo]) => tabActiveInfo),
|
||||
);
|
||||
|
||||
constructor(private platformUtilsService: PlatformUtilsService) {}
|
||||
|
||||
async setState(state: RawBadgeState, tabId?: number): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { Subscription } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { FakeAccountService, FakeStateProvider } from "@bitwarden/common/spec";
|
||||
|
||||
import { RawBadgeState } from "./badge-browser-api";
|
||||
@@ -13,6 +15,7 @@ import { MockBadgeBrowserApi } from "./test/mock-badge-browser-api";
|
||||
describe("BadgeService", () => {
|
||||
let badgeApi: MockBadgeBrowserApi;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let logService!: MockProxy<LogService>;
|
||||
let badgeService!: BadgeService;
|
||||
|
||||
let badgeServiceSubscription: Subscription;
|
||||
@@ -20,8 +23,9 @@ describe("BadgeService", () => {
|
||||
beforeEach(() => {
|
||||
badgeApi = new MockBadgeBrowserApi();
|
||||
stateProvider = new FakeStateProvider(new FakeAccountService({}));
|
||||
logService = mock<LogService>();
|
||||
|
||||
badgeService = new BadgeService(stateProvider, badgeApi);
|
||||
badgeService = new BadgeService(stateProvider, badgeApi, logService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -34,14 +38,10 @@ describe("BadgeService", () => {
|
||||
describe("given a single tab is open", () => {
|
||||
beforeEach(() => {
|
||||
badgeApi.tabs = [1];
|
||||
badgeApi.setActiveTab(tabId);
|
||||
badgeServiceSubscription = badgeService.startListening();
|
||||
});
|
||||
|
||||
// This relies on the state provider to auto-emit
|
||||
it("sets default values on startup", async () => {
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
});
|
||||
|
||||
it("sets provided state when no other state has been set", async () => {
|
||||
const state: BadgeState = {
|
||||
text: "text",
|
||||
@@ -52,7 +52,6 @@ describe("BadgeService", () => {
|
||||
await badgeService.setState("state-name", BadgeStatePriority.Default, state);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual(state);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(state);
|
||||
});
|
||||
|
||||
@@ -63,7 +62,6 @@ describe("BadgeService", () => {
|
||||
await badgeService.setState("state-name", BadgeStatePriority.Default, state);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState);
|
||||
});
|
||||
|
||||
@@ -82,7 +80,6 @@ describe("BadgeService", () => {
|
||||
backgroundColor: "#fff",
|
||||
icon: BadgeIcon.Locked,
|
||||
};
|
||||
expect(badgeApi.generalState).toEqual(expectedState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(expectedState);
|
||||
});
|
||||
|
||||
@@ -105,7 +102,6 @@ describe("BadgeService", () => {
|
||||
backgroundColor: "#aaa",
|
||||
icon: BadgeIcon.Locked,
|
||||
};
|
||||
expect(badgeApi.generalState).toEqual(expectedState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(expectedState);
|
||||
});
|
||||
|
||||
@@ -126,7 +122,6 @@ describe("BadgeService", () => {
|
||||
backgroundColor: "#fff",
|
||||
icon: BadgeIcon.Locked,
|
||||
};
|
||||
expect(badgeApi.generalState).toEqual(expectedState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(expectedState);
|
||||
});
|
||||
|
||||
@@ -147,7 +142,6 @@ describe("BadgeService", () => {
|
||||
await badgeService.clearState("state-3");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState);
|
||||
});
|
||||
|
||||
@@ -167,7 +161,6 @@ describe("BadgeService", () => {
|
||||
backgroundColor: "#fff",
|
||||
icon: DefaultBadgeState.icon,
|
||||
};
|
||||
expect(badgeApi.generalState).toEqual(expectedState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(expectedState);
|
||||
});
|
||||
|
||||
@@ -190,26 +183,20 @@ describe("BadgeService", () => {
|
||||
backgroundColor: "#fff",
|
||||
icon: BadgeIcon.Unlocked,
|
||||
};
|
||||
expect(badgeApi.generalState).toEqual(expectedState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given multiple tabs are open", () => {
|
||||
const tabId = 1;
|
||||
const tabIds = [1, 2, 3];
|
||||
|
||||
beforeEach(() => {
|
||||
badgeApi.tabs = tabIds;
|
||||
badgeApi.setActiveTab(tabId);
|
||||
badgeServiceSubscription = badgeService.startListening();
|
||||
});
|
||||
|
||||
it("sets default values for each tab on startup", async () => {
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
for (const tabId of tabIds) {
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState);
|
||||
}
|
||||
});
|
||||
|
||||
it("sets state for each tab when no other state has been set", async () => {
|
||||
const state: BadgeState = {
|
||||
text: "text",
|
||||
@@ -220,11 +207,10 @@ describe("BadgeService", () => {
|
||||
await badgeService.setState("state-name", BadgeStatePriority.Default, state);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual(state);
|
||||
expect(badgeApi.specificStates).toEqual({
|
||||
1: state,
|
||||
2: state,
|
||||
3: state,
|
||||
2: undefined,
|
||||
3: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -236,6 +222,7 @@ describe("BadgeService", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
badgeApi.tabs = [tabId];
|
||||
badgeApi.setActiveTab(tabId);
|
||||
badgeServiceSubscription = badgeService.startListening();
|
||||
});
|
||||
|
||||
@@ -249,7 +236,6 @@ describe("BadgeService", () => {
|
||||
await badgeService.setState("state-name", BadgeStatePriority.Default, state, tabId);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(state);
|
||||
});
|
||||
|
||||
@@ -260,7 +246,6 @@ describe("BadgeService", () => {
|
||||
await badgeService.setState("state-name", BadgeStatePriority.Default, state, tabId);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState);
|
||||
});
|
||||
|
||||
@@ -279,11 +264,6 @@ describe("BadgeService", () => {
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual({
|
||||
...DefaultBadgeState,
|
||||
text: "text",
|
||||
icon: BadgeIcon.Locked,
|
||||
});
|
||||
expect(badgeApi.specificStates[tabId]).toEqual({
|
||||
text: "text",
|
||||
backgroundColor: "#fff",
|
||||
@@ -316,7 +296,6 @@ describe("BadgeService", () => {
|
||||
backgroundColor: "#fff",
|
||||
icon: BadgeIcon.Locked,
|
||||
};
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(expectedState);
|
||||
});
|
||||
|
||||
@@ -354,7 +333,6 @@ describe("BadgeService", () => {
|
||||
backgroundColor: "#aaa",
|
||||
icon: BadgeIcon.Locked,
|
||||
};
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(expectedState);
|
||||
});
|
||||
|
||||
@@ -377,11 +355,6 @@ describe("BadgeService", () => {
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual({
|
||||
text: "override",
|
||||
backgroundColor: "#aaa",
|
||||
icon: DefaultBadgeState.icon,
|
||||
});
|
||||
expect(badgeApi.specificStates[tabId]).toEqual({
|
||||
text: "override",
|
||||
backgroundColor: "#aaa",
|
||||
@@ -411,7 +384,6 @@ describe("BadgeService", () => {
|
||||
await badgeService.clearState("state-2");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual({
|
||||
text: "text",
|
||||
backgroundColor: "#fff",
|
||||
@@ -451,7 +423,6 @@ describe("BadgeService", () => {
|
||||
await badgeService.clearState("state-3");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual(DefaultBadgeState);
|
||||
});
|
||||
|
||||
@@ -476,7 +447,6 @@ describe("BadgeService", () => {
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual({
|
||||
text: "text",
|
||||
backgroundColor: "#fff",
|
||||
@@ -513,7 +483,6 @@ describe("BadgeService", () => {
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual(DefaultBadgeState);
|
||||
expect(badgeApi.specificStates[tabId]).toEqual({
|
||||
text: "text",
|
||||
backgroundColor: "#fff",
|
||||
@@ -523,14 +492,16 @@ describe("BadgeService", () => {
|
||||
});
|
||||
|
||||
describe("given multiple tabs are open", () => {
|
||||
const tabId = 1;
|
||||
const tabIds = [1, 2, 3];
|
||||
|
||||
beforeEach(() => {
|
||||
badgeApi.tabs = tabIds;
|
||||
badgeApi.setActiveTab(tabId);
|
||||
badgeServiceSubscription = badgeService.startListening();
|
||||
});
|
||||
|
||||
it("sets tab-specific state for provided tab and general state for the others", async () => {
|
||||
it("sets tab-specific state for provided tab", async () => {
|
||||
const generalState: BadgeState = {
|
||||
text: "general-text",
|
||||
backgroundColor: "general-color",
|
||||
@@ -550,11 +521,10 @@ describe("BadgeService", () => {
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(badgeApi.generalState).toEqual(generalState);
|
||||
expect(badgeApi.specificStates).toEqual({
|
||||
[tabIds[0]]: { ...specificState, backgroundColor: "general-color" },
|
||||
[tabIds[1]]: generalState,
|
||||
[tabIds[2]]: generalState,
|
||||
[tabIds[1]]: undefined,
|
||||
[tabIds[2]]: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
defer,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
mergeMap,
|
||||
pairwise,
|
||||
startWith,
|
||||
Subscription,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
BADGE_MEMORY,
|
||||
GlobalState,
|
||||
@@ -39,6 +39,7 @@ export class BadgeService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private badgeApi: BadgeBrowserApi,
|
||||
private logService: LogService,
|
||||
) {
|
||||
this.states = this.stateProvider.getGlobal(BADGE_STATES);
|
||||
}
|
||||
@@ -48,52 +49,47 @@ export class BadgeService {
|
||||
* Without this the service will not be able to update the badge state.
|
||||
*/
|
||||
startListening(): Subscription {
|
||||
const initialSetup$ = defer(async () => {
|
||||
const openTabs = await this.badgeApi.getTabs();
|
||||
await this.badgeApi.setState(DefaultBadgeState);
|
||||
for (const tabId of openTabs) {
|
||||
await this.badgeApi.setState(DefaultBadgeState, tabId);
|
||||
}
|
||||
});
|
||||
|
||||
return initialSetup$
|
||||
.pipe(
|
||||
switchMap(() => this.states.state$),
|
||||
return combineLatest({
|
||||
states: this.states.state$.pipe(
|
||||
startWith({}),
|
||||
distinctUntilChanged(),
|
||||
map((states) => new Set(states ? Object.values(states) : [])),
|
||||
pairwise(),
|
||||
map(([previous, current]) => {
|
||||
const [removed, added] = difference(previous, current);
|
||||
return { states: current, removed, added };
|
||||
return { all: current, removed, added };
|
||||
}),
|
||||
filter(({ removed, added }) => removed.size > 0 || added.size > 0),
|
||||
mergeMap(async ({ states, removed, added }) => {
|
||||
const changed = [...removed, ...added];
|
||||
const changedTabIds = new Set(
|
||||
changed.map((s) => s.tabId).filter((tabId) => tabId !== undefined),
|
||||
);
|
||||
const onlyTabSpecificStatesChanged = changed.every((s) => s.tabId != undefined);
|
||||
if (onlyTabSpecificStatesChanged) {
|
||||
// If only tab-specific states changed then we only need to update those specific tabs.
|
||||
for (const tabId of changedTabIds) {
|
||||
const newState = this.calculateState(states, tabId);
|
||||
await this.badgeApi.setState(newState, tabId);
|
||||
}
|
||||
),
|
||||
activeTab: this.badgeApi.activeTab$.pipe(startWith(undefined)),
|
||||
})
|
||||
.pipe(
|
||||
concatMap(async ({ states, activeTab }) => {
|
||||
const changed = [...states.removed, ...states.added];
|
||||
|
||||
// If the active tab wasn't changed, we don't need to update the badge.
|
||||
if (!changed.some((s) => s.tabId === activeTab?.tabId || s.tabId === undefined)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are any general states that changed then we need to update all tabs.
|
||||
const openTabs = await this.badgeApi.getTabs();
|
||||
const generalState = this.calculateState(states);
|
||||
await this.badgeApi.setState(generalState);
|
||||
for (const tabId of openTabs) {
|
||||
const newState = this.calculateState(states, tabId);
|
||||
await this.badgeApi.setState(newState, tabId);
|
||||
try {
|
||||
const state = this.calculateState(states.all, activeTab?.tabId);
|
||||
await this.badgeApi.setState(state, activeTab?.tabId);
|
||||
} catch (error) {
|
||||
// This usually happens when the user opens a popout because of how the browser treats it
|
||||
// as a tab in the same window but then won't let you set the badge state for it.
|
||||
this.logService.warning("Failed to set badge state", error);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
.subscribe({
|
||||
error: (err: unknown) => {
|
||||
this.logService.error(
|
||||
"Fatal error in badge service observable, badge will fail to update",
|
||||
err,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { BadgeBrowserApi, RawBadgeState } from "../badge-browser-api";
|
||||
|
||||
export class MockBadgeBrowserApi implements BadgeBrowserApi {
|
||||
private _activeTab$ = new BehaviorSubject<chrome.tabs.TabActiveInfo | undefined>(undefined);
|
||||
activeTab$ = this._activeTab$.asObservable();
|
||||
|
||||
specificStates: Record<number, RawBadgeState> = {};
|
||||
generalState?: RawBadgeState;
|
||||
tabs: number[] = [];
|
||||
|
||||
setActiveTab(tabId: number) {
|
||||
this._activeTab$.next({
|
||||
tabId,
|
||||
windowId: 1,
|
||||
});
|
||||
}
|
||||
|
||||
setState(state: RawBadgeState, tabId?: number): Promise<void> {
|
||||
if (tabId !== undefined) {
|
||||
this.specificStates[tabId] = state;
|
||||
|
||||
@@ -2,10 +2,10 @@ import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
// Eventually if we want to support listening to notifications from browser foreground we
|
||||
// Eventually if we want to support listening to server notifications from browser foreground we
|
||||
// will only ever create a single SignalR connection, likely messaging to the background to reuse its connection.
|
||||
export class ForegroundServerNotificationsService implements ServerNotificationsService {
|
||||
notifications$: Observable<readonly [NotificationResponse, UserId]>;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { map, merge, Observable } from "rxjs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
@@ -10,7 +8,7 @@ import {
|
||||
SystemNotificationCreateInfo,
|
||||
SystemNotificationEvent,
|
||||
SystemNotificationsService,
|
||||
} from "@bitwarden/common/platform/notifications/system-notifications.service";
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
|
||||
import { fromChromeEvent } from "../browser/from-chrome-event";
|
||||
|
||||
@@ -37,60 +35,24 @@ export class BrowserSystemNotificationService implements SystemNotificationsServ
|
||||
);
|
||||
}
|
||||
|
||||
async create(createInfo: SystemNotificationCreateInfo): Promise<string | undefined> {
|
||||
try {
|
||||
const notificationId = createInfo.id || uuidv4();
|
||||
const deviceType = this.platformUtilsService.getDevice();
|
||||
|
||||
const notificationOptions: chrome.notifications.NotificationOptions<true> = {
|
||||
iconUrl: "https://avatars.githubusercontent.com/u/15990069?s=200",
|
||||
async create(createInfo: SystemNotificationCreateInfo): Promise<string> {
|
||||
return new Promise<string>((resolve) => {
|
||||
const options: chrome.notifications.NotificationOptions<true> = {
|
||||
iconUrl: chrome.runtime.getURL("images/icon128.png"),
|
||||
message: createInfo.body,
|
||||
type: "basic",
|
||||
title: createInfo.title,
|
||||
buttons: createInfo.buttons.map((value) => {
|
||||
return { title: value.title };
|
||||
}),
|
||||
buttons: createInfo.buttons.map((value) => ({ title: value.title })),
|
||||
};
|
||||
|
||||
switch (deviceType) {
|
||||
case DeviceType.FirefoxExtension:
|
||||
// Firefox does not support buttons in notifications
|
||||
delete notificationOptions.buttons;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
if (createInfo.id != null) {
|
||||
chrome.notifications.create(createInfo.id, options, (notificationId) =>
|
||||
resolve(notificationId),
|
||||
);
|
||||
} else {
|
||||
chrome.notifications.create(options, (notificationId) => resolve(notificationId));
|
||||
}
|
||||
|
||||
chrome.notifications.create(notificationId, notificationOptions);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chrome.notifications.onButtonClicked.addListener(
|
||||
(notificationId: string, buttonIndex: number) => {
|
||||
this.notificationClicked$.subscribe({
|
||||
next: () => ({
|
||||
id: notificationId,
|
||||
buttonIdentifier: buttonIndex,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chrome.notifications.onClicked.addListener((notificationId: string) => {
|
||||
this.notificationClicked$.subscribe({
|
||||
next: () => ({
|
||||
id: notificationId,
|
||||
buttonIdentifier: ButtonLocation.NotificationButton,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return notificationId;
|
||||
} catch (e) {
|
||||
this.logService.error(
|
||||
`Failed to create notification on ${this.platformUtilsService.getDevice()} with error: ${e}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async clear(clearInfo: SystemNotificationClearInfo): Promise<undefined> {
|
||||
|
||||
@@ -96,9 +96,8 @@ import { Message, MessageListener, MessageSender } from "@bitwarden/common/platf
|
||||
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
|
||||
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
|
||||
import { flagEnabled } from "@bitwarden/common/platform/misc/flags";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { SystemNotificationsService } from "@bitwarden/common/platform/notifications/system-notifications.service";
|
||||
import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory";
|
||||
@@ -114,6 +113,7 @@ import { InlineDerivedStateProvider } from "@bitwarden/common/platform/state/imp
|
||||
import { PrimarySecondaryStorageService } from "@bitwarden/common/platform/storage/primary-secondary-storage.service";
|
||||
import { WindowStorageService } from "@bitwarden/common/platform/storage/window-storage.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { SystemNotificationsService } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
03100CAF291891F4008E14EF /* encrypt-worker.js in Resources */ = {isa = PBXBuildFile; fileRef = 03100CAE291891F4008E14EF /* encrypt-worker.js */; };
|
||||
55BC93932CB4268A008CA4C6 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = 55BC93922CB4268A008CA4C6 /* assets */; };
|
||||
55E0374D2577FA6B00979016 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E0374C2577FA6B00979016 /* AppDelegate.swift */; };
|
||||
55E037502577FA6B00979016 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 55E0374E2577FA6B00979016 /* Main.storyboard */; };
|
||||
@@ -53,7 +52,6 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
03100CAE291891F4008E14EF /* encrypt-worker.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; name = "encrypt-worker.js"; path = "../../../build/encrypt-worker.js"; sourceTree = "<group>"; };
|
||||
5508DD7926051B5900A85C58 /* libswiftAppKit.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libswiftAppKit.tbd; path = usr/lib/swift/libswiftAppKit.tbd; sourceTree = SDKROOT; };
|
||||
55BC93922CB4268A008CA4C6 /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = assets; path = ../../../build/assets; sourceTree = "<group>"; };
|
||||
55E037482577FA6B00979016 /* desktop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = desktop.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -155,7 +153,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
55BC93922CB4268A008CA4C6 /* assets */,
|
||||
03100CAE291891F4008E14EF /* encrypt-worker.js */,
|
||||
55E037702577FA6F00979016 /* popup */,
|
||||
55E037712577FA6F00979016 /* background.js */,
|
||||
55E037722577FA6F00979016 /* images */,
|
||||
@@ -272,7 +269,6 @@
|
||||
55E037802577FA6F00979016 /* background.html in Resources */,
|
||||
55E0377A2577FA6F00979016 /* background.js in Resources */,
|
||||
55E037792577FA6F00979016 /* popup in Resources */,
|
||||
03100CAF291891F4008E14EF /* encrypt-worker.js in Resources */,
|
||||
55BC93932CB4268A008CA4C6 /* assets in Resources */,
|
||||
55E0377C2577FA6F00979016 /* notification in Resources */,
|
||||
55E0377E2577FA6F00979016 /* vendor.js in Resources */,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
v20
|
||||
@@ -90,9 +90,5 @@
|
||||
"semver": "7.7.2",
|
||||
"tldts": "7.0.1",
|
||||
"zxcvbn": "4.4.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "~20",
|
||||
"npm": "~10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
import { StateEventRunnerService } from "@bitwarden/common/platform/state";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
@@ -13,7 +13,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
|
||||
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||
import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/platform/sync";
|
||||
|
||||
@@ -3620,7 +3620,7 @@
|
||||
},
|
||||
"uriMatchDefaultStrategyHint": {
|
||||
"message": "URI match detection is how Bitwarden identifies autofill suggestions.",
|
||||
"description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item."
|
||||
"description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item."
|
||||
},
|
||||
"regExAdvancedOptionWarning": {
|
||||
"message": "\"Regular expression\" is an advanced option with increased risk of exposing credentials.",
|
||||
@@ -4066,6 +4066,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"showMore": {
|
||||
"message": "Show more"
|
||||
},
|
||||
"showLess": {
|
||||
"message": "Show less"
|
||||
},
|
||||
"enableAutotype": {
|
||||
"message": "Enable Autotype"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
import { StateEventRunnerService } from "@bitwarden/common/platform/state";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { OrganizationInvite } from "@bitwarden/common/auth/services/organization-invite/organization-invite";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
@@ -86,26 +87,29 @@ describe("WebLoginComponentService", () => {
|
||||
});
|
||||
|
||||
describe("getOrgPoliciesFromOrgInvite", () => {
|
||||
const mockEmail = "test@example.com";
|
||||
const orgInvite: OrganizationInvite = {
|
||||
organizationId: "org-id",
|
||||
token: "token",
|
||||
email: mockEmail,
|
||||
organizationUserId: "org-user-id",
|
||||
initOrganization: false,
|
||||
orgSsoIdentifier: "sso-id",
|
||||
orgUserHasExistingUser: false,
|
||||
organizationName: "org-name",
|
||||
};
|
||||
|
||||
it("returns undefined if organization invite is null", async () => {
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(null);
|
||||
const result = await service.getOrgPoliciesFromOrgInvite();
|
||||
const result = await service.getOrgPoliciesFromOrgInvite(mockEmail);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("logs an error if getPoliciesByToken throws an error", async () => {
|
||||
const error = new Error("Test error");
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue({
|
||||
organizationId: "org-id",
|
||||
token: "token",
|
||||
email: "email",
|
||||
organizationUserId: "org-user-id",
|
||||
initOrganization: false,
|
||||
orgSsoIdentifier: "sso-id",
|
||||
orgUserHasExistingUser: false,
|
||||
organizationName: "org-name",
|
||||
});
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
policyApiService.getPoliciesByToken.mockRejectedValue(error);
|
||||
await service.getOrgPoliciesFromOrgInvite();
|
||||
await service.getOrgPoliciesFromOrgInvite(mockEmail);
|
||||
expect(logService.error).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
@@ -120,16 +124,7 @@ describe("WebLoginComponentService", () => {
|
||||
const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions();
|
||||
resetPasswordPolicyOptions.autoEnrollEnabled = autoEnrollEnabled;
|
||||
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue({
|
||||
organizationId: "org-id",
|
||||
token: "token",
|
||||
email: "email",
|
||||
organizationUserId: "org-user-id",
|
||||
initOrganization: false,
|
||||
orgSsoIdentifier: "sso-id",
|
||||
orgUserHasExistingUser: false,
|
||||
organizationName: "org-name",
|
||||
});
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue(orgInvite);
|
||||
policyApiService.getPoliciesByToken.mockResolvedValue(policies);
|
||||
|
||||
internalPolicyService.getResetPasswordPolicyOptions.mockReturnValue([
|
||||
@@ -141,7 +136,7 @@ describe("WebLoginComponentService", () => {
|
||||
masterPasswordPolicyOptions,
|
||||
);
|
||||
|
||||
const result = await service.getOrgPoliciesFromOrgInvite();
|
||||
const result = await service.getOrgPoliciesFromOrgInvite(mockEmail);
|
||||
|
||||
expect(result).toEqual({
|
||||
policies: policies,
|
||||
@@ -151,5 +146,40 @@ describe("WebLoginComponentService", () => {
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("given the orgInvite email does not match the provided email", () => {
|
||||
const mockMismatchedEmail = "mismatched@example.com";
|
||||
it("should clear the login redirect URL and organization invite", async () => {
|
||||
// Arrange
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue({
|
||||
...orgInvite,
|
||||
email: mockMismatchedEmail,
|
||||
});
|
||||
|
||||
// Act
|
||||
await service.getOrgPoliciesFromOrgInvite(mockEmail);
|
||||
|
||||
// Assert
|
||||
expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalledTimes(1);
|
||||
expect(organizationInviteService.clearOrganizationInvitation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should log an error and return undefined", async () => {
|
||||
// Arrange
|
||||
organizationInviteService.getOrganizationInvite.mockResolvedValue({
|
||||
...orgInvite,
|
||||
email: mockMismatchedEmail,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await service.getOrgPoliciesFromOrgInvite(mockEmail);
|
||||
|
||||
// Assert
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
`WebLoginComponentService.getOrgPoliciesFromOrgInvite: Email mismatch. Expected: ${mockMismatchedEmail}, Received: ${mockEmail}`,
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,10 +66,27 @@ export class WebLoginComponentService
|
||||
return;
|
||||
}
|
||||
|
||||
async getOrgPoliciesFromOrgInvite(): Promise<PasswordPolicies | undefined> {
|
||||
async getOrgPoliciesFromOrgInvite(email: string): Promise<PasswordPolicies | undefined> {
|
||||
const orgInvite = await this.organizationInviteService.getOrganizationInvite();
|
||||
|
||||
if (orgInvite != null) {
|
||||
/**
|
||||
* Check if the email on the org invite matches the email submitted in the login form. This is
|
||||
* important because say userA at "userA@mail.com" clicks an emailed org invite link, but then
|
||||
* on the login page form they change the email to "userB@mail.com". We don't want to apply the org
|
||||
* invite in state to userB. Therefore we clear the login redirect url as well as the org invite,
|
||||
* allowing userB to login as normal.
|
||||
*/
|
||||
if (orgInvite.email !== email.toLowerCase()) {
|
||||
await this.routerService.getAndClearLoginRedirectUrl();
|
||||
await this.organizationInviteService.clearOrganizationInvitation();
|
||||
|
||||
this.logService.error(
|
||||
`WebLoginComponentService.getOrgPoliciesFromOrgInvite: Email mismatch. Expected: ${orgInvite.email}, Received: ${email}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let policies: Policy[];
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,11 +2,14 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -60,6 +63,11 @@ describe("EmergencyViewDialogComponent", () => {
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: TaskService, useValue: mock<TaskService>() },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
|
||||
},
|
||||
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
|
||||
],
|
||||
})
|
||||
.overrideComponent(EmergencyViewDialogComponent, {
|
||||
|
||||
@@ -84,7 +84,7 @@ import { IpcService } from "@bitwarden/common/platform/ipc";
|
||||
import {
|
||||
UnsupportedWebPushConnectionService,
|
||||
WebPushConnectionService,
|
||||
} from "@bitwarden/common/platform/notifications/internal";
|
||||
} from "@bitwarden/common/platform/server-notifications/internal";
|
||||
import { AppIdService as DefaultAppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
||||
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
|
||||
|
||||
@@ -13,7 +13,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { IpcService } from "@bitwarden/common/platform/ipc";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SupportStatus } from "@bitwarden/common/platform/misc/support-status";
|
||||
import {
|
||||
WebPushConnector,
|
||||
WorkerWebPushConnectionService,
|
||||
} from "@bitwarden/common/platform/notifications/internal";
|
||||
} from "@bitwarden/common/platform/server-notifications/internal";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class PermissionsWebPushConnectionService extends WorkerWebPushConnectionService {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom, ReplaySubject } from "rxjs";
|
||||
import { firstValueFrom, Observable, ReplaySubject } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SelfHostedEnvironment,
|
||||
} from "@bitwarden/common/platform/services/default-environment.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export type WebRegionConfig = RegionConfig & {
|
||||
key: Region | string; // strings are used for custom environments
|
||||
@@ -27,6 +28,8 @@ export type WebRegionConfig = RegionConfig & {
|
||||
* Web specific environment service. Ensures that the urls are set from the window location.
|
||||
*/
|
||||
export class WebEnvironmentService extends DefaultEnvironmentService {
|
||||
private _environmentSubject: ReplaySubject<Environment>;
|
||||
|
||||
constructor(
|
||||
private win: Window,
|
||||
stateProvider: StateProvider,
|
||||
@@ -60,7 +63,9 @@ export class WebEnvironmentService extends DefaultEnvironmentService {
|
||||
// Override the environment observable with a replay subject
|
||||
const subject = new ReplaySubject<Environment>(1);
|
||||
subject.next(environment);
|
||||
this._environmentSubject = subject;
|
||||
this.environment$ = subject.asObservable();
|
||||
this.globalEnvironment$ = subject.asObservable();
|
||||
}
|
||||
|
||||
// Web setting env means navigating to a new location
|
||||
@@ -100,6 +105,12 @@ export class WebEnvironmentService extends DefaultEnvironmentService {
|
||||
// This return shouldn't matter as we are about to leave the current window
|
||||
return chosenRegionConfig.urls;
|
||||
}
|
||||
|
||||
getEnvironment$(userId: UserId): Observable<Environment> {
|
||||
// Web does not support account switching, and even if it did, you'd be required to be the environment of where the application
|
||||
// is running.
|
||||
return this._environmentSubject.asObservable();
|
||||
}
|
||||
}
|
||||
|
||||
export class WebCloudEnvironment extends CloudEnvironment {
|
||||
|
||||
@@ -565,7 +565,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
this.refreshing = false;
|
||||
|
||||
// Explicitly mark for check to ensure the view is updated
|
||||
// Some sources are not always emitted within the Angular zone (e.g. ciphers updated via WS notifications)
|
||||
// Some sources are not always emitted within the Angular zone (e.g. ciphers updated via WS server notifications)
|
||||
this.changeDetectorRef.markForCheck();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11002,5 +11002,11 @@
|
||||
},
|
||||
"providersubCanceledmessage": {
|
||||
"message" : "To resubscribe, contact Bitwarden Customer Support."
|
||||
},
|
||||
"showMore": {
|
||||
"message": "Show more"
|
||||
},
|
||||
"showLess": {
|
||||
"message": "Show less"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,22 +204,20 @@ import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/inter
|
||||
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
|
||||
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
// eslint-disable-next-line no-restricted-imports -- Needed for service creation
|
||||
import {
|
||||
DefaultServerNotificationsService,
|
||||
UnsupportedServerNotificationsService,
|
||||
SignalRConnectionService,
|
||||
UnsupportedWebPushConnectionService,
|
||||
WebPushConnectionService,
|
||||
WebPushNotificationsApiService,
|
||||
} from "@bitwarden/common/platform/notifications/internal";
|
||||
import { SystemNotificationsService } from "@bitwarden/common/platform/notifications/system-notifications.service";
|
||||
import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/notifications/unsupported-system-notifications.service";
|
||||
import {
|
||||
DefaultTaskSchedulerService,
|
||||
TaskSchedulerService,
|
||||
} from "@bitwarden/common/platform/scheduling";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
// eslint-disable-next-line no-restricted-imports -- Needed for service creation
|
||||
import {
|
||||
DefaultServerNotificationsService,
|
||||
NoopServerNotificationsService,
|
||||
SignalRConnectionService,
|
||||
UnsupportedWebPushConnectionService,
|
||||
WebPushConnectionService,
|
||||
WebPushNotificationsApiService,
|
||||
} from "@bitwarden/common/platform/server-notifications/internal";
|
||||
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
||||
@@ -256,6 +254,8 @@ import { StateEventRunnerService } from "@bitwarden/common/platform/state/state-
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
// eslint-disable-next-line no-restricted-imports -- Needed for DI
|
||||
import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal";
|
||||
import { SystemNotificationsService } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/system-notifications/unsupported-system-notifications.service";
|
||||
import {
|
||||
DefaultThemeStateService,
|
||||
ThemeStateService,
|
||||
@@ -978,7 +978,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: ServerNotificationsService,
|
||||
useClass: devFlagEnabled("noopNotifications")
|
||||
? UnsupportedServerNotificationsService
|
||||
? NoopServerNotificationsService
|
||||
: DefaultServerNotificationsService,
|
||||
deps: [
|
||||
LogService,
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
<div class="tw-flex tw-justify-center tw-items-center" aria-hidden="true">
|
||||
<!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
|
||||
<div
|
||||
class="tw-flex tw-justify-center tw-items-center"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ng-container *ngIf="data$ | async as data">
|
||||
<img
|
||||
[src]="data.image"
|
||||
*ngIf="data.imageEnabled && data.image"
|
||||
class="tw-size-6 tw-rounded-md"
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
[ngClass]="{ 'tw-invisible tw-absolute': !imageLoaded() }"
|
||||
(load)="imageLoaded.set(true)"
|
||||
(error)="imageLoaded.set(false)"
|
||||
/>
|
||||
<i
|
||||
class="tw-w-6 tw-text-muted bwi bwi-lg {{ data.icon }}"
|
||||
*ngIf="!data.imageEnabled || !data.image || !imageLoaded()"
|
||||
></i>
|
||||
@if (data.imageEnabled && data.image) {
|
||||
<img
|
||||
[src]="data.image"
|
||||
class="tw-rounded-md"
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
[ngClass]="{
|
||||
'tw-invisible tw-absolute': !imageLoaded(),
|
||||
'tw-size-6': !coloredIcon(),
|
||||
}"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
(load)="imageLoaded.set(true)"
|
||||
(error)="imageLoaded.set(false)"
|
||||
/>
|
||||
}
|
||||
@if (!data.imageEnabled || !data.image || !imageLoaded()) {
|
||||
<div
|
||||
[ngClass]="{
|
||||
'tw-flex tw-items-center tw-justify-center': coloredIcon(),
|
||||
'tw-bg-illustration-bg-primary tw-rounded-full':
|
||||
data.icon?.startsWith('bwi-') && coloredIcon(),
|
||||
}"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
>
|
||||
<i
|
||||
class="tw-text-muted bwi bwi-lg {{ data.icon }}"
|
||||
[ngStyle]="{
|
||||
color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null,
|
||||
width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null,
|
||||
height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null,
|
||||
}"
|
||||
></i>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,11 @@ export class IconComponent {
|
||||
*/
|
||||
cipher = input.required<CipherViewLike>();
|
||||
|
||||
/**
|
||||
* coloredIcon will adjust the size of favicons and the colors of the text icon when user is in the item details view.
|
||||
*/
|
||||
coloredIcon = input<boolean>(false);
|
||||
|
||||
imageLoaded = signal(false);
|
||||
|
||||
protected data$: Observable<CipherIconDetails>;
|
||||
|
||||
@@ -23,7 +23,7 @@ export abstract class LoginComponentService {
|
||||
* Gets the organization policies if there is an organization invite.
|
||||
* - Used by: Web
|
||||
*/
|
||||
getOrgPoliciesFromOrgInvite?: () => Promise<PasswordPolicies | null>;
|
||||
getOrgPoliciesFromOrgInvite?: (email: string) => Promise<PasswordPolicies | null>;
|
||||
|
||||
/**
|
||||
* Indicates whether login with passkey is supported on the given client
|
||||
|
||||
@@ -80,6 +80,7 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
clientType: ClientType;
|
||||
ClientType = ClientType;
|
||||
orgPoliciesFromInvite: PasswordPolicies | null = null;
|
||||
LoginUiState = LoginUiState;
|
||||
isKnownDevice = false;
|
||||
loginUiState: LoginUiState = LoginUiState.EMAIL_ENTRY;
|
||||
@@ -232,11 +233,12 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
// Try to retrieve any org policies from an org invite now so we can send it to the
|
||||
// login strategies. Since it is optional and we only want to be doing this on the
|
||||
// web we will only send in content in the right context.
|
||||
const orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite
|
||||
? await this.loginComponentService.getOrgPoliciesFromOrgInvite()
|
||||
this.orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite
|
||||
? await this.loginComponentService.getOrgPoliciesFromOrgInvite(email)
|
||||
: null;
|
||||
|
||||
const orgMasterPasswordPolicyOptions = orgPoliciesFromInvite?.enforcedPasswordPolicyOptions;
|
||||
const orgMasterPasswordPolicyOptions =
|
||||
this.orgPoliciesFromInvite?.enforcedPasswordPolicyOptions;
|
||||
|
||||
const credentials = new PasswordLoginCredentials(
|
||||
email,
|
||||
@@ -327,25 +329,18 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
// TODO: PM-18269 - evaluate if we can combine this with the
|
||||
// password evaluation done in the password login strategy.
|
||||
// If there's an existing org invite, use it to get the org's password policies
|
||||
// so we can evaluate the MP against the org policies
|
||||
if (this.loginComponentService.getOrgPoliciesFromOrgInvite) {
|
||||
const orgPolicies: PasswordPolicies | null =
|
||||
await this.loginComponentService.getOrgPoliciesFromOrgInvite();
|
||||
if (this.orgPoliciesFromInvite) {
|
||||
// Since we have retrieved the policies, we can go ahead and set them into state for future use
|
||||
// e.g., the change-password page currently only references state for policy data and
|
||||
// doesn't fallback to pulling them from the server like it should if they are null.
|
||||
await this.setPoliciesIntoState(authResult.userId, this.orgPoliciesFromInvite.policies);
|
||||
|
||||
if (orgPolicies) {
|
||||
// Since we have retrieved the policies, we can go ahead and set them into state for future use
|
||||
// e.g., the change-password page currently only references state for policy data and
|
||||
// doesn't fallback to pulling them from the server like it should if they are null.
|
||||
await this.setPoliciesIntoState(authResult.userId, orgPolicies.policies);
|
||||
|
||||
const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy(
|
||||
orgPolicies.enforcedPasswordPolicyOptions,
|
||||
);
|
||||
if (isPasswordChangeRequired) {
|
||||
await this.router.navigate(["change-password"]);
|
||||
return;
|
||||
}
|
||||
const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy(
|
||||
this.orgPoliciesFromInvite.enforcedPasswordPolicyOptions,
|
||||
);
|
||||
if (isPasswordChangeRequired) {
|
||||
await this.router.navigate(["change-password"]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,9 +109,9 @@ export abstract class AuthRequestServiceAbstraction {
|
||||
): Promise<{ masterKey: MasterKey; masterKeyHash: string }>;
|
||||
|
||||
/**
|
||||
* Handles incoming auth request push notifications.
|
||||
* Handles incoming auth request push server notifications.
|
||||
* @param notification push notification.
|
||||
* @remark We should only be receiving approved push notifications to prevent enumeration.
|
||||
* @remark We should only be receiving approved push server notifications to prevent enumeration.
|
||||
*/
|
||||
abstract sendAuthRequestPushNotification(notification: AuthRequestPushNotification): void;
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export class DefaultAuthRequestApiService implements AuthRequestApiServiceAbstra
|
||||
try {
|
||||
// Submit the current device identifier in the header as well as in the POST body.
|
||||
// The value in the header will be used to build the request context and ensure that the resulting
|
||||
// notifications have the current device as a source.
|
||||
// server notifications have the current device as a source.
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
"/auth-requests/",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/notifications/system-notifications.service";
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export abstract class AuthRequestAnsweringServiceAbstraction {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ButtonLocation,
|
||||
SystemNotificationEvent,
|
||||
SystemNotificationsService,
|
||||
} from "@bitwarden/common/platform/notifications/system-notifications.service";
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum PushTechnology {
|
||||
/**
|
||||
* Indicates that we should use SignalR over web sockets to receive push notifications from the server.
|
||||
* Indicates that we should use SignalR over web sockets to receive push server notifications from the server.
|
||||
*/
|
||||
SignalR = 0,
|
||||
/**
|
||||
* Indicatates that we should use WebPush to receive push notifications from the server.
|
||||
* Indicates that we should use WebPush to receive push server notifications from the server.
|
||||
*/
|
||||
WebPush = 1,
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ export abstract class ConfigApiServiceAbstraction {
|
||||
/**
|
||||
* Fetches the server configuration for the given user. If no user is provided, the configuration will not contain user-specific context.
|
||||
*/
|
||||
abstract get(userId: UserId | undefined): Promise<ServerConfigResponse>;
|
||||
abstract get(userId: UserId | null): Promise<ServerConfigResponse>;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,13 @@ export interface Environment {
|
||||
*/
|
||||
export abstract class EnvironmentService {
|
||||
abstract environment$: Observable<Environment>;
|
||||
|
||||
/**
|
||||
* The environment stored in global state, when a user signs in the state stored here will become
|
||||
* their user environment.
|
||||
*/
|
||||
abstract globalEnvironment$: Observable<Environment>;
|
||||
|
||||
abstract cloudWebVaultUrl$: Observable<string>;
|
||||
|
||||
/**
|
||||
@@ -125,12 +132,12 @@ export abstract class EnvironmentService {
|
||||
* @param userId - The user id to set the cloud web vault app URL for. If null or undefined the global environment is set.
|
||||
* @param region - The region of the cloud web vault app.
|
||||
*/
|
||||
abstract setCloudRegion(userId: UserId, region: Region): Promise<void>;
|
||||
abstract setCloudRegion(userId: UserId | null, region: Region): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the environment from state. Useful if you need to get the environment for another user.
|
||||
*/
|
||||
abstract getEnvironment$(userId: UserId): Observable<Environment | undefined>;
|
||||
abstract getEnvironment$(userId: UserId): Observable<Environment>;
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link getEnvironment$} instead.
|
||||
|
||||
@@ -28,7 +28,7 @@ The `openPopup()` method has limitations in some environments due to browser-spe
|
||||
|
||||
- **Safari**: Only works when `openPopup()` is triggered from a window context. Attempts from background service workers fail.
|
||||
- **Firefox**: Does not appear to support `openPopup()` in either context.
|
||||
- **Chrome**: Fully functional in both contexts.
|
||||
- **Chrome**: Fully functional in both contexts, but only on Mac. Windows it does not work in.
|
||||
- **Edge**: Behavior has not been tested.
|
||||
- **Vivaldi**: `openPopup()` results in an error that _might_ be related to running in a background context, but the cause is currently unclear.
|
||||
- **Opera**: Works from window context. Background calls fail silently with no error message.
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("NotificationsService", () => {
|
||||
expect(actualNotification.type).toBe(expectedType);
|
||||
};
|
||||
|
||||
it("emits notifications through WebPush when supported", async () => {
|
||||
it("emits server notifications through WebPush when supported", async () => {
|
||||
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2)));
|
||||
|
||||
emitActiveUser(mockUser1);
|
||||
@@ -230,7 +230,7 @@ describe("NotificationsService", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
// Temporarily rolling back notifications being connected while locked
|
||||
// Temporarily rolling back server notifications being connected while locked
|
||||
// { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
|
||||
// { initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
|
||||
// { initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
|
||||
@@ -259,7 +259,7 @@ describe("NotificationsService", () => {
|
||||
);
|
||||
|
||||
it.each([
|
||||
// Temporarily disabling notifications connecting while in a locked state
|
||||
// Temporarily disabling server notifications connecting while in a locked state
|
||||
// AuthenticationStatus.Locked,
|
||||
AuthenticationStatus.Unlocked,
|
||||
])(
|
||||
@@ -285,7 +285,7 @@ describe("NotificationsService", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("does not connect to any notification stream when notifications are disabled through special url", () => {
|
||||
it("does not connect to any notification stream when server notifications are disabled through special url", () => {
|
||||
const subscription = sut.notifications$.subscribe();
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl(DISABLED_NOTIFICATIONS_URL);
|
||||
@@ -63,7 +63,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
distinctUntilChanged(),
|
||||
switchMap((activeAccountId) => {
|
||||
if (activeAccountId == null) {
|
||||
// We don't emit notifications for inactive accounts currently
|
||||
// We don't emit server-notifications for inactive accounts currently
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
@@ -76,8 +76,8 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a stream of push notifications for the given user.
|
||||
* @param userId The user id of the user to get the push notifications for.
|
||||
* Retrieves a stream of push server notifications for the given user.
|
||||
* @param userId The user id of the user to get the push server notifications for.
|
||||
*/
|
||||
private userNotifications$(userId: UserId) {
|
||||
return this.environmentService.environment$.pipe(
|
||||
@@ -111,7 +111,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
}),
|
||||
supportSwitch({
|
||||
supported: (service) => {
|
||||
this.logService.info("Using WebPush for notifications");
|
||||
this.logService.info("Using WebPush for server notifications");
|
||||
return service.notifications$.pipe(
|
||||
catchError((err: unknown) => {
|
||||
this.logService.warning("Issue with web push, falling back to SignalR", err);
|
||||
@@ -120,7 +120,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
);
|
||||
},
|
||||
notSupported: () => {
|
||||
this.logService.info("Using SignalR for notifications");
|
||||
this.logService.info("Using SignalR for server notifications");
|
||||
return this.connectSignalR$(userId, notificationsUrl);
|
||||
},
|
||||
}),
|
||||
@@ -240,7 +240,8 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
mergeMap(async ([notification, userId]) => this.processNotification(notification, userId)),
|
||||
)
|
||||
.subscribe({
|
||||
error: (e: unknown) => this.logService.warning("Error in notifications$ observable", e),
|
||||
error: (e: unknown) =>
|
||||
this.logService.warning("Error in server notifications$ observable", e),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * from "./worker-webpush-connection.service";
|
||||
export * from "./signalr-connection.service";
|
||||
export * from "./default-server-notifications.service";
|
||||
export * from "./unsupported-server-notifications.service";
|
||||
export * from "./noop-server-notifications.service";
|
||||
export * from "./unsupported-webpush-connection.service";
|
||||
export * from "./webpush-connection.service";
|
||||
export * from "./websocket-webpush-connection.service";
|
||||
@@ -6,14 +6,14 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { ServerNotificationsService } from "../server-notifications.service";
|
||||
|
||||
export class UnsupportedServerNotificationsService implements ServerNotificationsService {
|
||||
export class NoopServerNotificationsService implements ServerNotificationsService {
|
||||
notifications$: Observable<readonly [NotificationResponse, UserId]> = new Subject();
|
||||
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
startListening(): Subscription {
|
||||
this.logService.info(
|
||||
"Initializing no-op notification service, no push notifications will be received",
|
||||
"Initializing no-op notification service, no push server notifications will be received",
|
||||
);
|
||||
return Subscription.EMPTY;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export class WebPushNotificationsApiService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Posts a device-user association to the server and ensures it's installed for push notifications
|
||||
* Posts a device-user association to the server and ensures it's installed for push server notifications
|
||||
*/
|
||||
async putSubscription(pushSubscription: PushSubscriptionJSON): Promise<void> {
|
||||
const request = WebPushRequest.from(pushSubscription);
|
||||
@@ -40,7 +40,7 @@ interface PushEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation for connecting to web push based notifications running in a Worker.
|
||||
* An implementation for connecting to web push based server notifications running in a Worker.
|
||||
*/
|
||||
export class WorkerWebPushConnectionService implements WebPushConnectionService {
|
||||
private pushEvent = new Subject<PushEvent>();
|
||||
@@ -75,7 +75,7 @@ export class WorkerWebPushConnectionService implements WebPushConnectionService
|
||||
}
|
||||
|
||||
supportStatus$(userId: UserId): Observable<SupportStatus<WebPushConnector>> {
|
||||
// Check the server config to see if it supports sending WebPush notifications
|
||||
// Check the server config to see if it supports sending WebPush server notifications
|
||||
// FIXME: get config of server for the specified userId, once ConfigService supports it
|
||||
return this.configService.serverConfig$.pipe(
|
||||
map((config) =>
|
||||
@@ -13,11 +13,11 @@ export abstract class ServerNotificationsService {
|
||||
/**
|
||||
* @deprecated This method should not be consumed, an observable to listen to server
|
||||
* notifications will be available one day but it is not ready to be consumed generally.
|
||||
* Please add code reacting to notifications in {@link DefaultServerNotificationsService.processNotification}
|
||||
* Please add code reacting to server notifications in {@link DefaultServerNotificationsService.processNotification}
|
||||
*/
|
||||
abstract notifications$: Observable<readonly [NotificationResponse, UserId]>;
|
||||
/**
|
||||
* Starts automatic listening and processing of notifications, should only be called once per application,
|
||||
* Starts automatic listening and processing of server notifications, should only be called once per application,
|
||||
* or you will risk notifications being processed multiple times.
|
||||
*/
|
||||
abstract startListening(): Subscription;
|
||||
@@ -10,7 +10,7 @@ export class ConfigApiService implements ConfigApiServiceAbstraction {
|
||||
private tokenService: TokenService,
|
||||
) {}
|
||||
|
||||
async get(userId: UserId | undefined): Promise<ServerConfigResponse> {
|
||||
async get(userId: UserId | null): Promise<ServerConfigResponse> {
|
||||
// Authentication adds extra context to config responses, if the user has an access token, we want to use it
|
||||
// We don't particularly care about ensuring the token is valid and not expired, just that it exists
|
||||
const authed: boolean =
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
FakeGlobalState,
|
||||
FakeSingleUserState,
|
||||
FakeStateProvider,
|
||||
awaitAsync,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../spec";
|
||||
import { Matrix } from "../../../../spec/matrix";
|
||||
import { subscribeTo } from "../../../../spec/observable-tracker";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
@@ -74,7 +74,8 @@ describe("ConfigService", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
environmentService.environment$ = environmentSubject;
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
environmentService.globalEnvironment$ = environmentSubject;
|
||||
sut = new DefaultConfigService(
|
||||
configApiService,
|
||||
environmentService,
|
||||
@@ -98,9 +99,12 @@ describe("ConfigService", () => {
|
||||
: serverConfigFactory(activeApiUrl + userId, tooOld);
|
||||
const globalStored =
|
||||
configStateDescription === "missing"
|
||||
? {}
|
||||
? {
|
||||
[activeApiUrl]: null,
|
||||
}
|
||||
: {
|
||||
[activeApiUrl]: serverConfigFactory(activeApiUrl, tooOld),
|
||||
[activeApiUrl + "0"]: serverConfigFactory(activeApiUrl + userId, tooOld),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -108,11 +112,6 @@ describe("ConfigService", () => {
|
||||
userState.nextState(userStored);
|
||||
});
|
||||
|
||||
// sanity check
|
||||
test("authed and unauthorized state are different", () => {
|
||||
expect(globalStored[activeApiUrl]).not.toEqual(userStored);
|
||||
});
|
||||
|
||||
describe("fail to fetch", () => {
|
||||
beforeEach(() => {
|
||||
configApiService.get.mockRejectedValue(new Error("Unable to fetch"));
|
||||
@@ -178,6 +177,7 @@ describe("ConfigService", () => {
|
||||
beforeEach(() => {
|
||||
globalState.stateSubject.next(globalStored);
|
||||
userState.nextState(userStored);
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
});
|
||||
it("does not fetch from server", async () => {
|
||||
await firstValueFrom(sut.serverConfig$);
|
||||
@@ -189,21 +189,13 @@ describe("ConfigService", () => {
|
||||
const actual = await firstValueFrom(sut.serverConfig$);
|
||||
expect(actual).toEqual(activeUserId ? userStored : globalStored[activeApiUrl]);
|
||||
});
|
||||
|
||||
it("does not complete after emit", async () => {
|
||||
const emissions = [];
|
||||
const subscription = sut.serverConfig$.subscribe((v) => emissions.push(v));
|
||||
await awaitAsync();
|
||||
expect(emissions.length).toBe(1);
|
||||
expect(subscription.closed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("gets global config when there is an locked active user", async () => {
|
||||
await accountService.switchAccount(userId);
|
||||
environmentService.environment$ = of(environmentFactory(activeApiUrl));
|
||||
environmentService.globalEnvironment$ = of(environmentFactory(activeApiUrl));
|
||||
|
||||
globalState.stateSubject.next({
|
||||
[activeApiUrl]: serverConfigFactory(activeApiUrl + "global"),
|
||||
@@ -236,7 +228,8 @@ describe("ConfigService", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
environmentSubject = new Subject<Environment>();
|
||||
environmentService.environment$ = environmentSubject;
|
||||
environmentService.globalEnvironment$ = environmentSubject;
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
sut = new DefaultConfigService(
|
||||
configApiService,
|
||||
environmentService,
|
||||
@@ -327,7 +320,8 @@ describe("ConfigService", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
const config = serverConfigFactory("existing-data", tooOld);
|
||||
environmentService.environment$ = environmentSubject;
|
||||
environmentService.globalEnvironment$ = environmentSubject;
|
||||
Matrix.autoMockMethod(environmentService.getEnvironment$, () => environmentSubject);
|
||||
|
||||
globalState.stateSubject.next({ [apiUrl(0)]: config });
|
||||
userState.stateSubject.next({
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
map,
|
||||
mergeWith,
|
||||
NEVER,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
ReplaySubject,
|
||||
share,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
import { SemVer } from "semver";
|
||||
|
||||
@@ -50,11 +51,15 @@ export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record<ServerConfig, A
|
||||
},
|
||||
);
|
||||
|
||||
const environmentComparer = (previous: Environment, current: Environment) => {
|
||||
return previous.getApiUrl() === current.getApiUrl();
|
||||
};
|
||||
|
||||
// FIXME: currently we are limited to api requests for active users. Update to accept a UserId and APIUrl once ApiService supports it.
|
||||
export class DefaultConfigService implements ConfigService {
|
||||
private failedFetchFallbackSubject = new Subject<ServerConfig>();
|
||||
private failedFetchFallbackSubject = new Subject<ServerConfig | null>();
|
||||
|
||||
serverConfig$: Observable<ServerConfig>;
|
||||
serverConfig$: Observable<ServerConfig | null>;
|
||||
|
||||
serverSettings$: Observable<ServerSettings>;
|
||||
|
||||
@@ -67,32 +72,61 @@ export class DefaultConfigService implements ConfigService {
|
||||
private stateProvider: StateProvider,
|
||||
private authService: AuthService,
|
||||
) {
|
||||
const userId$ = this.stateProvider.activeUserId$;
|
||||
const authStatus$ = userId$.pipe(
|
||||
switchMap((userId) => (userId == null ? of(null) : this.authService.authStatusFor$(userId))),
|
||||
const globalConfig$ = this.environmentService.globalEnvironment$.pipe(
|
||||
distinctUntilChanged(environmentComparer),
|
||||
switchMap((environment) =>
|
||||
this.globalConfigFor$(environment.getApiUrl()).pipe(
|
||||
map((config) => {
|
||||
return [config, null as UserId | null, environment, config] as const;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.serverConfig$ = combineLatest([
|
||||
userId$,
|
||||
this.environmentService.environment$,
|
||||
authStatus$,
|
||||
]).pipe(
|
||||
switchMap(([userId, environment, authStatus]) => {
|
||||
if (userId == null || authStatus !== AuthenticationStatus.Unlocked) {
|
||||
return this.globalConfigFor$(environment.getApiUrl()).pipe(
|
||||
map((config) => [config, null, environment] as const),
|
||||
);
|
||||
this.serverConfig$ = this.stateProvider.activeUserId$.pipe(
|
||||
distinctUntilChanged(),
|
||||
switchMap((userId) => {
|
||||
if (userId == null) {
|
||||
// Global
|
||||
return globalConfig$;
|
||||
}
|
||||
|
||||
return this.userConfigFor$(userId).pipe(
|
||||
map((config) => [config, userId, environment] as const),
|
||||
return this.authService.authStatusFor$(userId).pipe(
|
||||
map((authStatus) => authStatus === AuthenticationStatus.Unlocked),
|
||||
distinctUntilChanged(),
|
||||
switchMap((isUnlocked) => {
|
||||
if (!isUnlocked) {
|
||||
return globalConfig$;
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.environmentService
|
||||
.getEnvironment$(userId)
|
||||
.pipe(distinctUntilChanged(environmentComparer)),
|
||||
this.userConfigFor$(userId),
|
||||
]).pipe(
|
||||
switchMap(([environment, config]) => {
|
||||
if (config == null) {
|
||||
// If the user doesn't have any config yet, use the global config for that url as the fallback
|
||||
return this.globalConfigFor$(environment.getApiUrl()).pipe(
|
||||
map(
|
||||
(globalConfig) =>
|
||||
[null as ServerConfig | null, userId, environment, globalConfig] as const,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return of([config, userId, environment, config] as const);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
tap(async (rec) => {
|
||||
const [existingConfig, userId, environment] = rec;
|
||||
const [existingConfig, userId, environment, fallbackConfig] = rec;
|
||||
// Grab new config if older retrieval interval
|
||||
if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) {
|
||||
await this.renewConfig(existingConfig, userId, environment);
|
||||
await this.renewConfig(existingConfig, userId, environment, fallbackConfig);
|
||||
}
|
||||
}),
|
||||
switchMap(([existingConfig]) => {
|
||||
@@ -106,7 +140,7 @@ export class DefaultConfigService implements ConfigService {
|
||||
}),
|
||||
// If fetch fails, we'll emit on this subject to fallback to the existing config
|
||||
mergeWith(this.failedFetchFallbackSubject),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
share({ connector: () => new ReplaySubject(1), resetOnRefCountZero: () => timer(1000) }),
|
||||
);
|
||||
|
||||
this.cloudRegion$ = this.serverConfig$.pipe(
|
||||
@@ -155,19 +189,18 @@ export class DefaultConfigService implements ConfigService {
|
||||
|
||||
// Updates the on-disk configuration with a newly retrieved configuration
|
||||
private async renewConfig(
|
||||
existingConfig: ServerConfig,
|
||||
userId: UserId,
|
||||
existingConfig: ServerConfig | null,
|
||||
userId: UserId | null,
|
||||
environment: Environment,
|
||||
fallbackConfig: ServerConfig | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Feature flags often have a big impact on user experience, lets ensure we return some value
|
||||
// somewhat quickly even though it may not be accurate, we won't cancel the HTTP request
|
||||
// though so that hopefully it can have finished and hydrated a more accurate value.
|
||||
const handle = setTimeout(() => {
|
||||
this.logService.info(
|
||||
"Self-host environment did not respond in time, emitting previous config.",
|
||||
);
|
||||
this.failedFetchFallbackSubject.next(existingConfig);
|
||||
this.logService.info("Environment did not respond in time, emitting previous config.");
|
||||
this.failedFetchFallbackSubject.next(fallbackConfig);
|
||||
}, SLOW_EMISSION_GUARD);
|
||||
const response = await this.configApiService.get(userId);
|
||||
clearTimeout(handle);
|
||||
@@ -195,17 +228,17 @@ export class DefaultConfigService implements ConfigService {
|
||||
// mutate error to be handled by catchError
|
||||
this.logService.error(`Unable to fetch ServerConfig from ${environment.getApiUrl()}`, e);
|
||||
// Emit the existing config
|
||||
this.failedFetchFallbackSubject.next(existingConfig);
|
||||
this.failedFetchFallbackSubject.next(fallbackConfig);
|
||||
}
|
||||
}
|
||||
|
||||
private globalConfigFor$(apiUrl: string): Observable<ServerConfig> {
|
||||
private globalConfigFor$(apiUrl: string): Observable<ServerConfig | null> {
|
||||
return this.stateProvider
|
||||
.getGlobal(GLOBAL_SERVER_CONFIGURATIONS)
|
||||
.state$.pipe(map((configs) => configs?.[apiUrl]));
|
||||
.state$.pipe(map((configs) => configs?.[apiUrl] ?? null));
|
||||
}
|
||||
|
||||
private userConfigFor$(userId: UserId): Observable<ServerConfig> {
|
||||
private userConfigFor$(userId: UserId): Observable<ServerConfig | null> {
|
||||
return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
);
|
||||
|
||||
environment$: Observable<Environment>;
|
||||
globalEnvironment$: Observable<Environment>;
|
||||
cloudWebVaultUrl$: Observable<string>;
|
||||
|
||||
constructor(
|
||||
@@ -148,6 +149,10 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
distinctUntilChanged((oldUserId: UserId, newUserId: UserId) => oldUserId == newUserId),
|
||||
);
|
||||
|
||||
this.globalEnvironment$ = this.stateProvider
|
||||
.getGlobal(GLOBAL_ENVIRONMENT_KEY)
|
||||
.state$.pipe(map((state) => this.buildEnvironment(state?.region, state?.urls)));
|
||||
|
||||
this.environment$ = account$.pipe(
|
||||
switchMap((userId) => {
|
||||
const t = userId
|
||||
@@ -263,7 +268,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
return new SelfHostedEnvironment(urls);
|
||||
}
|
||||
|
||||
async setCloudRegion(userId: UserId, region: CloudRegion) {
|
||||
async setCloudRegion(userId: UserId | null, region: CloudRegion) {
|
||||
if (userId == null) {
|
||||
await this.globalCloudRegionState.update(() => region);
|
||||
} else {
|
||||
@@ -271,7 +276,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
|
||||
}
|
||||
}
|
||||
|
||||
getEnvironment$(userId: UserId): Observable<Environment | undefined> {
|
||||
getEnvironment$(userId: UserId): Observable<Environment> {
|
||||
return this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).state$.pipe(
|
||||
map((state) => {
|
||||
return this.buildEnvironment(state?.region, state?.urls);
|
||||
|
||||
@@ -230,7 +230,7 @@ export abstract class CoreSyncService implements SyncService {
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Process only notifications for currently active user when user is not logged out
|
||||
// Process only server notifications for currently active user when user is not logged out
|
||||
// TODO: once send service allows data manipulation of non-active users, this should process any received notification
|
||||
if (activeUserId === notification.userId && status !== AuthenticationStatus.LoggedOut) {
|
||||
try {
|
||||
|
||||
1
libs/common/src/platform/system-notifications/index.ts
Normal file
1
libs/common/src/platform/system-notifications/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SystemNotificationsService } from "./system-notifications.service";
|
||||
@@ -32,7 +32,7 @@ export type SystemNotificationEvent = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A service responsible for displaying operating system level notifications.
|
||||
* A service responsible for displaying operating system level server notifications.
|
||||
*/
|
||||
export abstract class SystemNotificationsService {
|
||||
abstract notificationClicked$: Observable<SystemNotificationEvent>;
|
||||
@@ -43,7 +43,7 @@ export abstract class SystemNotificationsService {
|
||||
* @returns If a notification is successfully created it will respond back with an
|
||||
* id that refers to a notification.
|
||||
*/
|
||||
abstract create(createInfo: SystemNotificationCreateInfo): Promise<string | undefined>;
|
||||
abstract create(createInfo: SystemNotificationCreateInfo): Promise<string>;
|
||||
|
||||
/**
|
||||
* Clears a notification.
|
||||
@@ -52,7 +52,7 @@ export abstract class SystemNotificationsService {
|
||||
abstract clear(clearInfo: SystemNotificationClearInfo): Promise<void>;
|
||||
|
||||
/**
|
||||
* Used to know if a given platform supports notifications.
|
||||
* Used to know if a given platform supports server notifications.
|
||||
*/
|
||||
abstract isSupported(): boolean;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
export class UnsupportedSystemNotificationsService implements SystemNotificationsService {
|
||||
notificationClicked$ = throwError(() => new Error("Notification clicked is not supported."));
|
||||
|
||||
async create(createInfo: SystemNotificationCreateInfo): Promise<undefined> {
|
||||
async create(createInfo: SystemNotificationCreateInfo): Promise<string> {
|
||||
throw new Error("Create OS Notification unsupported.");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("End User Notification Center Service", () => {
|
||||
});
|
||||
|
||||
describe("notifications$", () => {
|
||||
it("should return notifications from state when not null", async () => {
|
||||
it("should return server notifications from state when not null", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
@@ -62,7 +62,7 @@ describe("End User Notification Center Service", () => {
|
||||
expect(mockLogService.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return notifications API when state is null", async () => {
|
||||
it("should return server notifications API when state is null", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -86,7 +86,7 @@ describe("End User Notification Center Service", () => {
|
||||
expect(mockLogService.warning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log a warning if there are more notifications available", async () => {
|
||||
it("should log a warning if there are more server notifications available", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
...new Array(DEFAULT_NOTIFICATION_PAGE_SIZE + 1).fill({ id: "notification-id" }),
|
||||
@@ -120,7 +120,7 @@ describe("End User Notification Center Service", () => {
|
||||
});
|
||||
|
||||
describe("unreadNotifications$", () => {
|
||||
it("should return unread notifications from state when read value is null", async () => {
|
||||
it("should return unread server notifications from state when read value is null", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
|
||||
{
|
||||
id: "notification-id" as NotificationId,
|
||||
@@ -136,7 +136,7 @@ describe("End User Notification Center Service", () => {
|
||||
});
|
||||
|
||||
describe("getNotifications", () => {
|
||||
it("should call getNotifications returning notifications from API", async () => {
|
||||
it("should call getNotifications returning server notifications from API", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -156,7 +156,7 @@ describe("End User Notification Center Service", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should update local state when notifications are updated", async () => {
|
||||
it("should update local state when server notifications are updated", async () => {
|
||||
mockApiService.send.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
|
||||
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 { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
|
||||
import { NotificationType } from "@bitwarden/common/enums";
|
||||
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
|
||||
import { Message, MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
|
||||
@@ -17,7 +17,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
|
||||
import { NotificationType } from "@bitwarden/common/enums";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ServerNotificationsService } from "@bitwarden/common/platform/server-notifications";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||
import {
|
||||
@@ -171,7 +171,7 @@ export class DefaultTaskService implements TaskService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription for pending security task notifications or completed syncs for unlocked users.
|
||||
* Creates a subscription for pending security task server notifications or completed syncs for unlocked users.
|
||||
*/
|
||||
listenForTaskNotifications(): Subscription {
|
||||
return this.authService.authStatuses$
|
||||
|
||||
@@ -34,14 +34,14 @@ ruleTester.run("required-using", rule.default, {
|
||||
using client = rc.take();
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Function reference with `using`",
|
||||
code: `
|
||||
${setup}
|
||||
const t = rc.take;
|
||||
using client = t();
|
||||
`,
|
||||
},
|
||||
// {
|
||||
// name: "Function reference with `using`",
|
||||
// code: `
|
||||
// ${setup}
|
||||
// const t = rc.take;
|
||||
// using client = t();
|
||||
// `,
|
||||
// },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
@@ -56,43 +56,43 @@ ruleTester.run("required-using", rule.default, {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Assignment without `using`",
|
||||
code: `
|
||||
${setup}
|
||||
let client;
|
||||
client = rc.take();
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
message: errorMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Function reference without `using`",
|
||||
code: `
|
||||
${setup}
|
||||
const t = rc.take;
|
||||
const client = t();
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
message: errorMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Destructuring without `using`",
|
||||
code: `
|
||||
${setup}
|
||||
const { value } = rc.take();
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
message: errorMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// name: "Assignment without `using`",
|
||||
// code: `
|
||||
// ${setup}
|
||||
// let client;
|
||||
// client = rc.take();
|
||||
// `,
|
||||
// errors: [
|
||||
// {
|
||||
// message: errorMessage,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// name: "Function reference without `using`",
|
||||
// code: `
|
||||
// ${setup}
|
||||
// const t = rc.take;
|
||||
// const client = t();
|
||||
// `,
|
||||
// errors: [
|
||||
// {
|
||||
// message: errorMessage,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// name: "Destructuring without `using`",
|
||||
// code: `
|
||||
// ${setup}
|
||||
// const { value } = rc.take();
|
||||
// `,
|
||||
// errors: [
|
||||
// {
|
||||
// message: errorMessage,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -625,7 +625,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
// Vault can be de-synced since notifications get ignored while locked. Need to check whether sync is required using the sync service.
|
||||
// Vault can be de-synced since server notifications get ignored while locked. Need to check whether sync is required using the sync service.
|
||||
const startSync = new Date().getTime();
|
||||
// TODO: This should probably not be blocking
|
||||
await this.syncService.fullSync(false);
|
||||
|
||||
@@ -1,83 +1,86 @@
|
||||
<section class="tw-mb-5 bit-compact:tw-mb-4">
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "itemDetails" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<bit-form-field
|
||||
[disableMargin]="!cipher.collectionIds?.length && !showOwnership && !cipher.folderId"
|
||||
[disableReadOnlyBorder]="!cipher.collectionIds?.length && !showOwnership && !cipher.folderId"
|
||||
<div
|
||||
class="tw-flex tw-place-items-center"
|
||||
[ngClass]="{
|
||||
'tw-mb-2': allItems.length > 0,
|
||||
}"
|
||||
>
|
||||
<bit-label [appTextDrag]="cipher.name">
|
||||
{{ "itemName" | i18n }}
|
||||
</bit-label>
|
||||
<input
|
||||
readonly
|
||||
id="itemName"
|
||||
bitInput
|
||||
type="text"
|
||||
[value]="cipher.name"
|
||||
aria-readonly="true"
|
||||
data-testid="item-name"
|
||||
/>
|
||||
</bit-form-field>
|
||||
|
||||
<ul
|
||||
[attr.aria-label]="'itemLocation' | i18n"
|
||||
*ngIf="cipher.collectionIds?.length || showOwnership || cipher.folderId"
|
||||
class="tw-mb-0 tw-pl-0"
|
||||
>
|
||||
<li
|
||||
*ngIf="showOwnership && organization"
|
||||
class="tw-flex tw-items-center tw-list-none"
|
||||
[ngClass]="{ 'tw-mb-3': cipher.collectionIds }"
|
||||
bitTypography="body2"
|
||||
[attr.aria-label]="('owner' | i18n) + organization.name"
|
||||
data-testid="owner"
|
||||
>
|
||||
<i
|
||||
appOrgIcon
|
||||
[tierType]="organization.productTierType"
|
||||
[size]="'large'"
|
||||
[title]="'owner' | i18n"
|
||||
></i>
|
||||
<span aria-hidden="true" class="tw-pl-1.5">
|
||||
{{ organization.name }}
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="tw-list-none"
|
||||
*ngIf="cipher.collectionIds && collections"
|
||||
[attr.aria-label]="'collection' | i18n"
|
||||
>
|
||||
<ul data-testid="collections" [ngClass]="{ 'tw-mb-0': !cipher.folderId }" class="tw-pl-0">
|
||||
<li
|
||||
*ngFor="let collection of collections; let last = last"
|
||||
class="tw-flex tw-items-center tw-list-none"
|
||||
<!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
|
||||
<div class="tw-flex tw-items-center tw-justify-center" style="width: 40px; height: 40px">
|
||||
<app-vault-icon [cipher]="cipher()" [coloredIcon]="true"></app-vault-icon>
|
||||
</div>
|
||||
<h2 bitTypography="h4" class="tw-ml-2 tw-mt-2" data-testid="item-name">
|
||||
{{ cipher().name }}
|
||||
</h2>
|
||||
</div>
|
||||
<ng-container>
|
||||
<div class="tw-flex tw-flex-col tw-mt-2 md:tw-flex-row md:tw-flex-wrap">
|
||||
@for (item of showItems(); track item.id; let last = $last) {
|
||||
<span
|
||||
class="tw-flex tw-items-center tw-mt-2 tw-mr-4"
|
||||
bitTypography="body2"
|
||||
[ngClass]="{ 'tw-mb-3': last && cipher.folderId }"
|
||||
[attr.aria-label]="collection.name"
|
||||
[attr.aria-label]="getAriaLabel(item)"
|
||||
[ngClass]="{ 'tw-mb-2': last && hasSmallScreen() }"
|
||||
data-testid="item-details-list"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-collection-shared bwi-lg"
|
||||
aria-hidden="true"
|
||||
[title]="'collection' | i18n"
|
||||
></i>
|
||||
@if (isOrgIcon(item)) {
|
||||
<i
|
||||
appOrgIcon
|
||||
[tierType]="organization().productTierType"
|
||||
[size]="'large'"
|
||||
[title]="'owner' | i18n"
|
||||
></i>
|
||||
} @else {
|
||||
<i
|
||||
class="bwi bwi-lg"
|
||||
[ngClass]="getIconClass(item)"
|
||||
aria-hidden="true"
|
||||
[title]="getItemTitle(item)"
|
||||
></i>
|
||||
}
|
||||
<span aria-hidden="true" class="tw-pl-1.5">
|
||||
{{ collection.name }}
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li
|
||||
*ngIf="cipher.folderId && folder"
|
||||
bitTypography="body2"
|
||||
class="tw-flex tw-items-center tw-list-none"
|
||||
[attr.aria-label]="('folder' | i18n) + folder.name"
|
||||
data-testid="folder"
|
||||
>
|
||||
<i class="bwi bwi-folder bwi-lg" aria-hidden="true" [title]="'folder' | i18n"></i>
|
||||
<span aria-hidden="true" class="tw-pl-1.5">{{ folder.name }} </span>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
}
|
||||
@if (allItems().length === 0) {
|
||||
<span
|
||||
class="tw-flex tw-items-center tw-mt-2 tw-mr-4"
|
||||
bitTypography="body2"
|
||||
[attr.aria-label]="'noneFolder' | i18n"
|
||||
>
|
||||
<i class="bwi bwi-folder bwi-lg" aria-hidden="true" [title]="'folder' | i18n"></i>
|
||||
<span aria-hidden="true" class="tw-pl-1.5">
|
||||
{{ "noneFolder" | i18n }}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
@if (hasSmallScreen() && allItems().length > 2 && cipher().collectionIds.length > 1) {
|
||||
<button
|
||||
bitTypography="body2"
|
||||
type="button"
|
||||
bitLink
|
||||
linkType="primary"
|
||||
class="tw-mt-1.5"
|
||||
(click)="toggleShowMore()"
|
||||
*ngIf="!showAllDetails()"
|
||||
>
|
||||
{{ "showMore" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
bitTypography="body2"
|
||||
type="button"
|
||||
bitLink
|
||||
linkType="primary"
|
||||
class="tw-mt-1.5"
|
||||
(click)="toggleShowMore()"
|
||||
*ngIf="showAllDetails()"
|
||||
>
|
||||
{{ "showLess" | i18n }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
</bit-card>
|
||||
</section>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { ComponentRef } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { of } from "rxjs";
|
||||
|
||||
// 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 { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
@@ -14,6 +20,7 @@ import { ItemDetailsV2Component } from "./item-details-v2.component";
|
||||
describe("ItemDetailsV2Component", () => {
|
||||
let component: ItemDetailsV2Component;
|
||||
let fixture: ComponentFixture<ItemDetailsV2Component>;
|
||||
let componentRef: ComponentRef<ItemDetailsV2Component>;
|
||||
|
||||
const cipher = {
|
||||
id: "cipher1",
|
||||
@@ -46,37 +53,46 @@ describe("ItemDetailsV2Component", () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ItemDetailsV2Component],
|
||||
providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: PlatformUtilsService, useValue: { getClientType: () => ClientType.Web } },
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
|
||||
},
|
||||
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ItemDetailsV2Component);
|
||||
component = fixture.componentInstance;
|
||||
component.cipher = cipher;
|
||||
component.organization = organization;
|
||||
component.collections = [collection, collection2];
|
||||
component.folder = folder;
|
||||
componentRef = fixture.componentRef;
|
||||
componentRef.setInput("cipher", cipher);
|
||||
componentRef.setInput("organization", organization);
|
||||
componentRef.setInput("collections", [collection, collection2]);
|
||||
componentRef.setInput("folder", folder);
|
||||
jest.spyOn(component, "hasSmallScreen").mockReturnValue(false); // Mocking small screen check
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("displays all available fields", () => {
|
||||
const itemName = fixture.debugElement.query(By.css('[data-testid="item-name"]'));
|
||||
const owner = fixture.debugElement.query(By.css('[data-testid="owner"]'));
|
||||
const collections = fixture.debugElement.queryAll(By.css('[data-testid="collections"] li'));
|
||||
const folderElement = fixture.debugElement.query(By.css('[data-testid="folder"]'));
|
||||
const itemDetailsList = fixture.debugElement.queryAll(
|
||||
By.css('[data-testid="item-details-list"]'),
|
||||
);
|
||||
|
||||
expect(itemName.nativeElement.value).toBe(cipher.name);
|
||||
expect(owner.nativeElement.textContent.trim()).toBe(organization.name);
|
||||
expect(collections.map((c) => c.nativeElement.textContent.trim())).toEqual([
|
||||
collection.name,
|
||||
collection2.name,
|
||||
]);
|
||||
expect(folderElement.nativeElement.textContent.trim()).toBe(folder.name);
|
||||
expect(itemName.nativeElement.textContent.trim()).toEqual(cipher.name);
|
||||
expect(itemDetailsList.length).toBe(4); // Organization, Collection, Collection2, Folder
|
||||
expect(itemDetailsList[0].nativeElement.textContent.trim()).toContain(organization.name);
|
||||
expect(itemDetailsList[1].nativeElement.textContent.trim()).toContain(collection.name);
|
||||
expect(itemDetailsList[2].nativeElement.textContent.trim()).toContain(collection2.name);
|
||||
expect(itemDetailsList[3].nativeElement.textContent.trim()).toContain(folder.name);
|
||||
});
|
||||
|
||||
it("does not render owner when `hideOwner` is true", () => {
|
||||
component.hideOwner = true;
|
||||
componentRef.setInput("hideOwner", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const owner = fixture.debugElement.query(By.css('[data-testid="owner"]'));
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input } from "@angular/core";
|
||||
|
||||
import { Component, computed, input, signal } from "@angular/core";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { fromEvent, map, startWith } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import {
|
||||
ButtonLinkDirective,
|
||||
CardComponent,
|
||||
FormFieldModule,
|
||||
SectionHeaderComponent,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
@@ -26,20 +29,96 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
CardComponent,
|
||||
SectionHeaderComponent,
|
||||
TypographyModule,
|
||||
OrgIconDirective,
|
||||
FormFieldModule,
|
||||
ButtonLinkDirective,
|
||||
],
|
||||
})
|
||||
export class ItemDetailsV2Component {
|
||||
@Input() cipher: CipherView;
|
||||
@Input() organization?: Organization;
|
||||
@Input() collections?: CollectionView[];
|
||||
@Input() folder?: FolderView;
|
||||
@Input() hideOwner?: boolean = false;
|
||||
hideOwner = input<boolean>(false);
|
||||
cipher = input.required<CipherView>();
|
||||
organization = input<Organization | undefined>();
|
||||
folder = input<FolderView | undefined>();
|
||||
collections = input<CollectionView[] | undefined>();
|
||||
showAllDetails = signal(false);
|
||||
|
||||
get showOwnership() {
|
||||
return this.cipher.organizationId && this.organization && !this.hideOwner;
|
||||
showOwnership = computed(() => {
|
||||
return this.cipher().organizationId && this.organization() && !this.hideOwner();
|
||||
});
|
||||
|
||||
hasSmallScreen = toSignal(
|
||||
fromEvent(window, "resize").pipe(
|
||||
map(() => window.innerWidth),
|
||||
startWith(window.innerWidth),
|
||||
map((width) => width < 681),
|
||||
),
|
||||
);
|
||||
|
||||
// Array to hold all details of item. Organization, Collections, and Folder
|
||||
allItems = computed(() => {
|
||||
let items: any[] = [];
|
||||
if (this.showOwnership() && this.organization()) {
|
||||
items.push(this.organization());
|
||||
}
|
||||
if (this.cipher().collectionIds?.length > 0 && this.collections()) {
|
||||
items = [...items, ...this.collections()];
|
||||
}
|
||||
if (this.cipher().folderId && this.folder()) {
|
||||
items.push(this.folder());
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
showItems = computed(() => {
|
||||
if (
|
||||
this.hasSmallScreen() &&
|
||||
this.allItems().length > 2 &&
|
||||
!this.showAllDetails() &&
|
||||
this.cipher().collectionIds?.length > 1
|
||||
) {
|
||||
return this.allItems().slice(0, 2);
|
||||
} else {
|
||||
return this.allItems();
|
||||
}
|
||||
});
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
toggleShowMore() {
|
||||
this.showAllDetails.update((value) => !value);
|
||||
}
|
||||
|
||||
getAriaLabel(item: Organization | CollectionView | FolderView): string {
|
||||
if (item instanceof Organization) {
|
||||
return this.i18nService.t("owner") + item.name;
|
||||
} else if (item instanceof CollectionView) {
|
||||
return this.i18nService.t("collection") + item.name;
|
||||
} else if (item instanceof FolderView) {
|
||||
return this.i18nService.t("folder") + item.name;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
getIconClass(item: Organization | CollectionView | FolderView): string {
|
||||
if (item instanceof CollectionView) {
|
||||
return "bwi-collection-shared";
|
||||
} else if (item instanceof FolderView) {
|
||||
return "bwi-folder";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
getItemTitle(item: Organization | CollectionView | FolderView): string {
|
||||
if (item instanceof CollectionView) {
|
||||
return this.i18nService.t("collection");
|
||||
} else if (item instanceof FolderView) {
|
||||
return this.i18nService.t("folder");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
isOrgIcon(item: Organization | CollectionView | FolderView): boolean {
|
||||
return item instanceof Organization;
|
||||
}
|
||||
}
|
||||
|
||||
129
package-lock.json
generated
129
package-lock.json
generated
@@ -120,7 +120,7 @@
|
||||
"@typescript-eslint/rule-tester": "8.31.0",
|
||||
"@typescript-eslint/utils": "8.31.0",
|
||||
"@webcomponents/custom-elements": "1.6.0",
|
||||
"@yao-pkg/pkg": "5.16.1",
|
||||
"@yao-pkg/pkg": "6.5.1",
|
||||
"angular-eslint": "19.6.0",
|
||||
"autoprefixer": "10.4.21",
|
||||
"axe-playwright": "2.1.0",
|
||||
@@ -192,7 +192,7 @@
|
||||
},
|
||||
"apps/browser": {
|
||||
"name": "@bitwarden/browser",
|
||||
"version": "2025.7.0"
|
||||
"version": "2025.7.1"
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@bitwarden/cli",
|
||||
@@ -229,10 +229,6 @@
|
||||
},
|
||||
"bin": {
|
||||
"bw": "build/bw.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "~20",
|
||||
"npm": "~10"
|
||||
}
|
||||
},
|
||||
"apps/cli/node_modules/define-lazy-prop": {
|
||||
@@ -14132,34 +14128,39 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@yao-pkg/pkg": {
|
||||
"version": "5.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-5.16.1.tgz",
|
||||
"integrity": "sha512-crUlnNFSReFNFuXDc4f3X2ignkFlc9kmEG7Bp/mJMA1jYyqR0lqjZGLgrSDYTYiNsYud8AzgA3RY1DrMdcUZWg==",
|
||||
"version": "6.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.5.1.tgz",
|
||||
"integrity": "sha512-z6XlySYfnqfm1AfVlBN8A3yeAQniIwL7TKQfDCGsswYSVYLt2snbRefQYsfQQ3pw5lVXrZdLqgTjzaqID9IkWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.23.0",
|
||||
"@babel/parser": "^7.23.0",
|
||||
"@babel/types": "^7.23.0",
|
||||
"@yao-pkg/pkg-fetch": "3.5.16",
|
||||
"@yao-pkg/pkg-fetch": "3.5.23",
|
||||
"into-stream": "^6.0.0",
|
||||
"minimist": "^1.2.6",
|
||||
"multistream": "^4.1.0",
|
||||
"picocolors": "^1.1.0",
|
||||
"picomatch": "^4.0.2",
|
||||
"prebuild-install": "^7.1.1",
|
||||
"resolve": "^1.22.0",
|
||||
"resolve": "^1.22.10",
|
||||
"stream-meter": "^1.0.4",
|
||||
"tinyglobby": "^0.2.9"
|
||||
"tar": "^7.4.3",
|
||||
"tinyglobby": "^0.2.11",
|
||||
"unzipper": "^0.12.3"
|
||||
},
|
||||
"bin": {
|
||||
"pkg": "lib-es5/bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@yao-pkg/pkg-fetch": {
|
||||
"version": "3.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.16.tgz",
|
||||
"integrity": "sha512-mCnZvZz0/Ylpk4TGyt34pqWJyBGYJM8c3dPoMRV8Knodv2QhcYS4iXb5kB/JNWkrRtCKukGZIKkMLXZ3TQlzPg==",
|
||||
"version": "3.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.23.tgz",
|
||||
"integrity": "sha512-rn45sqVQSkcJNSBdTnYze3n+kyub4CN8aiWYlPgA9yp9FZeEF+BlpL68kSIm3HaVuANniF+7RBMH5DkC4zlHZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -14261,6 +14262,73 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@yao-pkg/pkg/node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@yao-pkg/pkg/node_modules/minizlib": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
|
||||
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@yao-pkg/pkg/node_modules/mkdirp": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
|
||||
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mkdirp": "dist/cjs/src/bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@yao-pkg/pkg/node_modules/tar": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
|
||||
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.0.1",
|
||||
"mkdirp": "^3.0.1",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@yao-pkg/pkg/node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
|
||||
@@ -15769,6 +15837,13 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/bluebird": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||
@@ -18881,6 +18956,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/duplexer2": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@@ -37611,6 +37696,20 @@
|
||||
"@unrs/resolver-binding-win32-x64-msvc": "1.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/unzipper": {
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
|
||||
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bluebird": "~3.7.2",
|
||||
"duplexer2": "~0.1.4",
|
||||
"fs-extra": "^11.2.0",
|
||||
"graceful-fs": "^4.2.2",
|
||||
"node-int64": "^0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
"@typescript-eslint/rule-tester": "8.31.0",
|
||||
"@typescript-eslint/utils": "8.31.0",
|
||||
"@webcomponents/custom-elements": "1.6.0",
|
||||
"@yao-pkg/pkg": "5.16.1",
|
||||
"@yao-pkg/pkg": "6.5.1",
|
||||
"angular-eslint": "19.6.0",
|
||||
"autoprefixer": "10.4.21",
|
||||
"axe-playwright": "2.1.0",
|
||||
|
||||
Reference in New Issue
Block a user