1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-12 14:34:02 +00:00

Merge branch 'main' into billing/pm-29602/build-upgrade-dialogs

This commit is contained in:
Stephon Brown
2026-02-04 14:57:37 -05:00
51 changed files with 1157 additions and 183 deletions

View File

@@ -6123,6 +6123,12 @@
"whyAmISeeingThis": {
"message": "Why am I seeing this?"
},
"items": {
"message": "Items"
},
"searchResults": {
"message": "Search results"
},
"resizeSideNavigation": {
"message": "Resize side navigation"
},

View File

@@ -347,6 +347,18 @@ describe("AutofillInit", () => {
);
});
it("removes the LOAD event listener", () => {
jest.spyOn(window, "removeEventListener");
autofillInit.init();
autofillInit.destroy();
expect(window.removeEventListener).toHaveBeenCalledWith(
"load",
autofillInit["sendCollectDetailsMessage"],
);
});
it("removes the extension message listeners", () => {
autofillInit.destroy();

View File

@@ -72,21 +72,24 @@ class AutofillInit implements AutofillInitInterface {
* to act on the page.
*/
private collectPageDetailsOnLoad() {
const sendCollectDetailsMessage = () => {
this.clearCollectPageDetailsOnLoadTimeout();
this.collectPageDetailsOnLoadTimeout = setTimeout(
() => this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }),
750,
);
};
if (globalThis.document.readyState === "complete") {
sendCollectDetailsMessage();
this.sendCollectDetailsMessage();
}
globalThis.addEventListener(EVENTS.LOAD, sendCollectDetailsMessage);
globalThis.addEventListener(EVENTS.LOAD, this.sendCollectDetailsMessage);
}
/**
* Sends a message to collect page details after a short delay.
*/
private sendCollectDetailsMessage = () => {
this.clearCollectPageDetailsOnLoadTimeout();
this.collectPageDetailsOnLoadTimeout = setTimeout(
() => this.sendExtensionMessage("bgCollectPageDetails", { sender: "autofillInit" }),
750,
);
};
/**
* Collects the page details and sends them to the
* extension background script. If the `sendDetailsInResponse`
@@ -218,6 +221,7 @@ class AutofillInit implements AutofillInitInterface {
*/
destroy() {
this.clearCollectPageDetailsOnLoadTimeout();
globalThis.removeEventListener(EVENTS.LOAD, this.sendCollectDetailsMessage);
chrome.runtime.onMessage.removeListener(this.handleExtensionMessage);
this.collectAutofillContentService.destroy();
this.autofillOverlayContentService?.destroy();

View File

@@ -32,4 +32,5 @@ export type BackgroundPortMessageHandlers = {
export interface AutofillInlineMenuIframeService {
initMenuIframe(): void;
destroy(): void;
}

View File

@@ -645,6 +645,292 @@ describe("AutofillInlineMenuContentService", () => {
expect(disconnectSpy).toHaveBeenCalled();
});
it("unobserves custom elements", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService.destroy();
expect(disconnectSpy).toHaveBeenCalled();
});
it("unobserves the container element", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["containerElementMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService.destroy();
expect(disconnectSpy).toHaveBeenCalled();
});
it("clears the mutation observer iterations reset timeout", () => {
jest.useFakeTimers();
const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuContentService["mutationObserverIterationsResetTimeout"] = setTimeout(
jest.fn(),
1000,
);
autofillInlineMenuContentService.destroy();
expect(clearTimeoutSpy).toHaveBeenCalled();
expect(autofillInlineMenuContentService["mutationObserverIterationsResetTimeout"]).toBeNull();
});
it("destroys the button iframe", () => {
const mockButtonIframe = { destroy: jest.fn() };
autofillInlineMenuContentService["buttonIframe"] = mockButtonIframe as any;
autofillInlineMenuContentService.destroy();
expect(mockButtonIframe.destroy).toHaveBeenCalled();
});
it("destroys the list iframe", () => {
const mockListIframe = { destroy: jest.fn() };
autofillInlineMenuContentService["listIframe"] = mockListIframe as any;
autofillInlineMenuContentService.destroy();
expect(mockListIframe.destroy).toHaveBeenCalled();
});
});
describe("observeCustomElements", () => {
it("observes the button element for attribute mutations", () => {
const buttonElement = document.createElement("div");
autofillInlineMenuContentService["buttonElement"] = buttonElement;
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observeCustomElements"]();
expect(observeSpy).toHaveBeenCalledWith(buttonElement, { attributes: true });
});
it("observes the list element for attribute mutations", () => {
const listElement = document.createElement("div");
autofillInlineMenuContentService["listElement"] = listElement;
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observeCustomElements"]();
expect(observeSpy).toHaveBeenCalledWith(listElement, { attributes: true });
});
it("does not observe when no elements exist", () => {
autofillInlineMenuContentService["buttonElement"] = undefined;
autofillInlineMenuContentService["listElement"] = undefined;
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observeCustomElements"]();
expect(observeSpy).not.toHaveBeenCalled();
});
});
describe("observeContainerElement", () => {
it("observes the container element for child list mutations", () => {
const containerElement = document.createElement("div");
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["containerElementMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observeContainerElement"](containerElement);
expect(observeSpy).toHaveBeenCalledWith(containerElement, { childList: true });
});
});
describe("unobserveContainerElement", () => {
it("disconnects the container element mutation observer", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["containerElementMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService["unobserveContainerElement"]();
expect(disconnectSpy).toHaveBeenCalled();
});
it("handles the case when the mutation observer is undefined", () => {
autofillInlineMenuContentService["containerElementMutationObserver"] = undefined as any;
expect(() => autofillInlineMenuContentService["unobserveContainerElement"]()).not.toThrow();
});
});
describe("observePageAttributes", () => {
it("observes the document element for attribute mutations", () => {
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["htmlMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observePageAttributes"]();
expect(observeSpy).toHaveBeenCalledWith(document.documentElement, { attributes: true });
});
it("observes the body element for attribute mutations", () => {
const observeSpy = jest.spyOn(
autofillInlineMenuContentService["bodyMutationObserver"],
"observe",
);
autofillInlineMenuContentService["observePageAttributes"]();
expect(observeSpy).toHaveBeenCalledWith(document.body, { attributes: true });
});
});
describe("unobservePageAttributes", () => {
it("disconnects the html mutation observer", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["htmlMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService["unobservePageAttributes"]();
expect(disconnectSpy).toHaveBeenCalled();
});
it("disconnects the body mutation observer", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["bodyMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService["unobservePageAttributes"]();
expect(disconnectSpy).toHaveBeenCalled();
});
});
describe("checkPageRisks", () => {
it("returns true and closes inline menu when page is not opaque", async () => {
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(false);
const closeInlineMenuSpy = jest.spyOn(
autofillInlineMenuContentService as any,
"closeInlineMenu",
);
const result = await autofillInlineMenuContentService["checkPageRisks"]();
expect(result).toBe(true);
expect(closeInlineMenuSpy).toHaveBeenCalled();
});
it("returns true and closes inline menu when inline menu is disabled", async () => {
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(true);
autofillInlineMenuContentService["inlineMenuEnabled"] = false;
const closeInlineMenuSpy = jest.spyOn(
autofillInlineMenuContentService as any,
"closeInlineMenu",
);
const result = await autofillInlineMenuContentService["checkPageRisks"]();
expect(result).toBe(true);
expect(closeInlineMenuSpy).toHaveBeenCalled();
});
it("returns false when page is opaque and inline menu is enabled", async () => {
jest.spyOn(autofillInlineMenuContentService as any, "getPageIsOpaque").mockReturnValue(true);
autofillInlineMenuContentService["inlineMenuEnabled"] = true;
const closeInlineMenuSpy = jest.spyOn(
autofillInlineMenuContentService as any,
"closeInlineMenu",
);
const result = await autofillInlineMenuContentService["checkPageRisks"]();
expect(result).toBe(false);
expect(closeInlineMenuSpy).not.toHaveBeenCalled();
});
});
describe("handlePageMutations", () => {
it("checks page risks when mutations include attribute changes", async () => {
const checkPageRisksSpy = jest.spyOn(
autofillInlineMenuContentService as any,
"checkPageRisks",
);
const mutations = [{ type: "attributes" } as MutationRecord];
await autofillInlineMenuContentService["handlePageMutations"](mutations);
expect(checkPageRisksSpy).toHaveBeenCalled();
});
it("does not check page risks when mutations do not include attribute changes", async () => {
const checkPageRisksSpy = jest.spyOn(
autofillInlineMenuContentService as any,
"checkPageRisks",
);
const mutations = [{ type: "childList" } as MutationRecord];
await autofillInlineMenuContentService["handlePageMutations"](mutations);
expect(checkPageRisksSpy).not.toHaveBeenCalled();
});
});
describe("clearPersistentLastChildOverrideTimeout", () => {
it("clears the timeout when it exists", () => {
jest.useFakeTimers();
const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = setTimeout(
jest.fn(),
1000,
);
autofillInlineMenuContentService["clearPersistentLastChildOverrideTimeout"]();
expect(clearTimeoutSpy).toHaveBeenCalled();
});
it("does nothing when the timeout is null", () => {
const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = null;
autofillInlineMenuContentService["clearPersistentLastChildOverrideTimeout"]();
expect(clearTimeoutSpy).not.toHaveBeenCalled();
});
});
describe("elementAtCenterOfInlineMenuPosition", () => {
it("returns the element at the center of the given position", () => {
const mockElement = document.createElement("div");
jest.spyOn(globalThis.document, "elementFromPoint").mockReturnValue(mockElement);
const result = autofillInlineMenuContentService["elementAtCenterOfInlineMenuPosition"]({
top: 100,
left: 200,
width: 50,
height: 30,
});
expect(globalThis.document.elementFromPoint).toHaveBeenCalledWith(225, 115);
expect(result).toBe(mockElement);
});
});
describe("getOwnedTagNames", () => {
@@ -975,6 +1261,25 @@ describe("AutofillInlineMenuContentService", () => {
});
});
describe("unobserveCustomElements", () => {
it("disconnects the inline menu elements mutation observer", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"],
"disconnect",
);
autofillInlineMenuContentService["unobserveCustomElements"]();
expect(disconnectSpy).toHaveBeenCalled();
});
it("handles the case when the mutation observer is undefined", () => {
autofillInlineMenuContentService["inlineMenuElementsMutationObserver"] = undefined as any;
expect(() => autofillInlineMenuContentService["unobserveCustomElements"]()).not.toThrow();
});
});
describe("getPageIsOpaque", () => {
it("returns false when no page elements exist", () => {
jest.spyOn(globalThis.document, "querySelectorAll").mockReturnValue([] as any);

View File

@@ -41,7 +41,9 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
globalThis.navigator.userAgent.indexOf(" Firefox/") !== -1 ||
globalThis.navigator.userAgent.indexOf(" Gecko/") !== -1;
private buttonElement?: HTMLElement;
private buttonIframe?: AutofillInlineMenuButtonIframe;
private listElement?: HTMLElement;
private listIframe?: AutofillInlineMenuListIframe;
private htmlMutationObserver: MutationObserver;
private bodyMutationObserver: MutationObserver;
private inlineMenuElementsMutationObserver: MutationObserver;
@@ -264,18 +266,19 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
if (this.isFirefoxBrowser) {
this.buttonElement = globalThis.document.createElement("div");
this.buttonElement.setAttribute("popover", "manual");
new AutofillInlineMenuButtonIframe(this.buttonElement);
this.buttonIframe = new AutofillInlineMenuButtonIframe(this.buttonElement);
return this.buttonElement;
}
const customElementName = this.generateRandomCustomElementName();
const self = this;
globalThis.customElements?.define(
customElementName,
class extends HTMLElement {
constructor() {
super();
new AutofillInlineMenuButtonIframe(this);
self.buttonIframe = new AutofillInlineMenuButtonIframe(this);
}
},
);
@@ -293,18 +296,19 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
if (this.isFirefoxBrowser) {
this.listElement = globalThis.document.createElement("div");
this.listElement.setAttribute("popover", "manual");
new AutofillInlineMenuListIframe(this.listElement);
this.listIframe = new AutofillInlineMenuListIframe(this.listElement);
return this.listElement;
}
const customElementName = this.generateRandomCustomElementName();
const self = this;
globalThis.customElements?.define(
customElementName,
class extends HTMLElement {
constructor() {
super();
new AutofillInlineMenuListIframe(this);
self.listIframe = new AutofillInlineMenuListIframe(this);
}
},
);
@@ -778,5 +782,13 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte
this.closeInlineMenu();
this.clearPersistentLastChildOverrideTimeout();
this.unobservePageAttributes();
this.unobserveCustomElements();
this.unobserveContainerElement();
if (this.mutationObserverIterationsResetTimeout) {
clearTimeout(this.mutationObserverIterationsResetTimeout);
this.mutationObserverIterationsResetTimeout = null;
}
this.buttonIframe?.destroy();
this.listIframe?.destroy();
}
}

View File

@@ -1,6 +1,8 @@
import { AutofillInlineMenuIframeService } from "./autofill-inline-menu-iframe.service";
export class AutofillInlineMenuIframeElement {
private autofillInlineMenuIframeService: AutofillInlineMenuIframeService;
constructor(
element: HTMLElement,
portName: string,
@@ -12,14 +14,14 @@ export class AutofillInlineMenuIframeElement {
const shadow: ShadowRoot = element.attachShadow({ mode: "closed" });
shadow.prepend(style);
const autofillInlineMenuIframeService = new AutofillInlineMenuIframeService(
this.autofillInlineMenuIframeService = new AutofillInlineMenuIframeService(
shadow,
portName,
initStyles,
iframeTitle,
ariaAlert,
);
autofillInlineMenuIframeService.initMenuIframe();
this.autofillInlineMenuIframeService.initMenuIframe();
}
/**
@@ -67,4 +69,11 @@ export class AutofillInlineMenuIframeElement {
return style;
}
/**
* Cleans up the iframe service to prevent memory leaks.
*/
destroy() {
this.autofillInlineMenuIframeService?.destroy();
}
}

View File

@@ -752,4 +752,164 @@ describe("AutofillInlineMenuIframeService", () => {
expect(autofillInlineMenuIframeService["iframe"].title).toBe("title");
});
});
describe("destroy", () => {
beforeEach(() => {
autofillInlineMenuIframeService.initMenuIframe();
autofillInlineMenuIframeService["iframe"].dispatchEvent(new Event(EVENTS.LOAD));
portSpy = autofillInlineMenuIframeService["port"];
});
it("removes the LOAD event listener from the iframe", () => {
const removeEventListenerSpy = jest.spyOn(
autofillInlineMenuIframeService["iframe"],
"removeEventListener",
);
autofillInlineMenuIframeService.destroy();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
EVENTS.LOAD,
autofillInlineMenuIframeService["setupPortMessageListener"],
);
});
it("clears the aria alert timeout", () => {
jest.spyOn(autofillInlineMenuIframeService, "clearAriaAlert");
autofillInlineMenuIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 1000);
autofillInlineMenuIframeService.destroy();
expect(autofillInlineMenuIframeService.clearAriaAlert).toHaveBeenCalled();
});
it("clears the fade in timeout", () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuIframeService["fadeInTimeout"] = setTimeout(jest.fn(), 1000);
autofillInlineMenuIframeService.destroy();
expect(globalThis.clearTimeout).toHaveBeenCalled();
expect(autofillInlineMenuIframeService["fadeInTimeout"]).toBeNull();
});
it("clears the delayed close timeout", () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuIframeService["delayedCloseTimeout"] = setTimeout(jest.fn(), 1000);
autofillInlineMenuIframeService.destroy();
expect(globalThis.clearTimeout).toHaveBeenCalled();
expect(autofillInlineMenuIframeService["delayedCloseTimeout"]).toBeNull();
});
it("clears the mutation observer iterations reset timeout", () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuIframeService["mutationObserverIterationsResetTimeout"] = setTimeout(
jest.fn(),
1000,
);
autofillInlineMenuIframeService.destroy();
expect(globalThis.clearTimeout).toHaveBeenCalled();
expect(autofillInlineMenuIframeService["mutationObserverIterationsResetTimeout"]).toBeNull();
});
it("unobserves the iframe mutation observer", () => {
const disconnectSpy = jest.spyOn(
autofillInlineMenuIframeService["iframeMutationObserver"],
"disconnect",
);
autofillInlineMenuIframeService.destroy();
expect(disconnectSpy).toHaveBeenCalled();
});
it("removes the port message listeners and disconnects the port", () => {
autofillInlineMenuIframeService.destroy();
expect(portSpy.onMessage.removeListener).toHaveBeenCalledWith(handlePortMessageSpy);
expect(portSpy.onDisconnect.removeListener).toHaveBeenCalledWith(handlePortDisconnectSpy);
expect(portSpy.disconnect).toHaveBeenCalled();
expect(autofillInlineMenuIframeService["port"]).toBeNull();
});
it("handles the case when the port is null", () => {
autofillInlineMenuIframeService["port"] = null;
expect(() => autofillInlineMenuIframeService.destroy()).not.toThrow();
});
it("handles the case when the iframe is undefined", () => {
autofillInlineMenuIframeService["iframe"] = undefined as any;
expect(() => autofillInlineMenuIframeService.destroy()).not.toThrow();
});
});
describe("clearAriaAlert", () => {
it("clears the aria alert timeout when it exists", () => {
jest.useFakeTimers();
jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuIframeService["ariaAlertTimeout"] = setTimeout(jest.fn(), 1000);
autofillInlineMenuIframeService.clearAriaAlert();
expect(globalThis.clearTimeout).toHaveBeenCalled();
expect(autofillInlineMenuIframeService["ariaAlertTimeout"]).toBeNull();
});
it("does nothing when the aria alert timeout is null", () => {
jest.spyOn(globalThis, "clearTimeout");
autofillInlineMenuIframeService["ariaAlertTimeout"] = null;
autofillInlineMenuIframeService.clearAriaAlert();
expect(globalThis.clearTimeout).not.toHaveBeenCalled();
});
});
describe("unobserveIframe", () => {
it("disconnects the iframe mutation observer", () => {
autofillInlineMenuIframeService.initMenuIframe();
const disconnectSpy = jest.spyOn(
autofillInlineMenuIframeService["iframeMutationObserver"],
"disconnect",
);
autofillInlineMenuIframeService["unobserveIframe"]();
expect(disconnectSpy).toHaveBeenCalled();
});
it("handles the case when the mutation observer is undefined", () => {
autofillInlineMenuIframeService["iframeMutationObserver"] = undefined as any;
expect(() => autofillInlineMenuIframeService["unobserveIframe"]()).not.toThrow();
});
});
describe("observeIframe", () => {
beforeEach(() => {
autofillInlineMenuIframeService.initMenuIframe();
});
it("observes the iframe for attribute mutations", () => {
const observeSpy = jest.spyOn(
autofillInlineMenuIframeService["iframeMutationObserver"],
"observe",
);
autofillInlineMenuIframeService["observeIframe"]();
expect(observeSpy).toHaveBeenCalledWith(autofillInlineMenuIframeService["iframe"], {
attributes: true,
});
});
});
});

View File

@@ -555,4 +555,26 @@ export class AutofillInlineMenuIframeService implements AutofillInlineMenuIframe
return false;
}
/**
* Cleans up all event listeners, timeouts, and observers to prevent memory leaks.
*/
destroy() {
this.iframe?.removeEventListener(EVENTS.LOAD, this.setupPortMessageListener);
this.clearAriaAlert();
this.clearFadeInTimeout();
if (this.delayedCloseTimeout) {
clearTimeout(this.delayedCloseTimeout);
this.delayedCloseTimeout = null;
}
if (this.mutationObserverIterationsResetTimeout) {
clearTimeout(this.mutationObserverIterationsResetTimeout);
this.mutationObserverIterationsResetTimeout = null;
}
this.unobserveIframe();
this.port?.onMessage.removeListener(this.handlePortMessage);
this.port?.onDisconnect.removeListener(this.handlePortDisconnect);
this.port?.disconnect();
this.port = null;
}
}

View File

@@ -6,7 +6,7 @@ import { firstValueFrom, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { AnchorLinkDirective, CalloutModule, BannerModule } from "@bitwarden/components";
import { LinkComponent, CalloutModule, BannerModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwarden/vault";
@@ -15,7 +15,7 @@ import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwar
@Component({
selector: "vault-at-risk-password-callout",
imports: [
AnchorLinkDirective,
LinkComponent,
CommonModule,
RouterModule,
CalloutModule,

View File

@@ -107,20 +107,32 @@
@if (vaultState === null) {
<vault-fade-in-out>
@if (!(loading$ | async)) {
<app-autofill-vault-list-items></app-autofill-vault-list-items>
<app-vault-list-items-container
[title]="'favorites' | i18n"
[ciphers]="(favoriteCiphers$ | async) || []"
id="favorites"
collapsibleKey="favorites"
></app-vault-list-items-container>
<app-vault-list-items-container
[title]="'allItems' | i18n"
[ciphers]="(remainingCiphers$ | async) || []"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"
></app-vault-list-items-container>
<!--If there is search text fold all the filtered ciphers into one container-->
@if (hasSearchText$ | async) {
<app-vault-list-items-container
[title]="'searchResults' | i18n"
[ciphers]="(filteredCiphers$ | async) || []"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"
></app-vault-list-items-container>
} @else {
<app-autofill-vault-list-items></app-autofill-vault-list-items>
<app-vault-list-items-container
[title]="'favorites' | i18n"
[ciphers]="(favoriteCiphers$ | async) || []"
id="favorites"
collapsibleKey="favorites"
></app-vault-list-items-container>
<!--Change the title header when a filter is applied-->
<app-vault-list-items-container
[title]="((numberOfAppliedFilters$ | async) === 0 ? 'allItems' : 'items') | i18n"
[ciphers]="(remainingCiphers$ | async) || []"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"
></app-vault-list-items-container>
}
}
</vault-fade-in-out>
}

View File

@@ -44,6 +44,7 @@ import { VaultPopupAutofillService } from "../../services/vault-popup-autofill.s
import { VaultPopupCopyButtonsService } from "../../services/vault-popup-copy-buttons.service";
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
import { VaultPopupLoadingService } from "../../services/vault-popup-loading.service";
import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component";
@@ -174,15 +175,21 @@ describe("VaultV2Component", () => {
showDeactivatedOrg$: new BehaviorSubject<boolean>(false),
favoriteCiphers$: new BehaviorSubject<any[]>([]),
remainingCiphers$: new BehaviorSubject<any[]>([]),
filteredCiphers$: new BehaviorSubject<any[]>([]),
cipherCount$: new BehaviorSubject<number>(0),
loading$: new BehaviorSubject<boolean>(true),
hasSearchText$: new BehaviorSubject<boolean>(false),
} as Partial<VaultPopupItemsService>;
const filtersSvc = {
const filtersSvc: any = {
allFilters$: new Subject<any>(),
filters$: new BehaviorSubject<any>({}),
filterVisibilityState$: new BehaviorSubject<any>({}),
} as Partial<VaultPopupListFiltersService>;
numberOfAppliedFilters$: new BehaviorSubject<number>(0),
};
const loadingSvc: any = {
loading$: new BehaviorSubject<boolean>(false),
};
const activeAccount$ = new BehaviorSubject<FakeAccount | null>({ id: "user-1" });
@@ -240,6 +247,7 @@ describe("VaultV2Component", () => {
provideNoopAnimations(),
{ provide: VaultPopupItemsService, useValue: itemsSvc },
{ provide: VaultPopupListFiltersService, useValue: filtersSvc },
{ provide: VaultPopupLoadingService, useValue: loadingSvc },
{ provide: VaultPopupScrollPositionService, useValue: scrollSvc },
{
provide: AccountService,
@@ -366,18 +374,18 @@ describe("VaultV2Component", () => {
});
it("loading$ is true when items loading or filters missing; false when both ready", () => {
const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject<boolean>;
const vaultLoading$ = loadingSvc.loading$ as unknown as BehaviorSubject<boolean>;
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
const readySubject$ = component["readySubject"] as unknown as BehaviorSubject<boolean>;
const values: boolean[] = [];
getObs<boolean>(component, "loading$").subscribe((v) => values.push(!!v));
itemsLoading$.next(true);
vaultLoading$.next(true);
allFilters$.next({});
itemsLoading$.next(false);
vaultLoading$.next(false);
readySubject$.next(true);
@@ -389,7 +397,7 @@ describe("VaultV2Component", () => {
const component = fixture.componentInstance;
const readySubject$ = component["readySubject"] as unknown as BehaviorSubject<boolean>;
const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject<boolean>;
const vaultLoading$ = loadingSvc.loading$ as unknown as BehaviorSubject<boolean>;
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
fixture.detectChanges();
@@ -400,7 +408,7 @@ describe("VaultV2Component", () => {
) as HTMLElement;
// Unblock loading
itemsLoading$.next(false);
vaultLoading$.next(false);
readySubject$.next(true);
allFilters$.next({});
tick();
@@ -607,6 +615,127 @@ describe("VaultV2Component", () => {
expect(spotlights.length).toBe(0);
}));
it("does not render app-autofill-vault-list-items or favorites item container when hasSearchText$ is true", () => {
itemsSvc.hasSearchText$.next(true);
const fixture = TestBed.createComponent(VaultV2Component);
component = fixture.componentInstance;
const readySubject$ = component["readySubject"];
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
// Unblock loading
readySubject$.next(true);
allFilters$.next({});
fixture.detectChanges();
const autofillElement = fixture.debugElement.query(By.css("app-autofill-vault-list-items"));
expect(autofillElement).toBeFalsy();
const favoritesElement = fixture.debugElement.query(By.css("#favorites"));
expect(favoritesElement).toBeFalsy();
});
it("does render app-autofill-vault-list-items and favorites item container when hasSearchText$ is false", () => {
// Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg)
itemsSvc.emptyVault$.next(false);
itemsSvc.noFilteredResults$.next(false);
itemsSvc.showDeactivatedOrg$.next(false);
itemsSvc.hasSearchText$.next(false);
loadingSvc.loading$.next(false);
const fixture = TestBed.createComponent(VaultV2Component);
component = fixture.componentInstance;
const readySubject$ = component["readySubject"];
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
// Unblock loading
readySubject$.next(true);
allFilters$.next({});
fixture.detectChanges();
const autofillElement = fixture.debugElement.query(By.css("app-autofill-vault-list-items"));
expect(autofillElement).toBeTruthy();
const favoritesElement = fixture.debugElement.query(By.css("#favorites"));
expect(favoritesElement).toBeTruthy();
});
it("does set the title for allItems container to allItems when hasSearchText$ and numberOfAppliedFilters$ are false and 0 respectively", () => {
// Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg)
itemsSvc.emptyVault$.next(false);
itemsSvc.noFilteredResults$.next(false);
itemsSvc.showDeactivatedOrg$.next(false);
itemsSvc.hasSearchText$.next(false);
filtersSvc.numberOfAppliedFilters$.next(0);
loadingSvc.loading$.next(false);
const fixture = TestBed.createComponent(VaultV2Component);
component = fixture.componentInstance;
const readySubject$ = component["readySubject"];
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
// Unblock loading
readySubject$.next(true);
allFilters$.next({});
fixture.detectChanges();
const allItemsElement = fixture.debugElement.query(By.css("#allItems"));
const allItemsTitle = allItemsElement.componentInstance.title();
expect(allItemsTitle).toBe("allItems");
});
it("does set the title for allItems container to searchResults when hasSearchText$ is true", () => {
// Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg)
itemsSvc.emptyVault$.next(false);
itemsSvc.noFilteredResults$.next(false);
itemsSvc.showDeactivatedOrg$.next(false);
itemsSvc.hasSearchText$.next(true);
loadingSvc.loading$.next(false);
const fixture = TestBed.createComponent(VaultV2Component);
component = fixture.componentInstance;
const readySubject$ = component["readySubject"];
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
// Unblock loading
readySubject$.next(true);
allFilters$.next({});
fixture.detectChanges();
const allItemsElement = fixture.debugElement.query(By.css("#allItems"));
const allItemsTitle = allItemsElement.componentInstance.title();
expect(allItemsTitle).toBe("searchResults");
});
it("does set the title for allItems container to items when numberOfAppliedFilters$ is > 0", fakeAsync(() => {
// Ensure vaultState is null (not Empty, NoResults, or DeactivatedOrg)
itemsSvc.emptyVault$.next(false);
itemsSvc.noFilteredResults$.next(false);
itemsSvc.showDeactivatedOrg$.next(false);
itemsSvc.hasSearchText$.next(false);
filtersSvc.numberOfAppliedFilters$.next(1);
loadingSvc.loading$.next(false);
const fixture = TestBed.createComponent(VaultV2Component);
component = fixture.componentInstance;
const readySubject$ = component["readySubject"];
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
// Unblock loading
readySubject$.next(true);
allFilters$.next({});
fixture.detectChanges();
const allItemsElement = fixture.debugElement.query(By.css("#allItems"));
const allItemsTitle = allItemsElement.componentInstance.title();
expect(allItemsTitle).toBe("items");
}));
describe("AutoConfirmExtensionSetupDialog", () => {
beforeEach(() => {
autoConfirmDialogSpy.mockClear();

View File

@@ -160,6 +160,11 @@ export class VaultV2Component implements OnInit, OnDestroy {
FeatureFlag.BrowserPremiumSpotlight,
);
protected readonly hasSearchText$ = this.vaultPopupItemsService.hasSearchText$;
protected readonly numberOfAppliedFilters$ =
this.vaultPopupListFiltersService.numberOfAppliedFilters$;
protected filteredCiphers$ = this.vaultPopupItemsService.filteredCiphers$;
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
protected allFilters$ = this.vaultPopupListFiltersService.allFilters$;

View File

@@ -323,6 +323,25 @@ describe("VaultPopupItemsService", () => {
});
});
describe("filteredCiphers$", () => {
it("should filter filteredCipher$ down to search term", (done) => {
const cipherList = Object.values(allCiphers);
const searchText = "Login";
searchService.searchCiphers.mockImplementation(async () => {
return cipherList.filter((cipher) => {
return cipher.name.includes(searchText);
});
});
service.filteredCiphers$.subscribe((ciphers) => {
// There are 10 ciphers but only 3 with "Login" in the name
expect(ciphers.length).toBe(3);
done();
});
});
});
describe("favoriteCiphers$", () => {
it("should exclude autofill ciphers", (done) => {
service.favoriteCiphers$.subscribe((ciphers) => {

View File

@@ -201,6 +201,15 @@ export class VaultPopupItemsService {
shareReplay({ refCount: true, bufferSize: 1 }),
);
/**
* List of ciphers that are filtered using filters and search.
* Includes favorite ciphers and ciphers currently suggested for autofill.
* Ciphers are sorted by name.
*/
filteredCiphers$: Observable<PopupCipherViewLike[]> = this._filteredCipherList$.pipe(
shareReplay({ refCount: false, bufferSize: 1 }),
);
/**
* List of ciphers that can be used for autofill on the current tab. Includes cards and/or identities
* if enabled in the vault settings. Ciphers are sorted by type, then by last used date, then by name.

View File

@@ -74,9 +74,11 @@
<button type="button" bitMenuItem (click)="edit(cipher)">
{{ "edit" | i18n }}
</button>
<button type="button" bitMenuItem (click)="clone(cipher)">
{{ "clone" | i18n }}
</button>
@if (userHasPremium$ | async) {
<button type="button" bitMenuItem (click)="clone(cipher)">
{{ "clone" | i18n }}
</button>
}
@if (canAssignCollections$ | async) {
<button
type="button"

View File

@@ -1,4 +1,6 @@
import { TestBed } from "@angular/core/testing";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { Router } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
@@ -7,10 +9,13 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { PopupRouterCacheService } from "@bitwarden/browser/platform/popup/view-cache/popup-router-cache.service";
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 { 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 { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
@@ -25,40 +30,78 @@ jest.mock("qrcode-parser", () => {});
describe("ArchiveComponent", () => {
let component: ArchiveComponent;
let fixture: ComponentFixture<ArchiveComponent>;
let hasOrganizations: jest.Mock;
let decryptedCollections$: jest.Mock;
let navigate: jest.Mock;
let showPasswordPrompt: jest.Mock;
let userHasPremium$: jest.Mock;
let archivedCiphers$: jest.Mock;
beforeAll(async () => {
beforeEach(async () => {
navigate = jest.fn();
showPasswordPrompt = jest.fn().mockResolvedValue(true);
hasOrganizations = jest.fn();
decryptedCollections$ = jest.fn();
hasOrganizations = jest.fn().mockReturnValue(of(false));
decryptedCollections$ = jest.fn().mockReturnValue(of([]));
userHasPremium$ = jest.fn().mockReturnValue(of(false));
archivedCiphers$ = jest.fn().mockReturnValue(of([{ id: "cipher-1" }]));
await TestBed.configureTestingModule({
imports: [ArchiveComponent],
providers: [
provideNoopAnimations(),
{ provide: Router, useValue: { navigate } },
{
provide: AccountService,
useValue: { activeAccount$: new BehaviorSubject({ id: "user-id" }) },
},
{ provide: PasswordRepromptService, useValue: { showPasswordPrompt } },
{ provide: OrganizationService, useValue: { hasOrganizations } },
{
provide: OrganizationService,
useValue: { hasOrganizations, organizations$: () => of([]) },
},
{ provide: CollectionService, useValue: { decryptedCollections$ } },
{ provide: DialogService, useValue: mock<DialogService>() },
{ provide: CipherService, useValue: mock<CipherService>() },
{ provide: CipherArchiveService, useValue: mock<CipherArchiveService>() },
{
provide: CipherArchiveService,
useValue: {
userHasPremium$,
archivedCiphers$,
userCanArchive$: jest.fn().mockReturnValue(of(true)),
showSubscriptionEndedMessaging$: jest.fn().mockReturnValue(of(false)),
},
},
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{
provide: EnvironmentService,
useValue: {
environment$: of({
getIconsUrl: () => "https://icons.example.com",
}),
},
},
{
provide: DomainSettingsService,
useValue: {
showFavicons$: of(true),
},
},
{
provide: CipherAuthorizationService,
useValue: {
canDeleteCipher$: jest.fn().mockReturnValue(of(true)),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ArchiveComponent);
fixture = TestBed.createComponent(ArchiveComponent);
component = fixture.componentInstance;
});
@@ -137,4 +180,54 @@ describe("ArchiveComponent", () => {
expect(navigate).not.toHaveBeenCalled();
});
});
describe("clone menu option", () => {
const getBitMenuPanel = () => document.querySelector(".bit-menu-panel");
it("is shown when user has premium", async () => {
userHasPremium$.mockReturnValue(of(true));
const testFixture = TestBed.createComponent(ArchiveComponent);
testFixture.detectChanges();
await testFixture.whenStable();
const menuTrigger = testFixture.debugElement.query(By.css('button[aria-haspopup="menu"]'));
expect(menuTrigger).toBeTruthy();
(menuTrigger.nativeElement as HTMLButtonElement).click();
testFixture.detectChanges();
const menuPanel = getBitMenuPanel();
expect(menuPanel).toBeTruthy();
const menuButtons = menuPanel?.querySelectorAll("button[bitMenuItem]");
const cloneButtonFound = Array.from(menuButtons || []).some(
(btn) => btn.textContent?.trim() === "clone",
);
expect(cloneButtonFound).toBe(true);
});
it("is not shown when user does not have premium", async () => {
userHasPremium$.mockReturnValue(of(false));
const testFixture = TestBed.createComponent(ArchiveComponent);
testFixture.detectChanges();
await testFixture.whenStable();
const menuTrigger = testFixture.debugElement.query(By.css('button[aria-haspopup="menu"]'));
expect(menuTrigger).toBeTruthy();
(menuTrigger.nativeElement as HTMLButtonElement).click();
testFixture.detectChanges();
const menuPanel = getBitMenuPanel();
expect(menuPanel).toBeTruthy();
const menuButtons = menuPanel?.querySelectorAll("button[bitMenuItem]");
const cloneButtonFound = Array.from(menuButtons || []).some(
(btn) => btn.textContent?.trim() === "clone",
);
expect(cloneButtonFound).toBe(false);
});
});
});

View File

@@ -135,6 +135,10 @@ export class ArchiveComponent {
switchMap((userId) => this.cipherArchiveService.showSubscriptionEndedMessaging$(userId)),
);
protected userHasPremium$ = this.userId$.pipe(
switchMap((userId) => this.cipherArchiveService.userHasPremium$(userId)),
);
async navigateToPremium() {
await this.router.navigate(["/premium"]);
}

View File

@@ -69,7 +69,7 @@
"browser-hrtime": "1.1.8",
"chalk": "4.1.2",
"commander": "14.0.0",
"core-js": "3.47.0",
"core-js": "3.48.0",
"form-data": "4.0.4",
"https-proxy-agent": "7.0.6",
"inquirer": "8.2.6",

View File

@@ -6,6 +6,7 @@
(onCipherClicked)="viewCipher($event)"
(onCipherRightClicked)="viewCipherMenu($event)"
(onAddCipher)="addCipher($event)"
(onAddFolder)="addFolder()"
[showPremiumCallout]="showPremiumCallout$ | async"
>
</app-vault-items-v2>

View File

@@ -229,12 +229,20 @@ export class ItemFooterComponent implements OnInit, OnChanges {
}
protected async archive() {
await this.archiveCipherUtilitiesService.archiveCipher(this.cipher);
/**
* When the Archive Button is used in the footer we can skip the reprompt since
* the user will have already passed the reprompt when they opened the item.
*/
await this.archiveCipherUtilitiesService.archiveCipher(this.cipher, true);
this.onArchiveToggle.emit();
}
protected async unarchive() {
await this.archiveCipherUtilitiesService.unarchiveCipher(this.cipher);
/**
* When the Unarchive Button is used in the footer we can skip the reprompt since
* the user will have already passed the reprompt when they opened the item.
*/
await this.archiveCipherUtilitiesService.unarchiveCipher(this.cipher, true);
this.onArchiveToggle.emit();
}

View File

@@ -95,5 +95,13 @@
{{ itemTypes.labelKey | i18n }}
</button>
}
@if (desktopMigrationMilestone1()) {
<bit-menu-divider />
<button type="button" bitMenuItem (click)="onAddFolder.emit()">
<i class="bwi bwi-folder tw-mr-1" aria-hidden="true"></i>
{{ "folder" | i18n }}
</button>
}
</bit-menu>
</ng-template>

View File

@@ -1,12 +1,13 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { Component, input } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Component, input, output } from "@angular/core";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { distinctUntilChanged, debounceTime } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -32,6 +33,12 @@ import { SearchBarService } from "../../../app/layout/search/search-bar.service"
export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultItemsComponent<C> {
readonly showPremiumCallout = input<boolean>(false);
readonly onAddFolder = output<void>();
protected readonly desktopMigrationMilestone1 = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone1),
);
protected CipherViewLikeUtils = CipherViewLikeUtils;
constructor(

View File

@@ -363,7 +363,7 @@ describe("VaultItemDialogComponent", () => {
});
it("refocuses the dialog header", async () => {
const focusOnHeaderSpy = jest.spyOn(component["dialogComponent"](), "focusOnHeader");
const focusOnHeaderSpy = jest.spyOn(component["dialogComponent"](), "handleAutofocus");
await component["changeMode"]("view");

View File

@@ -692,7 +692,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
this.dialogContent().nativeElement.parentElement.scrollTop = 0;
// Refocus on title element, the built-in focus management of the dialog only works for the initial open.
this.dialogComponent().focusOnHeader();
this.dialogComponent().handleAutofocus();
// Update the URL query params to reflect the new mode.
await this.router.navigate([], {

View File

@@ -46,17 +46,21 @@
bitMenuItem
(click)="unlinkSso(organization)"
>
<i class="bwi bwi-fw bwi-minus-circle"></i>
{{ "unlinkSso" | i18n }}
</button>
<ng-template #linkSso>
<button type="button" bitMenuItem (click)="handleLinkSso(organization)">
<i class="bwi bwi-fw bwi-plus-circle"></i>
{{ "linkSso" | i18n }}
</button>
</ng-template>
</ng-container>
<button *ngIf="showLeaveOrgOption" type="button" bitMenuItem (click)="leave(organization)">
<i class="bwi bwi-fw bwi-sign-out tw-text-danger" aria-hidden="true"></i>
<span class="tw-text-danger">{{ "leave" | i18n }}</span>
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-sign-out" aria-hidden="true"></i>
{{ "leave" | i18n }}
</span>
</button>
</div>
</bit-menu>

View File

@@ -38,6 +38,9 @@
"accessIntelligence": {
"message": "Access Intelligence"
},
"noApplicationsMatchTheseFilters": {
"message": "No applications match these filters"
},
"passwordRisk": {
"message": "Password Risk"
},

View File

@@ -3,12 +3,10 @@
} @else {
@let drawerDetails = dataService.drawerDetails$ | async;
<div class="tw-mt-4 tw-flex tw-flex-col">
<h2 class="tw-mb-6" bitTypography="h2">{{ "allApplications" | i18n }}</h2>
<div class="tw-flex tw-mb-4 tw-gap-4 tw-items-center">
<bit-search
[placeholder]="'searchApps' | i18n"
class="tw-w-1/2"
class="tw-min-w-96"
[formControl]="searchControl"
></bit-search>
@@ -20,7 +18,8 @@
(ngModelChange)="setFilterApplicationsByStatus($event)"
fullWidth="false"
class="tw-min-w-48"
></bit-chip-select>
>
</bit-chip-select>
<button
type="button"
@@ -44,5 +43,11 @@
[checkboxChange]="onCheckboxChange"
[showAppAtRiskMembers]="showAppAtRiskMembers"
></app-table-row-scrollable-m11>
@if (emptyTableExplanation()) {
<div class="tw-flex tw-mt-10 tw-justify-center">
<span bitTypography="body2">{{ emptyTableExplanation() }}</span>
</div>
}
</div>
}

View File

@@ -96,12 +96,15 @@ export class ApplicationsComponent implements OnInit {
{
label: this.i18nService.t("critical", this.criticalApplicationsCount()),
value: ApplicationFilterOption.Critical,
icon: " ",
},
{
label: this.i18nService.t("notCritical", this.nonCriticalApplicationsCount()),
value: ApplicationFilterOption.NonCritical,
icon: " ",
},
]);
protected readonly emptyTableExplanation = signal("");
constructor(
protected i18nService: I18nService,
@@ -162,6 +165,12 @@ export class ApplicationsComponent implements OnInit {
this.dataSource.filter = (app) =>
filterFunction(app) &&
app.applicationName.toLowerCase().includes(searchText.toLowerCase());
if (this.dataSource?.filteredData?.length === 0) {
this.emptyTableExplanation.set(this.i18nService.t("noApplicationsMatchTheseFilters"));
} else {
this.emptyTableExplanation.set("");
}
});
}

View File

@@ -94,7 +94,7 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
protected cipherService: CipherService,
protected accountService: AccountService,
protected restrictedItemTypesService: RestrictedItemTypesService,
private configService: ConfigService,
protected configService: ConfigService,
) {
this.subscribeToCiphers();

View File

@@ -1,6 +1,14 @@
<span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
<ng-content></ng-content>
<span class="tw-relative tw-flex tw-items-center tw-justify-center">
<span [class.tw-invisible]="showLoadingStyle()" class="tw-flex tw-items-center tw-gap-2">
@if (startIcon()) {
<i class="{{ startIconClasses() }}"></i>
}
<div>
<ng-content></ng-content>
</div>
@if (endIcon()) {
<i class="{{ endIconClasses() }}"></i>
}
</span>
@if (showLoadingStyle()) {
<span class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center">

View File

@@ -1,4 +1,4 @@
import { NgClass } from "@angular/common";
import { NgClass, NgTemplateOutlet } from "@angular/common";
import {
input,
HostBinding,
@@ -14,6 +14,7 @@ import { debounce, interval } from "rxjs";
import { AriaDisableDirective } from "../a11y";
import { ButtonLikeAbstraction, ButtonType, ButtonSize } from "../shared/button-like.abstraction";
import { BitwardenIcon } from "../shared/icon";
import { SpinnerComponent } from "../spinner";
import { ariaDisableElement } from "../utils";
@@ -71,7 +72,7 @@ const buttonStyles: Record<ButtonType, string[]> = {
selector: "button[bitButton], a[bitButton]",
templateUrl: "button.component.html",
providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }],
imports: [NgClass, SpinnerComponent],
imports: [NgClass, NgTemplateOutlet, SpinnerComponent],
hostDirectives: [AriaDisableDirective],
})
export class ButtonComponent implements ButtonLikeAbstraction {
@@ -125,12 +126,23 @@ export class ButtonComponent implements ButtonLikeAbstraction {
readonly buttonType = input<ButtonType>("secondary");
readonly startIcon = input<BitwardenIcon | undefined>(undefined);
readonly endIcon = input<BitwardenIcon | undefined>(undefined);
readonly size = input<ButtonSize>("default");
readonly block = input(false, { transform: booleanAttribute });
readonly loading = model<boolean>(false);
readonly startIconClasses = computed(() => {
return ["bwi", this.startIcon()];
});
readonly endIconClasses = computed(() => {
return ["bwi", this.endIcon()];
});
/**
* Determine whether it is appropriate to display a loading spinner. We only want to show
* a spinner if it's been more than 75 ms since the `loading` state began. This prevents

View File

@@ -152,15 +152,13 @@ export const WithIcon: Story = {
template: /*html*/ `
<span class="tw-flex tw-gap-8">
<div>
<button type="button" bitButton [buttonType]="buttonType" [block]="block">
<i class="bwi bwi-plus tw-me-2"></i>
<button type="button" startIcon="bwi-plus" bitButton [buttonType]="buttonType" [block]="block">
Button label
</button>
</div>
<div>
<button type="button" bitButton [buttonType]="buttonType" [block]="block">
<button type="button" endIcon="bwi-plus" bitButton [buttonType]="buttonType" [block]="block">
Button label
<i class="bwi bwi-plus tw-ms-2"></i>
</button>
</div>
</span>

View File

@@ -113,7 +113,7 @@ export const WithTextButton: Story = {
template: `
<bit-callout ${formatArgsForCodeSnippet<CalloutComponent>(args)}>
<p class="tw-mb-2">The content of the callout</p>
<a bitLink> Visit the help center<i aria-hidden="true" class="bwi bwi-fw bwi-sm bwi-angle-right"></i> </a>
<a bitLink endIcon="bwi-angle-right">Visit the help center</a>
</bit-callout>
`,
}),

View File

@@ -1,6 +1,6 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { AnchorLinkDirective } from "../../link";
import { LinkComponent } from "../../link";
import { TypographyModule } from "../../typography";
import { BaseCardComponent } from "./base-card.component";
@@ -10,7 +10,7 @@ export default {
component: BaseCardComponent,
decorators: [
moduleMetadata({
imports: [AnchorLinkDirective, TypographyModule],
imports: [LinkComponent, TypographyModule],
}),
],
parameters: {

View File

@@ -12,9 +12,10 @@ import {
computed,
signal,
AfterViewInit,
NgZone,
} from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import { combineLatest, switchMap } from "rxjs";
import { combineLatest, firstValueFrom, switchMap } from "rxjs";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -65,6 +66,9 @@ const drawerSizeToWidth = {
})
export class DialogComponent implements AfterViewInit {
private readonly destroyRef = inject(DestroyRef);
private readonly ngZone = inject(NgZone);
private readonly el = inject(ElementRef);
private readonly dialogHeader =
viewChild.required<ElementRef<HTMLHeadingElement>>("dialogHeader");
private readonly scrollableBody = viewChild.required(CdkScrollable);
@@ -144,10 +148,6 @@ export class DialogComponent implements AfterViewInit {
return [...baseClasses, this.width(), ...sizeClasses, ...animationClasses];
});
ngAfterViewInit() {
this.focusOnHeader();
}
handleEsc(event: Event) {
if (!this.dialogRef?.disableClose) {
this.dialogRef?.close();
@@ -159,24 +159,54 @@ export class DialogComponent implements AfterViewInit {
this.animationCompleted.set(true);
}
/**
* Moves focus to the dialog header element.
* This is done automatically when the dialog is opened but can be called manually
* when the contents of the dialog change and focus should be reset.
*/
focusOnHeader(): void {
async ngAfterViewInit() {
/**
* Wait a tick for any focus management to occur on the trigger element before moving focus to
* the dialog header. We choose the dialog header because it is always present, unlike possible
* interactive elements.
*
* We are doing this manually instead of using `cdkTrapFocusAutoCapture` and `cdkFocusInitial`
* because we need this delay behavior.
* Wait for the zone to stabilize before performing any focus behaviors. This ensures that all
* child elements are rendered and stable.
*/
const headerFocusTimeout = setTimeout(() => {
this.dialogHeader().nativeElement.focus();
}, 0);
if (this.ngZone.isStable) {
this.handleAutofocus();
} else {
await firstValueFrom(this.ngZone.onStable);
this.handleAutofocus();
}
}
this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
/**
* Ensure that the user's focus is in the dialog by autofocusing the appropriate element.
*
* If there is a descendant of the dialog with the AutofocusDirective applied, we defer to that.
* If not, we want to fallback to a default behavior of focusing the dialog's header element. We
* choose the dialog header as the default fallback for dialog focus because it is always present,
* unlike possible interactive elements.
*/
handleAutofocus() {
/**
* Angular's contentChildren query cannot see into the internal templates of child components.
* We need to use a regular DOM query instead to see if there are descendants using the
* AutofocusDirective.
*/
const dialogRef = this.el.nativeElement;
// Must match selectors of AutofocusDirective
const autofocusDescendants = dialogRef.querySelectorAll("[appAutofocus], [bitAutofocus]");
const hasAutofocusDescendants = autofocusDescendants.length > 0;
if (!hasAutofocusDescendants) {
/**
* Wait a tick for any focus management to occur on the trigger element before moving focus
* to the dialog header.
*
* We are doing this manually instead of using Angular's built-in focus management
* directives (`cdkTrapFocusAutoCapture` and `cdkFocusInitial`) because we need this delay
* behavior.
*
* And yes, we need the timeout even though we are already waiting for ngZone to stabilize.
*/
const headerFocusTimeout = setTimeout(() => {
this.dialogHeader().nativeElement.focus();
}, 0);
this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
}
}
}

View File

@@ -22,6 +22,8 @@ import { FocusableElement } from "../shared/focusable-element";
*
* If the component provides the `FocusableElement` interface, the `focus`
* method will be called. Otherwise, the native element will be focused.
*
* If selector changes, `dialog.component.ts` must also be updated
*/
@Directive({
selector: "[appAutofocus], [bitAutofocus]",

View File

@@ -5,7 +5,7 @@ import { booleanAttribute, Component, ElementRef, inject, input, viewChild } fro
import { RouterModule } from "@angular/router";
import { DrawerService } from "../dialog/drawer.service";
import { LinkModule } from "../link";
import { LinkComponent, LinkModule } from "../link";
import { SideNavService } from "../navigation/side-nav.service";
import { SharedModule } from "../shared";
@@ -52,11 +52,11 @@ export class LayoutComponent {
*
* @see https://github.com/angular/components/issues/10247#issuecomment-384060265
**/
private readonly skipLink = viewChild.required<ElementRef<HTMLElement>>("skipLink");
private readonly skipLink = viewChild.required<LinkComponent>("skipLink");
handleKeydown(ev: KeyboardEvent) {
if (isNothingFocused()) {
ev.preventDefault();
this.skipLink().nativeElement.focus();
this.skipLink().el.nativeElement.focus();
}
}
}

View File

@@ -1,2 +1,2 @@
export * from "./link.directive";
export * from "./link.component";
export * from "./link.module";

View File

@@ -0,0 +1,11 @@
<div class="tw-flex tw-gap-2 tw-items-center">
@if (startIcon()) {
<i [class]="['bwi', startIcon()]" aria-hidden="true"></i>
}
<span>
<ng-content></ng-content>
</span>
@if (endIcon()) {
<i [class]="['bwi', endIcon()]" aria-hidden="true"></i>
}
</div>

View File

@@ -1,6 +1,14 @@
import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
computed,
input,
booleanAttribute,
inject,
ElementRef,
} from "@angular/core";
import { AriaDisableDirective } from "../a11y";
import { BitwardenIcon } from "../shared/icon";
import { ariaDisableElement } from "../utils";
export const LinkTypes = [
@@ -46,16 +54,16 @@ const commonStyles = [
"tw-transition",
"tw-no-underline",
"tw-cursor-pointer",
"hover:tw-underline",
"hover:tw-decoration-1",
"[&:hover_span]:tw-underline",
"[&.tw-test-hover_span]:tw-underline",
"[&:hover_span]:tw-decoration-[.125em]",
"[&.tw-test-hover_span]:tw-decoration-[.125em]",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
"disabled:!tw-text-fg-disabled",
"disabled:hover:!tw-text-fg-disabled",
"disabled:hover:tw-no-underline",
"focus-visible:tw-outline-none",
"focus-visible:tw-underline",
"focus-visible:tw-decoration-1",
"focus-visible:before:tw-ring-border-focus",
// Workaround for html button tag not being able to be set to `display: inline`
@@ -72,8 +80,12 @@ const commonStyles = [
"before:tw-block",
"before:tw-absolute",
"before:-tw-inset-x-[0.1em]",
"before:-tw-inset-y-[0]",
"before:tw-rounded-md",
"before:tw-transition",
"before:tw-h-full",
"before:tw-w-[calc(100%_+_.25rem)]",
"before:tw-pointer-events-none",
"focus-visible:before:tw-ring-2",
"focus-visible:tw-z-10",
"aria-disabled:tw-no-underline",
@@ -83,47 +95,57 @@ const commonStyles = [
"aria-disabled:hover:tw-no-underline",
];
@Directive()
abstract class LinkDirective {
readonly linkType = input<LinkType>("default");
}
/**
* Text Links and Buttons can use either the `<a>` or `<button>` tags. Choose which based on the action the button takes:
* - if navigating to a new page, use a `<a>`
* - if taking an action on the current page, use a `<button>`
* Text buttons or links are most commonly used in paragraphs of text or in forms to customize actions or show/hide additional form options.
*/
@Directive({
selector: "a[bitLink]",
@Component({
selector: "a[bitLink], button[bitLink]",
templateUrl: "./link.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
"[class]": "classList()",
// This is for us to be able to correctly aria-disable the button and capture clicks.
// It's normally added via the AriaDisableDirective as a host directive.
// But, we're not able to conditionally apply the host directive based on if this is a button or not
"[attr.bit-aria-disable]": "isButton ? true : null",
},
})
export class AnchorLinkDirective extends LinkDirective {
@HostBinding("class") get classList() {
return ["before:-tw-inset-y-[0.125rem]"]
.concat(commonStyles)
.concat(linkStyles[this.linkType()] ?? []);
}
}
@Directive({
selector: "button[bitLink]",
hostDirectives: [AriaDisableDirective],
})
export class ButtonLinkDirective extends LinkDirective {
private el = inject(ElementRef<HTMLButtonElement>);
export class LinkComponent {
readonly el = inject(ElementRef<HTMLElement>);
/**
* The variant of link you want to render
* @default "primary"
*/
readonly linkType = input<LinkType>("primary");
/**
* The leading icon to display within the link
* @default undefined
*/
readonly startIcon = input<BitwardenIcon | undefined>(undefined);
/**
* The trailing icon to display within the link
* @default undefined
*/
readonly endIcon = input<BitwardenIcon | undefined>(undefined);
/**
* Whether the button is disabled
* @default false
* @note Only applicable if the link is rendered as a button
*/
readonly disabled = input(false, { transform: booleanAttribute });
@HostBinding("class") get classList() {
return ["before:-tw-inset-y-[0.25rem]"]
protected readonly isButton = this.el.nativeElement.tagName === "BUTTON";
readonly classList = computed(() => {
return [!this.isButton && "tw-inline-flex"]
.concat(commonStyles)
.concat(linkStyles[this.linkType()] ?? []);
});
focus() {
this.el.nativeElement.focus();
}
constructor() {
super();
ariaDisableElement(this.el.nativeElement, this.disabled);
if (this.isButton) {
ariaDisableElement(this.el.nativeElement, this.disabled);
}
}
}

View File

@@ -1,9 +1,9 @@
import { NgModule } from "@angular/core";
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
import { LinkComponent } from "./link.component";
@NgModule({
imports: [AnchorLinkDirective, ButtonLinkDirective],
exports: [AnchorLinkDirective, ButtonLinkDirective],
imports: [LinkComponent],
exports: [LinkComponent],
})
export class LinkModule {}

View File

@@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { AnchorLinkDirective, ButtonLinkDirective, LinkTypes } from "./link.directive";
import { LinkComponent, LinkTypes } from "./link.component";
import { LinkModule } from "./link.module";
export default {
@@ -26,7 +26,7 @@ export default {
},
} as Meta;
type Story = StoryObj<ButtonLinkDirective>;
type Story = StoryObj<LinkComponent>;
export const Default: Story = {
render: (args) => ({
@@ -40,9 +40,9 @@ export const Default: Story = {
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [class]="backgroundClass">
<a bitLink href="" ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
</div>
<div class="tw-p-2" [class]="backgroundClass">
<a bitLink href="#" ${formatArgsForCodeSnippet<LinkComponent>(args)}>Your text here</a>
</div>
`,
}),
args: {
@@ -181,14 +181,12 @@ export const Buttons: Story = {
<button type="button" bitLink [linkType]="linkType">Button</button>
</div>
<div class="tw-block tw-p-2">
<button type="button" bitLink [linkType]="linkType">
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
<button type="button" bitLink [linkType]="linkType" startIcon="bwi-plus-circle">
Add Icon Button
</button>
</div>
<div class="tw-block tw-p-2">
<button type="button" bitLink [linkType]="linkType">
<i class="bwi bwi-fw bwi-sm bwi-angle-right" aria-hidden="true"></i>
<button type="button" bitLink [linkType]="linkType" endIcon="bwi-angle-right">
Chevron Icon Button
</button>
</div>
@@ -203,7 +201,7 @@ export const Buttons: Story = {
},
};
export const Anchors: StoryObj<AnchorLinkDirective> = {
export const Anchors: StoryObj<LinkComponent> = {
render: (args) => ({
props: {
linkType: args.linkType,
@@ -220,14 +218,12 @@ export const Anchors: StoryObj<AnchorLinkDirective> = {
<a bitLink [linkType]="linkType" href="#">Anchor</a>
</div>
<div class="tw-block tw-p-2">
<a bitLink [linkType]="linkType" href="#">
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
<a bitLink [linkType]="linkType" href="#" startIcon="bwi-plus-circle">
Add Icon Anchor
</a>
</div>
<div class="tw-block tw-p-2">
<a bitLink [linkType]="linkType" href="#">
<i class="bwi bwi-fw bwi-sm bwi-angle-right" aria-hidden="true"></i>
<a bitLink [linkType]="linkType" href="#" endIcon="bwi-angle-right">
Chevron Icon Anchor
</a>
</div>
@@ -247,20 +243,57 @@ export const Inline: Story = {
props: args,
template: /*html*/ `
<span class="tw-text-main">
On the internet paragraphs often contain <a bitLink href="#">inline links</a>, but few know that <button type="button" bitLink>buttons</button> can be used for similar purposes.
On the internet paragraphs often contain <a bitLink href="#">inline links with very long text that might break</a>, but few know that <button type="button" bitLink>buttons</button> can be used for similar purposes.
</span>
`,
}),
};
export const Inactive: Story = {
export const WithIcons: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<button type="button" bitLink disabled linkType="primary" class="tw-me-2">Primary</button>
<button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary</button>
<div class="tw-bg-bg-contrast tw-p-2 tw-inline-block">
<button type="button" bitLink disabled linkType="contrast">Contrast</button>
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
<div class="tw-block tw-p-2">
<a bitLink [linkType]="linkType" href="#" startIcon="bwi-star">Start icon link</a>
</div>
<div class="tw-block tw-p-2">
<a bitLink [linkType]="linkType" href="#" endIcon="bwi-external-link">External link</a>
</div>
<div class="tw-block tw-p-2">
<a bitLink [linkType]="linkType" href="#" startIcon="bwi-angle-left" endIcon="bwi-angle-right">Both icons</a>
</div>
<div class="tw-block tw-p-2">
<button type="button" bitLink [linkType]="linkType" startIcon="bwi-plus-circle">Add item</button>
</div>
<div class="tw-block tw-p-2">
<button type="button" bitLink [linkType]="linkType" endIcon="bwi-angle-right">Next</button>
</div>
<div class="tw-block tw-p-2">
<button type="button" bitLink [linkType]="linkType" startIcon="bwi-download" endIcon="bwi-check">Download complete</button>
</div>
</div>
`,
}),
args: {
linkType: "primary",
},
};
export const Inactive: Story = {
render: (args) => ({
props: {
...args,
onClick: () => {
alert("Button clicked! (This should not appear when disabled)");
},
},
template: /*html*/ `
<button type="button" bitLink (click)="onClick()" disabled linkType="primary" class="tw-me-2">Primary button</button>
<a bitLink href="" disabled linkType="primary" class="tw-me-2">Links can not be inactive</a>
<button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary button</button>
<div class="tw-bg-primary-600 tw-p-2 tw-inline-block">
<button type="button" bitLink disabled linkType="contrast">Contrast button</button>
</div>
`,
}),

View File

@@ -3,13 +3,13 @@ import { Component, inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
ButtonLinkDirective,
ButtonModule,
CenterPositionStrategy,
DialogModule,
DialogRef,
DialogService,
DIALOG_DATA,
DialogRef,
CenterPositionStrategy,
LinkComponent,
} from "@bitwarden/components";
export type AdvancedUriOptionDialogParams = {
@@ -22,7 +22,7 @@ export type AdvancedUriOptionDialogParams = {
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "advanced-uri-option-dialog.component.html",
imports: [ButtonLinkDirective, ButtonModule, DialogModule, JslibModule],
imports: [LinkComponent, ButtonModule, DialogModule, JslibModule],
})
export class AdvancedUriOptionDialogComponent {
constructor(private dialogRef: DialogRef<boolean>) {}

View File

@@ -76,6 +76,9 @@ export class DefaultCipherFormService implements CipherFormService {
.then((res) => res.cipher);
} else {
// Updating a cipher with collection changes is not supported with a single request currently
// Save the new collectionIds before overwriting
const newCollectionIdsToSave = cipher.collectionIds;
// First update the cipher with the original collectionIds
cipher.collectionIds = config.originalCipher.collectionIds;
const newCipher = await this.cipherService.updateWithServer(
@@ -86,7 +89,7 @@ export class DefaultCipherFormService implements CipherFormService {
);
// Then save the new collection changes separately
newCipher.collectionIds = cipher.collectionIds;
newCipher.collectionIds = newCollectionIdsToSave;
// TODO: Remove after migrating all SDK ops
const { cipher: encryptedCipher } = await this.cipherService.encrypt(newCipher, activeUserId);

View File

@@ -12,9 +12,15 @@
</bit-callout>
<bit-callout *ngIf="showChangePasswordLink()" type="warning" [title]="''">
<a bitLink href="#" appStopClick (click)="launchChangePassword()" linkType="secondary">
<a
bitLink
href="#"
appStopClick
(click)="launchChangePassword()"
linkType="secondary"
endIcon="bwi-external-link"
>
{{ "changeAtRiskPassword" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</bit-callout>

View File

@@ -30,7 +30,7 @@ import {
CalloutModule,
SearchModule,
TypographyModule,
AnchorLinkDirective,
LinkComponent,
} from "@bitwarden/components";
import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service";
@@ -66,7 +66,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
ViewIdentitySectionsComponent,
LoginCredentialsViewComponent,
AutofillOptionsViewComponent,
AnchorLinkDirective,
LinkComponent,
TypographyModule,
],
})

View File

@@ -19,9 +19,9 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import {
BadgeModule,
ButtonLinkDirective,
CardComponent,
FormFieldModule,
LinkComponent,
TypographyModule,
} from "@bitwarden/components";
@@ -39,7 +39,7 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
TypographyModule,
OrgIconDirective,
FormFieldModule,
ButtonLinkDirective,
LinkComponent,
BadgeModule,
],
})

View File

@@ -7,7 +7,7 @@ import { CipherId } from "@bitwarden/common/types/guid";
import {
DIALOG_DATA,
DialogRef,
AnchorLinkDirective,
LinkComponent,
AsyncActionsModule,
ButtonModule,
DialogModule,
@@ -32,7 +32,7 @@ export type DecryptionFailureDialogParams = {
JslibModule,
AsyncActionsModule,
ButtonModule,
AnchorLinkDirective,
LinkComponent,
],
})
export class DecryptionFailureDialogComponent {

10
package-lock.json generated
View File

@@ -38,7 +38,7 @@
"bufferutil": "4.1.0",
"chalk": "4.1.2",
"commander": "14.0.0",
"core-js": "3.47.0",
"core-js": "3.48.0",
"form-data": "4.0.4",
"https-proxy-agent": "7.0.6",
"inquirer": "8.2.6",
@@ -205,7 +205,7 @@
"browser-hrtime": "1.1.8",
"chalk": "4.1.2",
"commander": "14.0.0",
"core-js": "3.47.0",
"core-js": "3.48.0",
"form-data": "4.0.4",
"https-proxy-agent": "7.0.6",
"inquirer": "8.2.6",
@@ -20879,9 +20879,9 @@
}
},
"node_modules/core-js": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz",
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
"hasInstallScript": true,
"license": "MIT",
"funding": {

View File

@@ -177,7 +177,7 @@
"bufferutil": "4.1.0",
"chalk": "4.1.2",
"commander": "14.0.0",
"core-js": "3.47.0",
"core-js": "3.48.0",
"form-data": "4.0.4",
"https-proxy-agent": "7.0.6",
"inquirer": "8.2.6",