1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-04 18:53:20 +00:00

Merge remote-tracking branch 'origin/main' into autofill/PM-25072-autofill-password-potterybarn

This commit is contained in:
Jeffrey Holland
2025-09-16 16:20:17 +02:00
30 changed files with 454 additions and 982 deletions

View File

@@ -97,7 +97,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Publish to Chromatic
uses: chromaui/action@e8cc4c31775280b175a3c440076c00d19a9014d7 # v11.28.2
uses: chromaui/action@d0795df816d05c4a89c80295303970fddd247cce # v13.1.4
with:
token: ${{ secrets.GITHUB_TOKEN }}
projectToken: ${{ steps.get-kv-secrets.outputs.CHROMATIC-PROJECT-TOKEN }}

View File

@@ -183,6 +183,7 @@ jobs:
npm:
name: Publish NPM
environment: CLI - NPM
runs-on: ubuntu-22.04
needs: setup
permissions:
@@ -195,23 +196,20 @@ jobs:
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get Node version
id: retrieve-node-version
run: |
NODE_NVMRC=$(cat .nvmrc)
NODE_VERSION=${NODE_NVMRC/v/''}
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "npm-api-key"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
npm-version: "11.5.1" # FIXME: npm 11.5.1 or later is required to publish w/ OIDC; move version management to somewhere maintainable by automation
registry-url: "https://registry.npmjs.org/"
- name: Download and set up artifact
run: |
@@ -219,19 +217,9 @@ jobs:
wget https://github.com/bitwarden/clients/releases/download/cli-v${{ env._PKG_VERSION }}/bitwarden-cli-${{ env._PKG_VERSION }}-npm-build.zip
unzip bitwarden-cli-${{ env._PKG_VERSION }}-npm-build.zip -d build
- name: Setup NPM
run: |
echo 'registry="https://registry.npmjs.org/"' > ./.npmrc
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ./.npmrc
env:
NPM_TOKEN: ${{ steps.retrieve-secrets.outputs.npm-api-key }}
- name: Install Husky
run: npm install -g husky
- name: Publish NPM
if: ${{ inputs.publish_type != 'Dry Run' }}
run: npm publish --access public --regsitry=https://registry.npmjs.org/ --userconfig=./.npmrc
run: npm publish --access public
update-deployment:
name: Update Deployment Status

View File

@@ -43,17 +43,13 @@ describe("AuthPopoutWindow", () => {
singleActionKey: AuthPopoutType.unlockExtension,
senderWindowId: 1,
});
expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened", {
skipNotification: false,
});
expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened", {});
});
it("sends an indication that the presenting the notification bar for unlocking the extension should be skipped", async () => {
await openUnlockPopout(senderTab, true);
it("sends the bgUnlockPopoutOpened message", async () => {
await openUnlockPopout(senderTab);
expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened", {
skipNotification: true,
});
expect(sendMessageDataSpy).toHaveBeenCalledWith(senderTab, "bgUnlockPopoutOpened", {});
});
it("closes any existing popup window types that are open to the unlock extension route", async () => {

View File

@@ -20,9 +20,8 @@ const extensionUnlockUrls = new Set([
* Opens a window that facilitates unlocking / logging into the extension.
*
* @param senderTab - Used to determine the windowId of the sender.
* @param skipNotification - Used to determine whether to show the unlock notification.
*/
async function openUnlockPopout(senderTab: chrome.tabs.Tab, skipNotification = false) {
async function openUnlockPopout(senderTab: chrome.tabs.Tab) {
const existingPopoutWindowTabs = await BrowserApi.tabsQuery({ windowType: "popup" });
existingPopoutWindowTabs.forEach((tab) => {
if (extensionUnlockUrls.has(tab.url)) {
@@ -36,7 +35,7 @@ async function openUnlockPopout(senderTab: chrome.tabs.Tab, skipNotification = f
singleActionKey: AuthPopoutType.unlockExtension,
senderWindowId: senderTab.windowId,
});
await BrowserApi.tabSendMessageData(senderTab, "bgUnlockPopoutOpened", { skipNotification });
await BrowserApi.tabSendMessageData(senderTab, "bgUnlockPopoutOpened", {});
}
/**

View File

@@ -141,7 +141,6 @@ type NotificationBackgroundExtensionMessageHandlers = {
sender,
}: BackgroundOnMessageHandlerParams) => Promise<void>;
bgNeverSave: ({ sender }: BackgroundSenderParam) => Promise<void>;
bgUnlockPopoutOpened: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgReopenUnlockPopout: ({ sender }: BackgroundSenderParam) => Promise<void>;
checkNotificationQueue: ({ sender }: BackgroundSenderParam) => Promise<void>;
collectPageDetailsResponse: ({ message }: BackgroundMessageParam) => Promise<void>;

View File

@@ -817,6 +817,7 @@ describe("NotificationBackground", () => {
reprompt: CipherRepromptType.None,
});
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
taskService.tasksEnabled$.mockImplementation(() => of(false));
sendMockExtensionMessage(message, sender);
await flushPromises();
@@ -865,7 +866,7 @@ describe("NotificationBackground", () => {
reprompt: CipherRepromptType.Password,
});
getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView);
taskService.tasksEnabled$.mockImplementation(() => of(false));
sendMockExtensionMessage(message, sender);
await flushPromises();
@@ -913,9 +914,6 @@ describe("NotificationBackground", () => {
taskService.pendingTasks$.mockImplementation(() =>
of([mockSecurityTask, mockSecurityTask2]),
);
jest
.spyOn(notificationBackground as any, "getNotificationFlag")
.mockResolvedValueOnce(true);
jest.spyOn(notificationBackground as any, "getOrgData").mockResolvedValueOnce([
{
id: mockOrgId,
@@ -1372,74 +1370,6 @@ describe("NotificationBackground", () => {
});
});
describe("bgUnlockPopoutOpened message handler", () => {
let pushUnlockVaultToQueueSpy: jest.SpyInstance;
beforeEach(() => {
pushUnlockVaultToQueueSpy = jest.spyOn(
notificationBackground as any,
"pushUnlockVaultToQueue",
);
});
it("skips pushing the unlock vault message to the queue if the message indicates that the notification should be skipped", async () => {
const tabMock = createChromeTabMock();
const sender = mock<chrome.runtime.MessageSender>({ tab: tabMock });
const message: NotificationBackgroundExtensionMessage = {
command: "bgUnlockPopoutOpened",
data: { skipNotification: true },
};
sendMockExtensionMessage(message, sender);
await flushPromises();
expect(pushUnlockVaultToQueueSpy).not.toHaveBeenCalled();
});
it("skips pushing the unlock vault message to the queue if the auth status is not `Locked`", async () => {
const tabMock = createChromeTabMock();
const sender = mock<chrome.runtime.MessageSender>({ tab: tabMock });
const message: NotificationBackgroundExtensionMessage = {
command: "bgUnlockPopoutOpened",
};
activeAccountStatusMock$.next(AuthenticationStatus.LoggedOut);
sendMockExtensionMessage(message, sender);
await flushPromises();
expect(pushUnlockVaultToQueueSpy).not.toHaveBeenCalled();
});
it("skips pushing the unlock vault message to the queue if the notification queue already has an item", async () => {
const tabMock = createChromeTabMock();
const sender = mock<chrome.runtime.MessageSender>({ tab: tabMock });
const message: NotificationBackgroundExtensionMessage = {
command: "bgUnlockPopoutOpened",
};
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
notificationBackground["notificationQueue"] = [mock<AddLoginQueueMessage>()];
sendMockExtensionMessage(message, sender);
await flushPromises();
expect(pushUnlockVaultToQueueSpy).not.toHaveBeenCalled();
});
it("sends an unlock vault message to the queue if the user has a locked vault", async () => {
const tabMock = createChromeTabMock({ url: "https://example.com" });
const sender = mock<chrome.runtime.MessageSender>({ tab: tabMock });
const message: NotificationBackgroundExtensionMessage = {
command: "bgUnlockPopoutOpened",
};
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
sendMockExtensionMessage(message, sender);
await flushPromises();
expect(pushUnlockVaultToQueueSpy).toHaveBeenCalledWith("example.com", sender.tab);
});
});
describe("checkNotificationQueue", () => {
let doNotificationQueueCheckSpy: jest.SpyInstance;
let getTabFromCurrentWindowSpy: jest.SpyInstance;

View File

@@ -22,7 +22,6 @@ import {
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
import { ProductTierType } from "@bitwarden/common/billing/enums/product-tier-type.enum";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
@@ -67,7 +66,6 @@ import { TemporaryNotificationChangeLoginService } from "../services/notificatio
import {
AddChangePasswordNotificationQueueMessage,
AddLoginQueueMessage,
AddUnlockVaultQueueMessage,
AddLoginMessageData,
NotificationQueueMessageItem,
LockedVaultPendingNotificationsData,
@@ -116,12 +114,10 @@ export default class NotificationBackground {
bgSaveCipher: ({ message, sender }) => this.handleSaveCipherMessage(message, sender),
bgHandleReprompt: ({ message, sender }: any) =>
this.handleCipherUpdateRepromptResponse(message),
bgUnlockPopoutOpened: ({ message, sender }) => this.unlockVault(message, sender.tab),
checkNotificationQueue: ({ sender }) => this.checkNotificationQueue(sender.tab),
collectPageDetailsResponse: ({ message }) =>
this.handleCollectPageDetailsResponseMessage(message),
getWebVaultUrlForNotification: () => this.getWebVaultUrl(),
notificationRefreshFlagValue: () => this.getNotificationFlag(),
unlockCompleted: ({ message, sender }) => this.handleUnlockCompleted(message, sender),
};
@@ -351,15 +347,6 @@ export default class NotificationBackground {
return await firstValueFrom(this.configService.serverConfig$);
}
/**
* Gets the current value of the notification refresh feature flag
* @returns Promise<boolean> indicating if the feature is enabled
*/
async getNotificationFlag(): Promise<boolean> {
const flagValue = await this.configService.getFeatureFlag(FeatureFlag.NotificationRefresh);
return flagValue;
}
/**
* Gets the current authentication status of the user.
* @returns Promise<AuthenticationStatus> - The current authentication status of the user.
@@ -465,11 +452,6 @@ export default class NotificationBackground {
data: ModifyLoginCipherFormData,
tab: chrome.tabs.Tab,
): Promise<boolean> {
const flag = await this.getNotificationFlag();
if (!flag) {
return false;
}
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
@@ -683,34 +665,6 @@ export default class NotificationBackground {
});
}
/**
* Sets up a notification to unlock the vault when the user
* attempts to autofill a cipher while the vault is locked.
*
* @param message - Extension message, determines if the notification should be skipped
* @param tab - The tab that the message was sent from
*/
private async unlockVault(message: NotificationBackgroundExtensionMessage, tab: chrome.tabs.Tab) {
const notificationRefreshFlagEnabled = await this.getNotificationFlag();
if (message.data?.skipNotification) {
return;
}
if (notificationRefreshFlagEnabled) {
return;
}
const currentAuthStatus = await this.getAuthStatus();
if (currentAuthStatus !== AuthenticationStatus.Locked || this.notificationQueue.length) {
return;
}
const loginDomain = Utils.getDomain(tab.url);
if (loginDomain) {
await this.pushUnlockVaultToQueue(loginDomain, tab);
}
}
private async pushChangePasswordToQueue(
cipherId: string,
loginDomain: string,
@@ -734,20 +688,6 @@ export default class NotificationBackground {
await this.checkNotificationQueue(tab);
}
private async pushUnlockVaultToQueue(loginDomain: string, tab: chrome.tabs.Tab) {
this.removeTabFromNotificationQueue(tab);
const launchTimestamp = new Date().getTime();
const message: AddUnlockVaultQueueMessage = {
type: NotificationType.UnlockVault,
domain: loginDomain,
tab: tab,
launchTimestamp,
expires: new Date(launchTimestamp + 0.5 * 60000), // 30 seconds
wasVaultLocked: true,
};
await this.sendNotificationQueueMessage(tab, message);
}
/**
* Saves a cipher based on the message sent from the notification bar. If the vault
* is locked, the message will be added to the notification queue and the unlock
@@ -906,12 +846,11 @@ export default class NotificationBackground {
}
const cipher = await this.cipherService.encrypt(cipherView, userId);
const shouldGetTasks = await this.getNotificationFlag();
try {
if (!cipherView.edit) {
throw new Error("You do not have permission to edit this cipher.");
}
const tasks = shouldGetTasks ? await this.getSecurityTasks(userId) : [];
const tasks = await this.getSecurityTasks(userId);
const updatedCipherTask = tasks.find((task) => task.cipherId === cipherView?.id);
const cipherHasTask = !!updatedCipherTask?.id;

View File

@@ -3389,6 +3389,7 @@ describe("OverlayBackground", () => {
usePasskey: true,
portKey,
});
await flushPromises();
triggerWebRequestOnCompletedEvent(
mock<chrome.webRequest.WebResponseCacheDetails>({
statusCode: 200,

View File

@@ -1127,11 +1127,16 @@ export class OverlayBackground implements OverlayBackgroundInterface {
{ inlineMenuCipherId, usePasskey }: OverlayPortMessage,
{ sender }: chrome.runtime.Port,
) {
await BrowserApi.tabSendMessage(
sender.tab,
{ command: "collectPageDetails" },
{ frameId: this.focusedFieldData?.frameId },
);
const pageDetailsForTab = this.pageDetailsForTab[sender.tab.id];
if (!inlineMenuCipherId || !pageDetailsForTab?.size) {
return;
}
const cipher = this.inlineMenuCiphers.get(inlineMenuCipherId);
if (usePasskey && cipher.login?.hasFido2Credentials) {
await this.authenticatePasskeyCredential(
@@ -2102,7 +2107,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
"addToLockedVaultPendingNotifications",
retryMessage,
);
await this.openUnlockPopout(sender.tab, true);
await this.openUnlockPopout(sender.tab);
}
/**

View File

@@ -100,10 +100,19 @@ describe("ContentMessageHandler", () => {
});
it("forwards the message to the extension background if it is present in the forwardCommands list", () => {
sendMockExtensionMessage({ command: "bgUnlockPopoutOpened" });
const forwardCommands = [
"addToLockedVaultPendingNotifications",
"unlockCompleted",
"addedCipher",
];
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(sendMessageSpy).toHaveBeenCalledWith({ command: "bgUnlockPopoutOpened" });
forwardCommands.forEach((command) => {
sendMockExtensionMessage({ command });
expect(sendMessageSpy).toHaveBeenCalledWith({ command });
});
expect(sendMessageSpy).toHaveBeenCalledTimes(forwardCommands.length);
});
});

View File

@@ -1,10 +1,8 @@
import { render } from "lit";
import { Theme, ThemeTypes } from "@bitwarden/common/platform/enums";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { AdjustNotificationBarMessageData } from "../background/abstractions/notification.background";
import { NotificationCipherData } from "../content/components/cipher/types";
import { CollectionView, I18n, OrgView } from "../content/components/common-types";
import { AtRiskNotification } from "../content/components/notification/at-risk-password/container";
@@ -12,8 +10,6 @@ import { NotificationConfirmationContainer } from "../content/components/notific
import { NotificationContainer } from "../content/components/notification/container";
import { selectedFolder as selectedFolderSignal } from "../content/components/signals/selected-folder";
import { selectedVault as selectedVaultSignal } from "../content/components/signals/selected-vault";
import { buildSvgDomElement } from "../utils";
import { circleCheckIcon } from "../utils/svg-icons";
import {
NotificationBarWindowMessageHandlers,
@@ -23,34 +19,18 @@ import {
NotificationTypes,
} from "./abstractions/notification-bar";
const logService = new ConsoleLogService(false);
let notificationBarIframeInitData: NotificationBarIframeInitData = {};
let windowMessageOrigin: string;
let useComponentBar = false;
const notificationBarWindowMessageHandlers: NotificationBarWindowMessageHandlers = {
initNotificationBar: ({ message }) => initNotificationBar(message),
saveCipherAttemptCompleted: ({ message }) =>
useComponentBar
? handleSaveCipherConfirmation(message)
: handleSaveCipherAttemptCompletedMessage(message),
saveCipherAttemptCompleted: ({ message }) => handleSaveCipherConfirmation(message),
};
globalThis.addEventListener("load", load);
function load() {
setupWindowMessageListener();
sendPlatformMessage({ command: "notificationRefreshFlagValue" }, (flagValue) => {
useComponentBar = flagValue;
applyNotificationBarStyle();
});
}
function applyNotificationBarStyle() {
if (!useComponentBar) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./bar.scss");
}
postMessageToParent({ command: "initNotificationBar" });
}
@@ -99,25 +79,6 @@ function getI18n() {
};
}
/**
* Attempts to locate an element by ID within a templates content and casts it to the specified type.
*
* @param templateElement - The template whose content will be searched for the element.
* @param elementId - The ID of the element being searched for.
* @returns The typed element if found, otherwise log error.
*
*/
const findElementById = <ElementType extends HTMLElement>(
templateElement: HTMLTemplateElement,
elementId: string,
): ElementType => {
const element = templateElement.content.getElementById(elementId);
if (!element) {
throw new Error(`Element with ID "${elementId}" not found in template.`);
}
return element as ElementType;
};
/**
* Returns the localized header message for the notification bar based on the notification type.
*
@@ -204,25 +165,6 @@ export function getNotificationTestId(
}[notificationType];
}
/**
* Sets the text content of an element identified by ID within a template's content.
*
* @param template - The template whose content will be searched for the element.
* @param elementId - The ID of the element whose text content is to be set.
* @param text - The text content to set for the specified element.
* @returns void
*
* This function attempts to locate an element by its ID within the content of a given HTML template.
* If the element is found, it updates the element's text content with the provided text.
* If the element is not found, the function does nothing, ensuring that the operation is safe and does not throw errors.
*/
function setElementText(template: HTMLTemplateElement, elementId: string, text: string): void {
const element = template.content.getElementById(elementId);
if (element) {
element.textContent = text;
}
}
async function initNotificationBar(message: NotificationBarWindowMessage) {
const { initData } = message;
if (!initData) {
@@ -232,189 +174,119 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
notificationBarIframeInitData = initData;
const {
isVaultLocked,
removeIndividualVault: personalVaultDisallowed, // renamed to avoid local method collision
removeIndividualVault: personalVaultDisallowed,
theme,
} = notificationBarIframeInitData;
const i18n = getI18n();
const resolvedTheme = getResolvedTheme(theme ?? ThemeTypes.Light);
if (useComponentBar) {
const resolvedType = resolveNotificationType(notificationBarIframeInitData);
const headerMessage = getNotificationHeaderMessage(i18n, resolvedType);
const notificationTestId = getNotificationTestId(resolvedType);
appendHeaderMessageToTitle(headerMessage);
const resolvedType = resolveNotificationType(notificationBarIframeInitData);
const headerMessage = getNotificationHeaderMessage(i18n, resolvedType);
const notificationTestId = getNotificationTestId(resolvedType);
appendHeaderMessageToTitle(headerMessage);
document.body.innerHTML = "";
// Current implementations utilize a require for scss files which creates the need to remove the node.
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((node) => node.remove());
document.body.innerHTML = "";
if (isVaultLocked) {
const notificationConfig = {
if (isVaultLocked) {
const notificationConfig = {
...notificationBarIframeInitData,
headerMessage,
type: resolvedType,
notificationTestId,
theme: resolvedTheme,
personalVaultIsAllowed: !personalVaultDisallowed,
handleCloseNotification,
handleEditOrUpdateAction,
i18n,
};
const handleSaveAction = () => {
sendSaveCipherMessage(true);
render(
NotificationContainer({
...notificationConfig,
handleSaveAction: () => {},
isLoading: true,
}),
document.body,
);
};
const UnlockNotification = NotificationContainer({ ...notificationConfig, handleSaveAction });
return render(UnlockNotification, document.body);
}
// Handle AtRiskPasswordNotification render
if (notificationBarIframeInitData.type === NotificationTypes.AtRiskPassword) {
return render(
AtRiskNotification({
...notificationBarIframeInitData,
type: notificationBarIframeInitData.type as NotificationType,
theme: resolvedTheme,
i18n,
notificationTestId,
params: initData.params,
handleCloseNotification,
}),
document.body,
);
}
// Default scenario: add or update password
const orgId = selectedVaultSignal.get();
await Promise.all([
new Promise<OrgView[]>((resolve) => sendPlatformMessage({ command: "bgGetOrgData" }, resolve)),
new Promise<FolderView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetFolderData" }, resolve),
),
new Promise<NotificationCipherData[]>((resolve) =>
sendPlatformMessage({ command: "bgGetDecryptedCiphers" }, resolve),
),
new Promise<CollectionView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetCollectionData", orgId }, resolve),
),
]).then(([organizations, folders, ciphers, collections]) => {
notificationBarIframeInitData = {
...notificationBarIframeInitData,
organizations,
folders,
ciphers,
collections,
};
// @TODO use context to avoid prop drilling
return render(
NotificationContainer({
...notificationBarIframeInitData,
headerMessage,
type: resolvedType,
notificationTestId,
theme: resolvedTheme,
notificationTestId,
personalVaultIsAllowed: !personalVaultDisallowed,
handleCloseNotification,
handleSaveAction,
handleEditOrUpdateAction,
i18n,
};
}),
document.body,
);
});
const handleSaveAction = () => {
sendSaveCipherMessage(true);
render(
NotificationContainer({
...notificationConfig,
handleSaveAction: () => {},
isLoading: true,
}),
document.body,
);
};
const UnlockNotification = NotificationContainer({ ...notificationConfig, handleSaveAction });
return render(UnlockNotification, document.body);
}
// Handle AtRiskPasswordNotification render
if (notificationBarIframeInitData.type === NotificationTypes.AtRiskPassword) {
return render(
AtRiskNotification({
...notificationBarIframeInitData,
type: notificationBarIframeInitData.type as NotificationType,
theme: resolvedTheme,
i18n,
notificationTestId,
params: initData.params,
handleCloseNotification,
}),
document.body,
);
}
// Default scenario: add or update password
const orgId = selectedVaultSignal.get();
await Promise.all([
new Promise<OrgView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetOrgData" }, resolve),
),
new Promise<FolderView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetFolderData" }, resolve),
),
new Promise<NotificationCipherData[]>((resolve) =>
sendPlatformMessage({ command: "bgGetDecryptedCiphers" }, resolve),
),
new Promise<CollectionView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetCollectionData", orgId }, resolve),
),
]).then(([organizations, folders, ciphers, collections]) => {
notificationBarIframeInitData = {
...notificationBarIframeInitData,
organizations,
folders,
ciphers,
collections,
};
// @TODO use context to avoid prop drilling
return render(
NotificationContainer({
...notificationBarIframeInitData,
headerMessage,
type: resolvedType,
theme: resolvedTheme,
notificationTestId,
personalVaultIsAllowed: !personalVaultDisallowed,
handleCloseNotification,
handleSaveAction,
handleEditOrUpdateAction,
i18n,
}),
document.body,
);
});
} else {
setNotificationBarTheme();
(document.getElementById("logo") as HTMLImageElement).src = isVaultLocked
? chrome.runtime.getURL("images/icon38_locked.png")
: chrome.runtime.getURL("images/icon38.png");
setupLogoLink(i18n.appName);
// i18n for "Add" template
const addTemplate = document.getElementById("template-add") as HTMLTemplateElement;
const neverButton = findElementById<HTMLButtonElement>(addTemplate, "never-save");
neverButton.textContent = i18n.never;
const selectFolder = findElementById<HTMLSelectElement>(addTemplate, "select-folder");
selectFolder.hidden = isVaultLocked || removeIndividualVault();
selectFolder.setAttribute("aria-label", i18n.folder);
const addButton = findElementById<HTMLButtonElement>(addTemplate, "add-save");
addButton.textContent = i18n.notificationAddSave;
const addEditButton = findElementById<HTMLButtonElement>(addTemplate, "add-edit");
// If Remove Individual Vault policy applies, "Add" opens the edit tab, so we hide the Edit button
addEditButton.hidden = removeIndividualVault();
addEditButton.textContent = i18n.notificationEdit;
setElementText(addTemplate, "add-text", i18n.notificationAddDesc);
// i18n for "Change" (update password) template
const changeTemplate = document.getElementById("template-change") as HTMLTemplateElement;
const changeButton = findElementById<HTMLSelectElement>(changeTemplate, "change-save");
changeButton.textContent = i18n.notificationUpdate;
const changeEditButton = findElementById<HTMLButtonElement>(changeTemplate, "change-edit");
changeEditButton.textContent = i18n.notificationEdit;
setElementText(changeTemplate, "change-text", i18n.notificationChangeDesc);
// i18n for "Unlock" (unlock extension) template
const unlockTemplate = document.getElementById("template-unlock") as HTMLTemplateElement;
const unlockButton = findElementById<HTMLButtonElement>(unlockTemplate, "unlock-vault");
unlockButton.textContent = i18n.notificationUnlock;
setElementText(unlockTemplate, "unlock-text", i18n.notificationUnlockDesc);
// i18n for body content
const closeButton = document.getElementById("close-button");
if (closeButton) {
closeButton.title = i18n.close;
}
const notificationType = initData.type;
if (notificationType === "add") {
handleTypeAdd();
} else if (notificationType === "change") {
handleTypeChange();
} else if (notificationType === "unlock") {
handleTypeUnlock();
}
closeButton?.addEventListener("click", handleCloseNotification);
globalThis.addEventListener("resize", adjustHeight);
adjustHeight();
}
function handleEditOrUpdateAction(e: Event) {
const notificationType = initData?.type;
e.preventDefault();
notificationType === "add" ? sendSaveCipherMessage(true) : sendSaveCipherMessage(false);
}
}
function handleCloseNotification(e: Event) {
e.preventDefault();
sendPlatformMessage({
command: "bgCloseNotificationBar",
fadeOutNotification: true,
});
}
@@ -439,57 +311,6 @@ function handleSaveAction(e: Event) {
}
}
function handleTypeAdd() {
setContent(document.getElementById("template-add") as HTMLTemplateElement);
const addButton = document.getElementById("add-save");
addButton?.addEventListener("click", (e) => {
e.preventDefault();
// If Remove Individual Vault policy applies, "Add" opens the edit tab
sendSaveCipherMessage(removeIndividualVault(), getSelectedFolder());
});
if (removeIndividualVault()) {
// Everything past this point is only required if user has an individual vault
return;
}
const editButton = document.getElementById("add-edit");
editButton?.addEventListener("click", (e) => {
e.preventDefault();
sendSaveCipherMessage(true, getSelectedFolder());
});
const neverButton = document.getElementById("never-save");
neverButton?.addEventListener("click", (e) => {
e.preventDefault();
sendPlatformMessage({
command: "bgNeverSave",
});
});
loadFolderSelector();
}
function handleTypeChange() {
setContent(document.getElementById("template-change") as HTMLTemplateElement);
const changeButton = document.getElementById("change-save");
changeButton?.addEventListener("click", (e) => {
e.preventDefault();
sendSaveCipherMessage(false);
});
const editButton = document.getElementById("change-edit");
editButton?.addEventListener("click", (e) => {
e.preventDefault();
sendSaveCipherMessage(true);
});
}
function sendSaveCipherMessage(edit: boolean, folder?: string) {
sendPlatformMessage({
command: "bgSaveCipher",
@@ -498,36 +319,6 @@ function sendSaveCipherMessage(edit: boolean, folder?: string) {
});
}
function handleSaveCipherAttemptCompletedMessage(message: NotificationBarWindowMessage) {
const addSaveButtonContainers = document.querySelectorAll(".add-change-cipher-buttons");
const notificationBarOuterWrapper = document.getElementById("notification-bar-outer-wrapper");
if (message?.error) {
addSaveButtonContainers.forEach((element) => {
element.textContent = chrome.i18n.getMessage("saveCipherAttemptFailed");
element.classList.add("error-message");
notificationBarOuterWrapper?.classList.add("error-event");
});
adjustHeight();
logService.error(`Error encountered when saving credentials: ${message.error}`);
return;
}
const messageName =
notificationBarIframeInitData.type === "add" ? "passwordSaved" : "passwordUpdated";
addSaveButtonContainers.forEach((element) => {
element.textContent = chrome.i18n.getMessage(messageName);
element.prepend(buildSvgDomElement(circleCheckIcon));
element.classList.add("success-message");
notificationBarOuterWrapper?.classList.add("success-event");
});
adjustHeight();
globalThis.setTimeout(
() => sendPlatformMessage({ command: "bgCloseNotificationBar", fadeOutNotification: true }),
3000,
);
}
function openAddEditVaultItemPopout(
e: Event,
options: {
@@ -583,27 +374,6 @@ function handleSaveCipherConfirmation(message: NotificationBarWindowMessage) {
);
}
function handleTypeUnlock() {
setContent(document.getElementById("template-unlock") as HTMLTemplateElement);
const unlockButton = document.getElementById("unlock-vault");
unlockButton?.addEventListener("click", (e) => {
sendPlatformMessage({
command: "bgReopenUnlockPopout",
});
});
}
function setContent(template: HTMLTemplateElement) {
const content = document.getElementById("content");
while (content?.firstChild) {
content?.removeChild(content.firstChild);
}
const newElement = template.content.cloneNode(true) as HTMLElement;
content?.appendChild(newElement);
}
function sendPlatformMessage(
msg: Record<string, unknown>,
responseCallback?: (response: any) => void,
@@ -615,51 +385,10 @@ function sendPlatformMessage(
});
}
function loadFolderSelector() {
const populateFolderData = (folderData: FolderView[]) => {
const select = document.getElementById("select-folder");
if (!select) {
return;
}
if (!folderData?.length) {
select.appendChild(new Option(chrome.i18n.getMessage("noFoldersFound"), undefined, true));
select.setAttribute("disabled", "true");
return;
}
select.appendChild(new Option(chrome.i18n.getMessage("selectFolder"), undefined, true));
folderData.forEach((folder: FolderView) => {
// Select "No Folder" (id=null) folder by default
select.appendChild(new Option(folder.name, folder.id || "", false));
});
};
sendPlatformMessage({ command: "bgGetFolderData" }, populateFolderData);
}
function getSelectedFolder(): string {
return (document.getElementById("select-folder") as HTMLSelectElement).value;
}
function removeIndividualVault(): boolean {
return Boolean(notificationBarIframeInitData?.removeIndividualVault);
}
function adjustHeight() {
const body = document.querySelector("body");
if (!body) {
return;
}
const data: AdjustNotificationBarMessageData = {
height: body.scrollHeight,
};
sendPlatformMessage({
command: "bgAdjustNotificationBar",
data,
});
}
function setupWindowMessageListener() {
globalThis.addEventListener("message", handleWindowMessage);
}
@@ -682,18 +411,6 @@ function handleWindowMessage(event: MessageEvent) {
handler({ message });
}
function setupLogoLink(linkText: string) {
const logoLink = document.getElementById("logo-link") as HTMLAnchorElement;
logoLink.title = linkText;
const setWebVaultUrlLink = (webVaultURL: string) => {
const newVaultURL = webVaultURL && decodeURIComponent(webVaultURL);
if (newVaultURL && newVaultURL !== logoLink.href) {
logoLink.href = newVaultURL;
}
};
sendPlatformMessage({ command: "getWebVaultUrlForNotification" }, setWebVaultUrlLink);
}
function getTheme(globalThis: any, theme: NotificationBarIframeInitData["theme"]) {
if (theme === ThemeTypes.System) {
return globalThis.matchMedia("(prefers-color-scheme: dark)").matches
@@ -712,12 +429,6 @@ function getResolvedTheme(theme: Theme) {
return resolvedTheme;
}
function setNotificationBarTheme() {
const theme = getTheme(globalThis, notificationBarIframeInitData.theme);
document.documentElement.classList.add(`theme_${theme}`);
}
function postMessageToParent(message: NotificationBarWindowMessage) {
globalThis.parent.postMessage(message, windowMessageOrigin || "*");
}

View File

@@ -3,7 +3,7 @@
exports[`OverlayNotificationsContentService opening the notification bar creates the notification bar elements and appends them to the body 1`] = `
<div
id="bit-notification-bar"
style="height: 82px !important; width: 430px !important; max-width: calc(100% - 20px) !important; min-height: initial !important; top: 10px !important; right: 10px !important; padding: 0px !important; position: fixed !important; z-index: 2147483647 !important; visibility: visible !important; border-radius: 4px !important; background-color: transparent !important; overflow: hidden !important; transition: box-shadow 0.15s ease !important; transition-delay: 0.15s;"
style="height: 400px !important; width: 430px !important; max-width: calc(100% - 20px) !important; min-height: initial !important; top: 10px !important; right: 0px !important; padding: 0px !important; position: fixed !important; z-index: 2147483647 !important; visibility: visible !important; border-radius: 4px !important; background-color: transparent !important; overflow: hidden !important; transition: box-shadow 0.15s ease !important; transition-delay: 0.15s;"
>
<iframe
id="bit-notification-bar-iframe"

View File

@@ -1,11 +1,11 @@
import { mock, MockProxy } from "jest-mock-extended";
import AutofillInit from "../../../content/autofill-init";
import { NotificationType } from "../../../enums/notification-type.enum";
import { DomQueryService } from "../../../services/abstractions/dom-query.service";
import DomElementVisibilityService from "../../../services/dom-element-visibility.service";
import { flushPromises, sendMockExtensionMessage } from "../../../spec/testing-utils";
import * as utils from "../../../utils";
import { sendExtensionMessage } from "../../../utils";
import { NotificationTypeData } from "../abstractions/overlay-notifications-content.service";
import { OverlayNotificationsContentService } from "./overlay-notifications-content.service";
@@ -19,11 +19,7 @@ describe("OverlayNotificationsContentService", () => {
beforeEach(() => {
jest.useFakeTimers();
jest
.spyOn(utils, "sendExtensionMessage")
.mockImplementation((command: string) =>
Promise.resolve(command === "notificationRefreshFlagValue" ? false : true),
);
jest.spyOn(utils, "sendExtensionMessage").mockImplementation(jest.fn());
domQueryService = mock<DomQueryService>();
domElementVisibilityService = new DomElementVisibilityService();
overlayNotificationsContentService = new OverlayNotificationsContentService();
@@ -51,37 +47,6 @@ describe("OverlayNotificationsContentService", () => {
expect(bodyAppendChildSpy).not.toHaveBeenCalled();
});
it("applies correct styles when notificationRefreshFlag is true", async () => {
(sendExtensionMessage as jest.Mock).mockResolvedValue(true);
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
typeData: mock<NotificationTypeData>(),
},
});
await flushPromises();
const barElement = overlayNotificationsContentService["notificationBarElement"]!;
expect(barElement.style.height).toBe("400px");
expect(barElement.style.right).toBe("0px");
});
it("applies correct styles when notificationRefreshFlag is false", async () => {
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
typeData: mock<NotificationTypeData>(),
},
});
await flushPromises();
const barElement = overlayNotificationsContentService["notificationBarElement"]!;
expect(barElement.style.height).toBe("82px");
expect(barElement.style.right).toBe("10px");
});
it("closes the notification bar if the notification bar type has changed", async () => {
overlayNotificationsContentService["currentNotificationBarType"] = "add";
const closeNotificationBarSpy = jest.spyOn(
@@ -92,7 +57,7 @@ describe("OverlayNotificationsContentService", () => {
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
type: NotificationType.ChangePassword,
typeData: mock<NotificationTypeData>(),
},
});
@@ -105,7 +70,7 @@ describe("OverlayNotificationsContentService", () => {
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
type: NotificationType.ChangePassword,
typeData: mock<NotificationTypeData>(),
},
});
@@ -118,7 +83,7 @@ describe("OverlayNotificationsContentService", () => {
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
type: NotificationType.ChangePassword,
typeData: mock<NotificationTypeData>({
launchTimestamp: Date.now(),
}),
@@ -135,7 +100,7 @@ describe("OverlayNotificationsContentService", () => {
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
type: NotificationType.ChangePassword,
typeData: mock<NotificationTypeData>(),
},
});
@@ -154,7 +119,7 @@ describe("OverlayNotificationsContentService", () => {
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
type: NotificationType.ChangePassword,
typeData: mock<NotificationTypeData>(),
},
});
@@ -211,8 +176,21 @@ describe("OverlayNotificationsContentService", () => {
).toBe("0");
jest.advanceTimersByTime(150);
});
expect(sendExtensionMessage).toHaveBeenCalledWith("bgRemoveTabFromNotificationQueue");
it("triggers a fadeout of the notification bar and removes from the notification queue", () => {
sendMockExtensionMessage({
command: "closeNotificationBar",
data: { fadeOutNotification: true, type: NotificationType.ChangePassword },
});
expect(
overlayNotificationsContentService["notificationBarIframeElement"]?.style.opacity,
).toBe("0");
jest.advanceTimersByTime(150);
expect(utils.sendExtensionMessage).toHaveBeenCalledWith("bgRemoveTabFromNotificationQueue");
});
it("closes the notification bar without a fadeout", () => {
@@ -232,7 +210,7 @@ describe("OverlayNotificationsContentService", () => {
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
type: NotificationType.ChangePassword,
typeData: mock<NotificationTypeData>(),
},
});
@@ -256,7 +234,7 @@ describe("OverlayNotificationsContentService", () => {
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
type: NotificationType.ChangePassword,
typeData: mock<NotificationTypeData>(),
},
});
@@ -286,7 +264,7 @@ describe("OverlayNotificationsContentService", () => {
sendMockExtensionMessage({
command: "openNotificationBar",
data: {
type: "change",
type: NotificationType.ChangePassword,
typeData: mock<NotificationTypeData>(),
},
});

View File

@@ -20,33 +20,25 @@ export class OverlayNotificationsContentService
private notificationBarElement: HTMLElement | null = null;
private notificationBarIframeElement: HTMLIFrameElement | null = null;
private currentNotificationBarType: NotificationType | null = null;
private notificationRefreshFlag: boolean = false;
private getNotificationBarStyles(): Partial<CSSStyleDeclaration> {
const styles: Partial<CSSStyleDeclaration> = {
height: "400px",
width: "430px",
maxWidth: "calc(100% - 20px)",
minHeight: "initial",
top: "10px",
right: "0px",
padding: "0",
position: "fixed",
zIndex: "2147483647",
visibility: "visible",
borderRadius: "4px",
border: "none",
backgroundColor: "transparent",
overflow: "hidden",
transition: "box-shadow 0.15s ease",
transitionDelay: "0.15s",
};
private notificationBarContainerStyles: Partial<CSSStyleDeclaration> = {
height: "400px",
width: "430px",
maxWidth: "calc(100% - 20px)",
minHeight: "initial",
top: "10px",
right: "0px",
padding: "0",
position: "fixed",
zIndex: "2147483647",
visibility: "visible",
borderRadius: "4px",
border: "none",
backgroundColor: "transparent",
overflow: "hidden",
transition: "box-shadow 0.15s ease",
transitionDelay: "0.15s",
};
if (!this.notificationRefreshFlag) {
styles.height = "82px";
styles.right = "10px";
}
return styles;
}
private notificationBarIframeElementStyles: Partial<CSSStyleDeclaration> = {
width: "100%",
height: "100%",
@@ -57,6 +49,7 @@ export class OverlayNotificationsContentService
borderRadius: "4px",
colorScheme: "auto",
};
private readonly extensionMessageHandlers: OverlayNotificationsExtensionMessageHandlers = {
openNotificationBar: ({ message }) => this.handleOpenNotificationBarMessage(message),
closeNotificationBar: ({ message }) => this.handleCloseNotificationBarMessage(message),
@@ -91,6 +84,7 @@ export class OverlayNotificationsContentService
if (this.currentNotificationBarType && type !== this.currentNotificationBarType) {
this.closeNotificationBar();
}
const initData = {
type: type as NotificationType,
isVaultLocked: typeData.isVaultLocked,
@@ -101,10 +95,6 @@ export class OverlayNotificationsContentService
params,
};
await sendExtensionMessage("notificationRefreshFlagValue").then((notificationRefreshFlag) => {
this.notificationRefreshFlag = !!notificationRefreshFlag;
});
if (globalThis.document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => this.openNotificationBar(initData));
return;
@@ -215,14 +205,8 @@ export class OverlayNotificationsContentService
{ transform: "translateX(0)", opacity: "1" },
true,
);
if (!this.notificationRefreshFlag) {
setElementStyles(
this.notificationBarElement,
{ boxShadow: "2px 4px 6px 0px #0000001A" },
true,
);
}
this.notificationBarIframeElement.removeEventListener(
this.notificationBarIframeElement?.removeEventListener(
EVENTS.LOAD,
this.handleNotificationBarIframeOnLoad,
);
@@ -236,7 +220,7 @@ export class OverlayNotificationsContentService
this.notificationBarElement = globalThis.document.createElement("div");
this.notificationBarElement.id = "bit-notification-bar";
setElementStyles(this.notificationBarElement, this.getNotificationBarStyles(), true);
setElementStyles(this.notificationBarElement, this.notificationBarContainerStyles, true);
this.notificationBarElement.appendChild(this.notificationBarIframeElement);
}

View File

@@ -25,7 +25,6 @@ export interface AutoFillOptions {
tab: chrome.tabs.Tab;
skipUsernameOnlyFill?: boolean;
onlyEmptyFields?: boolean;
onlyVisibleFields?: boolean;
fillNewPassword?: boolean;
skipLastUsed?: boolean;
allowUntrustedIframe?: boolean;
@@ -43,7 +42,6 @@ export interface FormData {
export interface GenerateFillScriptOptions {
skipUsernameOnlyFill: boolean;
onlyEmptyFields: boolean;
onlyVisibleFields: boolean;
fillNewPassword: boolean;
allowTotpAutofill: boolean;
autoSubmitLogin: boolean;

View File

@@ -83,8 +83,8 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
unsetMostRecentlyFocusedField: () => this.unsetMostRecentlyFocusedField(),
checkIsMostRecentlyFocusedFieldWithinViewport: () =>
this.checkIsMostRecentlyFocusedFieldWithinViewport(),
bgUnlockPopoutOpened: () => this.blurMostRecentlyFocusedField(true),
bgVaultItemRepromptPopoutOpened: () => this.blurMostRecentlyFocusedField(true),
bgUnlockPopoutOpened: () => this.blurMostRecentlyFocusedField(true),
redirectAutofillInlineMenuFocusOut: ({ message }) =>
this.redirectInlineMenuFocusOut(message?.data?.direction),
getSubFrameOffsets: ({ message }) => this.getSubFrameOffsets(message),

View File

@@ -741,7 +741,6 @@ describe("AutofillService", () => {
{
skipUsernameOnlyFill: autofillOptions.skipUsernameOnlyFill || false,
onlyEmptyFields: autofillOptions.onlyEmptyFields || false,
onlyVisibleFields: autofillOptions.onlyVisibleFields || false,
fillNewPassword: autofillOptions.fillNewPassword || false,
allowTotpAutofill: autofillOptions.allowTotpAutofill || false,
autoSubmitLogin: autofillOptions.allowTotpAutofill || false,
@@ -1070,7 +1069,6 @@ describe("AutofillService", () => {
skipLastUsed: !fromCommand,
skipUsernameOnlyFill: !fromCommand,
onlyEmptyFields: !fromCommand,
onlyVisibleFields: !fromCommand,
fillNewPassword: fromCommand,
allowUntrustedIframe: fromCommand,
allowTotpAutofill: fromCommand,
@@ -1100,7 +1098,6 @@ describe("AutofillService", () => {
skipLastUsed: !fromCommand,
skipUsernameOnlyFill: !fromCommand,
onlyEmptyFields: !fromCommand,
onlyVisibleFields: !fromCommand,
fillNewPassword: fromCommand,
allowUntrustedIframe: fromCommand,
allowTotpAutofill: fromCommand,
@@ -1127,7 +1124,6 @@ describe("AutofillService", () => {
skipLastUsed: !fromCommand,
skipUsernameOnlyFill: !fromCommand,
onlyEmptyFields: !fromCommand,
onlyVisibleFields: !fromCommand,
fillNewPassword: fromCommand,
allowUntrustedIframe: fromCommand,
allowTotpAutofill: fromCommand,
@@ -1306,7 +1302,6 @@ describe("AutofillService", () => {
skipLastUsed: false,
skipUsernameOnlyFill: false,
onlyEmptyFields: false,
onlyVisibleFields: false,
fillNewPassword: false,
allowUntrustedIframe: true,
allowTotpAutofill: false,
@@ -1353,7 +1348,6 @@ describe("AutofillService", () => {
skipLastUsed: false,
skipUsernameOnlyFill: false,
onlyEmptyFields: false,
onlyVisibleFields: false,
fillNewPassword: false,
allowUntrustedIframe: true,
allowTotpAutofill: false,
@@ -1903,23 +1897,14 @@ describe("AutofillService", () => {
options,
);
expect(AutofillService.loadPasswordFields).toHaveBeenCalledTimes(2);
expect(AutofillService.loadPasswordFields).toHaveBeenNthCalledWith(
1,
expect(AutofillService.loadPasswordFields).toHaveBeenCalledTimes(1);
expect(AutofillService.loadPasswordFields).toHaveBeenCalledWith(
pageDetails,
false,
false,
options.onlyEmptyFields,
options.fillNewPassword,
);
expect(AutofillService.loadPasswordFields).toHaveBeenNthCalledWith(
2,
pageDetails,
true,
true,
options.onlyEmptyFields,
options.fillNewPassword,
);
});
describe("given a valid list of forms within the passed page details", () => {
@@ -1932,36 +1917,7 @@ describe("AutofillService", () => {
jest.spyOn(autofillService as any, "findTotpField");
});
it("will attempt to find a username field from hidden fields if no visible username fields are found", async () => {
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["findUsernameField"]).toHaveBeenCalledTimes(2);
expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith(
1,
pageDetails,
passwordField,
false,
false,
false,
);
expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith(
2,
pageDetails,
passwordField,
true,
true,
false,
);
});
it("will not attempt to find a username field from hidden fields if the passed options indicate only visible fields should be referenced", async () => {
options.onlyVisibleFields = true;
it("will attempt to find a username field from visible fields", async () => {
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
@@ -1970,25 +1926,16 @@ describe("AutofillService", () => {
);
expect(autofillService["findUsernameField"]).toHaveBeenCalledTimes(1);
expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith(
1,
expect(autofillService["findUsernameField"]).toHaveBeenCalledWith(
pageDetails,
passwordField,
false,
false,
false,
);
expect(autofillService["findUsernameField"]).not.toHaveBeenNthCalledWith(
2,
pageDetails,
passwordField,
true,
true,
false,
);
});
it("will attempt to find a totp field from hidden fields if no visible totp fields are found", async () => {
it("will attempt to find a totp field from visible fields", async () => {
options.allowTotpAutofill = true;
await autofillService["generateLoginFillScript"](
fillScript,
@@ -1997,53 +1944,14 @@ describe("AutofillService", () => {
options,
);
expect(autofillService["findTotpField"]).toHaveBeenCalledTimes(2);
expect(autofillService["findTotpField"]).toHaveBeenNthCalledWith(
1,
pageDetails,
passwordField,
false,
false,
false,
);
expect(autofillService["findTotpField"]).toHaveBeenNthCalledWith(
2,
pageDetails,
passwordField,
true,
true,
false,
);
});
it("will not attempt to find a totp field from hidden fields if the passed options indicate only visible fields should be referenced", async () => {
options.allowTotpAutofill = true;
options.onlyVisibleFields = true;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["findTotpField"]).toHaveBeenCalledTimes(1);
expect(autofillService["findTotpField"]).toHaveBeenNthCalledWith(
1,
expect(autofillService["findTotpField"]).toHaveBeenCalledWith(
pageDetails,
passwordField,
false,
false,
false,
);
expect(autofillService["findTotpField"]).not.toHaveBeenNthCalledWith(
2,
pageDetails,
passwordField,
true,
true,
false,
);
});
it("will not attempt to find a totp field from hidden fields if the passed options do not allow for TOTP values to be filled", async () => {
@@ -2085,7 +1993,7 @@ describe("AutofillService", () => {
);
});
it("will attempt to match a password field that does not contain a form to a username field that is not visible", async () => {
it("will not attempt to match a password field that does not contain a form to a username field that is not visible", async () => {
usernameField.viewable = false;
usernameField.readonly = true;
@@ -2096,54 +2004,14 @@ describe("AutofillService", () => {
options,
);
expect(autofillService["findUsernameField"]).toHaveBeenCalledTimes(2);
expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith(
1,
pageDetails,
passwordField,
false,
false,
true,
);
expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith(
2,
pageDetails,
passwordField,
true,
true,
true,
);
});
it("will not attempt to match a password field that does not contain a form to a username field that is not visible if the passed options indicate only visible fields", async () => {
usernameField.viewable = false;
usernameField.readonly = true;
options.onlyVisibleFields = true;
await autofillService["generateLoginFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["findUsernameField"]).toHaveBeenCalledTimes(1);
expect(autofillService["findUsernameField"]).toHaveBeenNthCalledWith(
1,
expect(autofillService["findUsernameField"]).toHaveBeenCalledWith(
pageDetails,
passwordField,
false,
false,
true,
);
expect(autofillService["findUsernameField"]).not.toHaveBeenNthCalledWith(
2,
pageDetails,
passwordField,
true,
true,
true,
);
});
it("will attempt to match a password field that does not contain a form to a TOTP field", async () => {
@@ -2166,8 +2034,7 @@ describe("AutofillService", () => {
);
});
it("will attempt to match a password field that does not contain a form to a TOTP field that is not visible", async () => {
options.onlyVisibleFields = false;
it("will not attempt to match a password field that does not contain a form to a TOTP field that is not visible", async () => {
options.allowTotpAutofill = true;
totpField.viewable = false;
totpField.readonly = true;
@@ -2179,7 +2046,7 @@ describe("AutofillService", () => {
options,
);
expect(autofillService["findTotpField"]).toHaveBeenCalledTimes(2);
expect(autofillService["findTotpField"]).toHaveBeenCalledTimes(1);
expect(autofillService["findTotpField"]).toHaveBeenNthCalledWith(
1,
pageDetails,
@@ -2188,14 +2055,6 @@ describe("AutofillService", () => {
false,
true,
);
expect(autofillService["findTotpField"]).toHaveBeenNthCalledWith(
2,
pageDetails,
passwordField,
true,
true,
true,
);
});
});

View File

@@ -437,7 +437,6 @@ export default class AutofillService implements AutofillServiceInterface {
const fillScript = await this.generateFillScript(pd.details, {
skipUsernameOnlyFill: options.skipUsernameOnlyFill || false,
onlyEmptyFields: options.onlyEmptyFields || false,
onlyVisibleFields: options.onlyVisibleFields || false,
fillNewPassword: options.fillNewPassword || false,
allowTotpAutofill: options.allowTotpAutofill || false,
autoSubmitLogin: options.autoSubmitLogin || false,
@@ -571,7 +570,6 @@ export default class AutofillService implements AutofillServiceInterface {
skipLastUsed: !fromCommand,
skipUsernameOnlyFill: !fromCommand,
onlyEmptyFields: !fromCommand,
onlyVisibleFields: !fromCommand,
fillNewPassword: fromCommand,
allowUntrustedIframe: fromCommand,
allowTotpAutofill: fromCommand,
@@ -676,7 +674,6 @@ export default class AutofillService implements AutofillServiceInterface {
skipLastUsed: !fromCommand,
skipUsernameOnlyFill: !fromCommand,
onlyEmptyFields: !fromCommand,
onlyVisibleFields: !fromCommand,
fillNewPassword: false,
allowUntrustedIframe: fromCommand,
allowTotpAutofill: false,
@@ -843,23 +840,13 @@ export default class AutofillService implements AutofillServiceInterface {
fillScript.untrustedIframe = await this.inUntrustedIframe(pageDetails.url, options);
let passwordFields = AutofillService.loadPasswordFields(
const passwordFields = AutofillService.loadPasswordFields(
pageDetails,
false,
false,
options.onlyEmptyFields,
options.fillNewPassword,
);
if (!passwordFields.length && !options.onlyVisibleFields) {
// not able to find any viewable password fields. maybe there are some "hidden" ones?
passwordFields = AutofillService.loadPasswordFields(
pageDetails,
true,
true,
options.onlyEmptyFields,
options.fillNewPassword,
);
}
for (const formKey in pageDetails.forms) {
// eslint-disable-next-line
@@ -874,11 +861,6 @@ export default class AutofillService implements AutofillServiceInterface {
if (login.username) {
username = this.findUsernameField(pageDetails, pf, false, false, false);
if (!username && !options.onlyVisibleFields) {
// not able to find any viewable username fields. maybe there are some "hidden" ones?
username = this.findUsernameField(pageDetails, pf, true, true, false);
}
if (username) {
usernames.push(username);
}
@@ -887,11 +869,6 @@ export default class AutofillService implements AutofillServiceInterface {
if (options.allowTotpAutofill && login.totp) {
totp = this.findTotpField(pageDetails, pf, false, false, false);
if (!totp && !options.onlyVisibleFields) {
// not able to find any viewable totp fields. maybe there are some "hidden" ones?
totp = this.findTotpField(pageDetails, pf, true, true, false);
}
if (totp) {
totps.push(totp);
}
@@ -909,11 +886,6 @@ export default class AutofillService implements AutofillServiceInterface {
if (login.username && pf.elementNumber > 0) {
username = this.findUsernameField(pageDetails, pf, false, false, true);
if (!username && !options.onlyVisibleFields) {
// not able to find any viewable username fields. maybe there are some "hidden" ones?
username = this.findUsernameField(pageDetails, pf, true, true, true);
}
if (username) {
usernames.push(username);
}
@@ -922,11 +894,6 @@ export default class AutofillService implements AutofillServiceInterface {
if (options.allowTotpAutofill && login.totp && pf.elementNumber > 0) {
totp = this.findTotpField(pageDetails, pf, false, false, true);
if (!totp && !options.onlyVisibleFields) {
// not able to find any viewable username fields. maybe there are some "hidden" ones?
totp = this.findTotpField(pageDetails, pf, true, true, true);
}
if (totp) {
totps.push(totp);
}

View File

@@ -114,7 +114,6 @@ export function createGenerateFillScriptOptionsMock(customFields = {}): Generate
return {
skipUsernameOnlyFill: false,
onlyEmptyFields: false,
onlyVisibleFields: false,
fillNewPassword: false,
allowTotpAutofill: false,
autoSubmitLogin: false,

View File

@@ -106,19 +106,21 @@
<app-vault-icon [cipher]="cipher"></app-vault-icon>
</div>
<span data-testid="item-name">{{ cipher.name }}</span>
<i
*ngIf="cipher.organizationId"
slot="default-trailing"
appOrgIcon
[tierType]="cipher.organization!.productTierType"
[size]="'small'"
[appA11yTitle]="orgIconTooltip(cipher)"
></i>
<i
*ngIf="CipherViewLikeUtils.hasAttachments(cipher)"
class="bwi bwi-paperclip bwi-sm"
[appA11yTitle]="'attachments' | i18n"
></i>
<div slot="default-trailing" class="tw-flex tw-gap-1.5">
<i
*ngIf="cipher.organizationId"
slot="default-trailing"
appOrgIcon
[tierType]="cipher.organization!.productTierType"
[size]="'small'"
[appA11yTitle]="orgIconTooltip(cipher)"
></i>
<i
*ngIf="CipherViewLikeUtils.hasAttachments(cipher)"
class="bwi bwi-paperclip bwi-sm"
[appA11yTitle]="'attachments' | i18n"
></i>
</div>
<span slot="secondary">{{ CipherViewLikeUtils.subtitle(cipher) }}</span>
</button>

View File

@@ -0,0 +1 @@
export * from "./risk-insights-data-mappers";

View File

@@ -0,0 +1,144 @@
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
MemberDetailsFlat,
CipherHealthReportDetail,
CipherHealthReportUriDetail,
ApplicationHealthReportDetail,
} from "../models/password-health";
import { MemberCipherDetailsResponse } from "../response/member-cipher-details.response";
export function flattenMemberDetails(
memberCiphers: MemberCipherDetailsResponse[],
): MemberDetailsFlat[] {
return memberCiphers.flatMap((member) =>
member.cipherIds.map((cipherId) => ({
userGuid: member.userGuid,
userName: member.userName,
email: member.email,
cipherId,
})),
);
}
/**
* Trim the cipher uris down to get the password health application.
* The uri should only exist once after being trimmed. No duplication.
* Example:
* - Untrimmed Uris: https://gmail.com, gmail.com/login
* - Both would trim to gmail.com
* - The cipher trimmed uri list should only return on instance in the list
* @param cipher
* @returns distinct list of trimmed cipher uris
*/
export function getTrimmedCipherUris(cipher: CipherView): string[] {
const uris = cipher.login?.uris ?? [];
const uniqueDomains = new Set<string>();
uris.forEach((u: { uri: string }) => {
const domain = Utils.getDomain(u.uri) ?? u.uri;
uniqueDomains.add(domain);
});
return Array.from(uniqueDomains);
}
// Returns a deduplicated array of members by email
export function getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] {
const existingEmails = new Set<string>();
return orgMembers.filter((member) => {
if (existingEmails.has(member.email)) {
return false;
}
existingEmails.add(member.email);
return true;
});
}
/**
* Creates a flattened member details object
* @param userGuid User GUID
* @param userName User name
* @param email User email
* @param cipherId Cipher ID
* @returns Flattened member details
*/
export function getMemberDetailsFlat(
userGuid: string,
userName: string,
email: string,
cipherId: string,
): MemberDetailsFlat {
return {
userGuid: userGuid,
userName: userName,
email: email,
cipherId: cipherId,
};
}
/**
* Creates a flattened cipher details object for URI reporting
* @param detail Cipher health report detail
* @param uri Trimmed URI
* @returns Flattened cipher health details to URI
*/
export function getFlattenedCipherDetails(
detail: CipherHealthReportDetail,
uri: string,
): CipherHealthReportUriDetail {
return {
cipherId: detail.id,
reusedPasswordCount: detail.reusedPasswordCount,
weakPasswordDetail: detail.weakPasswordDetail,
exposedPasswordDetail: detail.exposedPasswordDetail,
cipherMembers: detail.cipherMembers,
trimmedUri: uri,
cipher: detail as CipherView,
};
}
/**
* Create the new application health report detail object with the details from the cipher health report uri detail object
* update or create the at risk values if the item is at risk.
* @param newUriDetail New cipher uri detail
* @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk
* @param existingUriDetail The previously processed Uri item
* @returns The new or updated application health report detail
*/
export function getApplicationReportDetail(
newUriDetail: CipherHealthReportUriDetail,
isAtRisk: boolean,
existingUriDetail?: ApplicationHealthReportDetail,
): ApplicationHealthReportDetail {
const reportDetail = {
applicationName: existingUriDetail
? existingUriDetail.applicationName
: newUriDetail.trimmedUri,
passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1,
memberDetails: existingUriDetail
? getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
: newUriDetail.cipherMembers,
atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [],
atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0,
atRiskCipherIds: existingUriDetail ? existingUriDetail.atRiskCipherIds : [],
atRiskMemberCount: existingUriDetail ? existingUriDetail.atRiskMemberDetails.length : 0,
cipherIds: existingUriDetail
? existingUriDetail.cipherIds.concat(newUriDetail.cipherId)
: [newUriDetail.cipherId],
} as ApplicationHealthReportDetail;
if (isAtRisk) {
reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1;
reportDetail.atRiskCipherIds.push(newUriDetail.cipherId);
reportDetail.atRiskMemberDetails = getUniqueMembers(
reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers),
);
reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length;
}
reportDetail.memberCount = reportDetail.memberDetails.length;
return reportDetail;
}

View File

@@ -22,6 +22,13 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
getApplicationReportDetail,
getFlattenedCipherDetails,
getMemberDetailsFlat,
getTrimmedCipherUris,
getUniqueMembers,
} from "../helpers/risk-insights-data-mappers";
import {
ApplicationHealthReportDetail,
ApplicationHealthReportSummary,
@@ -78,9 +85,7 @@ export class RiskInsightsReportService {
const results$ = zip(allCiphers$, memberCiphers$).pipe(
map(([allCiphers, memberCiphers]) => {
const details: MemberDetailsFlat[] = memberCiphers.flatMap((dtl) =>
dtl.cipherIds.map((c) =>
this.getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c),
),
dtl.cipherIds.map((c) => getMemberDetailsFlat(dtl.userGuid, dtl.userName, dtl.email, c)),
);
return [allCiphers, details] as const;
}),
@@ -185,10 +190,10 @@ export class RiskInsightsReportService {
reports: ApplicationHealthReportDetail[],
): ApplicationHealthReportSummary {
const totalMembers = reports.flatMap((x) => x.memberDetails);
const uniqueMembers = this.getUniqueMembers(totalMembers);
const uniqueMembers = getUniqueMembers(totalMembers);
const atRiskMembers = reports.flatMap((x) => x.atRiskMemberDetails);
const uniqueAtRiskMembers = this.getUniqueMembers(atRiskMembers);
const uniqueAtRiskMembers = getUniqueMembers(atRiskMembers);
return {
totalMemberCount: uniqueMembers.length,
@@ -317,7 +322,7 @@ export class RiskInsightsReportService {
const cipherMembers = memberDetails.filter((x) => x.cipherId === cipher.id);
// Trim uris to host name and create the cipher health report
const cipherTrimmedUris = this.getTrimmedCipherUris(cipher);
const cipherTrimmedUris = getTrimmedCipherUris(cipher);
const cipherHealth = {
...cipher,
weakPasswordDetail: weakPassword,
@@ -346,7 +351,7 @@ export class RiskInsightsReportService {
cipherHealthReport: CipherHealthReportDetail[],
): CipherHealthReportUriDetail[] {
return cipherHealthReport.flatMap((rpt) =>
rpt.trimmedUris.map((u) => this.getFlattenedCipherDetails(rpt, u)),
rpt.trimmedUris.map((u) => getFlattenedCipherDetails(rpt, u)),
);
}
@@ -369,9 +374,9 @@ export class RiskInsightsReportService {
}
if (index === -1) {
appReports.push(this.getApplicationReportDetail(uri, atRisk));
appReports.push(getApplicationReportDetail(uri, atRisk));
} else {
appReports[index] = this.getApplicationReportDetail(uri, atRisk, appReports[index]);
appReports[index] = getApplicationReportDetail(uri, atRisk, appReports[index]);
}
});
return appReports;
@@ -452,120 +457,6 @@ export class RiskInsightsReportService {
}
}
/**
* Create the new application health report detail object with the details from the cipher health report uri detail object
* update or create the at risk values if the item is at risk.
* @param newUriDetail New cipher uri detail
* @param isAtRisk If the cipher has a weak, exposed, or reused password it is at risk
* @param existingUriDetail The previously processed Uri item
* @returns The new or updated application health report detail
*/
private getApplicationReportDetail(
newUriDetail: CipherHealthReportUriDetail,
isAtRisk: boolean,
existingUriDetail?: ApplicationHealthReportDetail,
): ApplicationHealthReportDetail {
const reportDetail = {
applicationName: existingUriDetail
? existingUriDetail.applicationName
: newUriDetail.trimmedUri,
passwordCount: existingUriDetail ? existingUriDetail.passwordCount + 1 : 1,
memberDetails: existingUriDetail
? this.getUniqueMembers(existingUriDetail.memberDetails.concat(newUriDetail.cipherMembers))
: newUriDetail.cipherMembers,
atRiskMemberDetails: existingUriDetail ? existingUriDetail.atRiskMemberDetails : [],
atRiskPasswordCount: existingUriDetail ? existingUriDetail.atRiskPasswordCount : 0,
atRiskCipherIds: existingUriDetail ? existingUriDetail.atRiskCipherIds : [],
atRiskMemberCount: existingUriDetail ? existingUriDetail.atRiskMemberDetails.length : 0,
cipherIds: existingUriDetail
? existingUriDetail.cipherIds.concat(newUriDetail.cipherId)
: [newUriDetail.cipherId],
} as ApplicationHealthReportDetail;
if (isAtRisk) {
reportDetail.atRiskPasswordCount = reportDetail.atRiskPasswordCount + 1;
reportDetail.atRiskCipherIds.push(newUriDetail.cipherId);
reportDetail.atRiskMemberDetails = this.getUniqueMembers(
reportDetail.atRiskMemberDetails.concat(newUriDetail.cipherMembers),
);
reportDetail.atRiskMemberCount = reportDetail.atRiskMemberDetails.length;
}
reportDetail.memberCount = reportDetail.memberDetails.length;
return reportDetail;
}
/**
* Get a distinct array of members from a combined list. Input list may contain
* duplicate members.
* @param orgMembers Input list of members
* @returns Distinct array of members
*/
private getUniqueMembers(orgMembers: MemberDetailsFlat[]): MemberDetailsFlat[] {
const existingEmails = new Set<string>();
const distinctUsers = orgMembers.filter((member) => {
if (existingEmails.has(member.email)) {
return false;
}
existingEmails.add(member.email);
return true;
});
return distinctUsers;
}
private getFlattenedCipherDetails(
detail: CipherHealthReportDetail,
uri: string,
): CipherHealthReportUriDetail {
return {
cipherId: detail.id,
reusedPasswordCount: detail.reusedPasswordCount,
weakPasswordDetail: detail.weakPasswordDetail,
exposedPasswordDetail: detail.exposedPasswordDetail,
cipherMembers: detail.cipherMembers,
trimmedUri: uri,
cipher: detail as CipherView,
};
}
private getMemberDetailsFlat(
userGuid: string,
userName: string,
email: string,
cipherId: string,
): MemberDetailsFlat {
return {
userGuid: userGuid,
userName: userName,
email: email,
cipherId: cipherId,
};
}
/**
* Trim the cipher uris down to get the password health application.
* The uri should only exist once after being trimmed. No duplication.
* Example:
* - Untrimmed Uris: https://gmail.com, gmail.com/login
* - Both would trim to gmail.com
* - The cipher trimmed uri list should only return on instance in the list
* @param cipher
* @returns distinct list of trimmed cipher uris
*/
private getTrimmedCipherUris(cipher: CipherView): string[] {
const cipherUris: string[] = [];
const uris = cipher.login?.uris ?? [];
uris.map((u: { uri: string }) => {
const uri = Utils.getDomain(u.uri) ?? u.uri;
if (!cipherUris.includes(uri)) {
cipherUris.push(uri);
}
});
return cipherUris;
}
private isUserNameNotEmpty(c: CipherView): boolean {
return !Utils.isNullOrWhitespace(c.login.username);
}

View File

@@ -1,5 +1,11 @@
import { NgModule } from "@angular/core";
import { HecOrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/hec-organization-integration-service";
import { OrganizationIntegrationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-api.service";
import { OrganizationIntegrationConfigurationApiService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-configuration-api.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { safeProvider } from "@bitwarden/ui-common";
import { IntegrationCardComponent } from "../../dirt/organization-integrations/integration-card/integration-card.component";
import { IntegrationGridComponent } from "../../dirt/organization-integrations/integration-grid/integration-grid.component";
import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
@@ -14,6 +20,23 @@ import { IntegrationsComponent } from "./integrations.component";
IntegrationCardComponent,
IntegrationGridComponent,
],
providers: [
safeProvider({
provide: HecOrganizationIntegrationService,
useClass: HecOrganizationIntegrationService,
deps: [OrganizationIntegrationApiService, OrganizationIntegrationConfigurationApiService],
}),
safeProvider({
provide: OrganizationIntegrationApiService,
useClass: OrganizationIntegrationApiService,
deps: [ApiService],
}),
safeProvider({
provide: OrganizationIntegrationConfigurationApiService,
useClass: OrganizationIntegrationConfigurationApiService,
deps: [ApiService],
}),
],
declarations: [IntegrationsComponent],
})
export class IntegrationsModule {}

View File

@@ -18,7 +18,6 @@ export enum FeatureFlag {
PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals",
/* Autofill */
NotificationRefresh = "notification-refresh",
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
@@ -75,7 +74,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.CollectionVaultRefactor]: FALSE,
/* Autofill */
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,

View File

@@ -65,9 +65,10 @@
}"
>
<ng-content select="[bitDialogContent]"></ng-content>
<div #scrollBottom></div>
</div>
</div>
@let isScrollable = isScrollable$ | async;
@let showFooterBorder =
(!bodyHasScrolledFrom().top && isScrollable) || bodyHasScrolledFrom().bottom;
<footer

View File

@@ -8,13 +8,17 @@ import {
viewChild,
input,
booleanAttribute,
AfterViewInit,
ElementRef,
DestroyRef,
} from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import { combineLatest, switchMap } from "rxjs";
import { I18nPipe } from "@bitwarden/ui-common";
import { BitIconButtonComponent } from "../../icon-button/icon-button.component";
import { TypographyDirective } from "../../typography/typography.directive";
import { hasScrollableContent$ } from "../../utils/";
import { hasScrolledFrom } from "../../utils/has-scrolled-from";
import { fadeIn } from "../animations";
import { DialogRef } from "../dialog.service";
@@ -39,11 +43,22 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
CdkScrollable,
],
})
export class DialogComponent implements AfterViewInit {
protected dialogRef = inject(DialogRef, { optional: true });
export class DialogComponent {
private readonly destroyRef = inject(DestroyRef);
private scrollableBody = viewChild.required(CdkScrollable);
private scrollBottom = viewChild.required<ElementRef<HTMLDivElement>>("scrollBottom");
protected dialogRef = inject(DialogRef, { optional: true });
protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody);
protected isScrollable = false;
private scrollableBody$ = toObservable(this.scrollableBody);
private scrollBottom$ = toObservable(this.scrollBottom);
protected isScrollable$ = combineLatest([this.scrollableBody$, this.scrollBottom$]).pipe(
switchMap(([body, bottom]) =>
hasScrollableContent$(body.getElementRef().nativeElement, bottom.nativeElement),
),
);
/** Background color */
readonly background = input<"default" | "alt">("default");
@@ -105,13 +120,4 @@ export class DialogComponent implements AfterViewInit {
}
}
}
ngAfterViewInit() {
this.isScrollable = this.canScroll();
}
canScroll(): boolean {
const el = this.scrollableBody().getElementRef().nativeElement as HTMLElement;
return el.scrollHeight > el.clientHeight;
}
}

View File

@@ -0,0 +1,17 @@
import { Observable } from "rxjs";
/** IntersectionObserver Observable */
export const intersectionObserver$ = (
target: Element,
init: IntersectionObserverInit,
): Observable<IntersectionObserverEntry> => {
return new Observable((sub) => {
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
sub.next(e);
}
}, init);
io.observe(target);
return () => io.disconnect();
});
};

View File

@@ -0,0 +1,26 @@
import { Observable, animationFrameScheduler } from "rxjs";
import { auditTime, map, startWith, observeOn, distinctUntilChanged } from "rxjs/operators";
import { intersectionObserver$ } from "./dom-observables";
/**
* Utility to determine if an element has scrollable content.
* Returns an Observable that emits whenever scroll/resize/layout might change visibility
*/
export const hasScrollableContent$ = (
root: HTMLElement,
target: HTMLElement,
threshold: number = 1,
): Observable<boolean> => {
return intersectionObserver$(target, { root, threshold }).pipe(
startWith(null as IntersectionObserverEntry | null),
auditTime(0, animationFrameScheduler),
observeOn(animationFrameScheduler),
map((entry: IntersectionObserverEntry | null) => {
if (!entry) {
return root.scrollHeight > root.clientHeight;
}
return !entry.isIntersecting;
}),
distinctUntilChanged(),
);
};

View File

@@ -1,3 +1,4 @@
export * from "./aria-disable-element";
export * from "./function-to-observable";
export * from "./has-scrollable-content";
export * from "./i18n-mock.service";