mirror of
https://github.com/bitwarden/browser
synced 2026-02-18 10:23:52 +00:00
Merge branch 'main' of github.com:bitwarden/clients into feature/PM-30737-Migrate-DeleteAccount
This commit is contained in:
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -156,6 +156,8 @@ apps/desktop/macos/autofill-extension @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/windows_plugin_authenticator @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/autotype @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-autofill-desktop-dev @bitwarden/wg-ssh-keys
|
||||
apps/desktop/desktop_native/ssh_agent @bitwarden/team-autofill-desktop-dev @bitwarden/wg-ssh-keys
|
||||
apps/desktop/desktop_native/napi/src/autofill.rs @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/napi/src/autotype.rs @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/desktop_native/napi/src/sshagent.rs @bitwarden/team-autofill-desktop-dev
|
||||
@@ -164,8 +166,6 @@ apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team-autofill-desktop-dev
|
||||
apps/desktop/src/services/encrypted-message-handler.service.ts @bitwarden/team-autofill-desktop-dev
|
||||
.github/workflows/alert-ddg-files-modified.yml @bitwarden/team-autofill-desktop-dev
|
||||
# SSH Agent
|
||||
apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-autofill-desktop-dev @bitwarden/wg-ssh-keys
|
||||
|
||||
## UI Foundation ##
|
||||
.github/workflows/chromatic.yml @bitwarden/team-ui-foundation
|
||||
|
||||
1
.github/renovate.json5
vendored
1
.github/renovate.json5
vendored
@@ -313,7 +313,6 @@
|
||||
"@types/inquirer",
|
||||
"@types/koa",
|
||||
"@types/koa__multer",
|
||||
"@types/koa__router",
|
||||
"@types/koa-bodyparser",
|
||||
"@types/koa-json",
|
||||
"@types/lunr",
|
||||
|
||||
@@ -3094,29 +3094,9 @@
|
||||
"message": "Send saved",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"sendFilePopoutDialogText": {
|
||||
"message": "Pop out extension?",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"sendFilePopoutDialogDesc": {
|
||||
"message": "To create a file Send, you need to pop out the extension to a new window.",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
},
|
||||
"sendLinuxChromiumFileWarning": {
|
||||
"message": "In order to choose a file, open the extension in the sidebar (if possible) or pop out to a new window by clicking this banner."
|
||||
},
|
||||
"sendFirefoxFileWarning": {
|
||||
"message": "In order to choose a file using Firefox, open the extension in the sidebar or pop out to a new window by clicking this banner."
|
||||
},
|
||||
"sendSafariFileWarning": {
|
||||
"message": "In order to choose a file using Safari, pop out to a new window by clicking this banner."
|
||||
},
|
||||
"popOut": {
|
||||
"message": "Pop out"
|
||||
},
|
||||
"sendFileCalloutHeader": {
|
||||
"message": "Before you start"
|
||||
},
|
||||
"expirationDateIsInvalid": {
|
||||
"message": "The expiration date provided is not valid."
|
||||
},
|
||||
@@ -6123,6 +6103,12 @@
|
||||
"whyAmISeeingThis": {
|
||||
"message": "Why am I seeing this?"
|
||||
},
|
||||
"items": {
|
||||
"message": "Items"
|
||||
},
|
||||
"searchResults": {
|
||||
"message": "Search results"
|
||||
},
|
||||
"resizeSideNavigation": {
|
||||
"message": "Resize side navigation"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -32,4 +32,5 @@ export type BackgroundPortMessageHandlers = {
|
||||
|
||||
export interface AutofillInlineMenuIframeService {
|
||||
initMenuIframe(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,6 +1110,7 @@ export default class MainBackground {
|
||||
this.logService,
|
||||
this.platformUtilsService,
|
||||
this.configService,
|
||||
this.sdkService,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router
|
||||
import { RouteCacheOptions } from "../platform/services/popup-view-cache-background.service";
|
||||
import { CredentialGeneratorHistoryComponent } from "../tools/popup/generator/credential-generator-history.component";
|
||||
import { CredentialGeneratorComponent } from "../tools/popup/generator/credential-generator.component";
|
||||
import { firefoxPopoutGuard } from "../tools/popup/guards/firefox-popout.guard";
|
||||
import { filePickerPopoutGuard } from "../tools/popup/guards/file-picker-popout.guard";
|
||||
import { SendAddEditComponent as SendAddEditV2Component } from "../tools/popup/send-v2/add-edit/send-add-edit.component";
|
||||
import { SendCreatedComponent } from "../tools/popup/send-v2/send-created/send-created.component";
|
||||
import { SendV2Component } from "../tools/popup/send-v2/send-v2.component";
|
||||
@@ -248,7 +248,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "attachments",
|
||||
component: AttachmentsV2Component,
|
||||
canActivate: [authGuard],
|
||||
canActivate: [authGuard, filePickerPopoutGuard()],
|
||||
data: { elevation: 4 } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
@@ -266,7 +266,7 @@ const routes: Routes = [
|
||||
{
|
||||
path: "import",
|
||||
component: ImportBrowserV2Component,
|
||||
canActivate: [authGuard, firefoxPopoutGuard()],
|
||||
canActivate: [authGuard, filePickerPopoutGuard()],
|
||||
data: { elevation: 1 } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
@@ -350,13 +350,13 @@ const routes: Routes = [
|
||||
{
|
||||
path: "add-send",
|
||||
component: SendAddEditV2Component,
|
||||
canActivate: [authGuard, firefoxPopoutGuard()],
|
||||
canActivate: [authGuard, filePickerPopoutGuard()],
|
||||
data: { elevation: 1 } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
path: "edit-send",
|
||||
component: SendAddEditV2Component,
|
||||
canActivate: [authGuard, firefoxPopoutGuard()],
|
||||
canActivate: [authGuard, filePickerPopoutGuard()],
|
||||
data: { elevation: 1 } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -33,7 +33,6 @@ import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.comp
|
||||
import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../platform/popup/layout/popup-page.component";
|
||||
import { PopupTabNavigationComponent } from "../platform/popup/layout/popup-tab-navigation.component";
|
||||
import { FilePopoutCalloutComponent } from "../tools/popup/components/file-popout-callout.component";
|
||||
|
||||
import { AppRoutingModule } from "./app-routing.module";
|
||||
import { AppComponent } from "./app.component";
|
||||
@@ -67,7 +66,6 @@ import "../platform/popup/locales";
|
||||
ScrollingModule,
|
||||
ServicesModule,
|
||||
DialogModule,
|
||||
FilePopoutCalloutComponent,
|
||||
AvatarModule,
|
||||
AccountComponent,
|
||||
ButtonModule,
|
||||
|
||||
@@ -230,7 +230,6 @@ import {
|
||||
isNotificationsSupported,
|
||||
} from "../../platform/system-notifications/browser-system-notification.service";
|
||||
import { fromChromeRuntimeMessaging } from "../../platform/utils/from-chrome-runtime-messaging";
|
||||
import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service";
|
||||
import { BrowserAutofillNudgeService } from "../../vault/popup/services/browser-autofill-nudge.service";
|
||||
import { Fido2UserVerificationService } from "../../vault/services/fido2-user-verification.service";
|
||||
import { ExtensionAnonLayoutWrapperDataService } from "../components/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service";
|
||||
@@ -502,13 +501,6 @@ const safeProviders: SafeProvider[] = [
|
||||
},
|
||||
deps: [PlatformUtilsService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: FilePopoutUtilsService,
|
||||
useFactory: (platformUtilsService: PlatformUtilsService) => {
|
||||
return new FilePopoutUtilsService(platformUtilsService);
|
||||
},
|
||||
deps: [PlatformUtilsService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DerivedStateProvider,
|
||||
useClass: InlineDerivedStateProvider,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<bit-callout
|
||||
type="warning"
|
||||
icon="bwi-external-link bwi-rotate-270 bwi-fw"
|
||||
title="{{ 'sendFileCalloutHeader' | i18n }}"
|
||||
(click)="popOutWindow()"
|
||||
*ngIf="showFilePopoutMessage"
|
||||
>
|
||||
<div *ngIf="showChromiumFileWarning">{{ "sendLinuxChromiumFileWarning" | i18n }}</div>
|
||||
<div *ngIf="showFirefoxFileWarning">{{ "sendFirefoxFileWarning" | i18n }}</div>
|
||||
<div *ngIf="showSafariFileWarning">{{ "sendSafariFileWarning" | i18n }}</div>
|
||||
</bit-callout>
|
||||
@@ -1,37 +0,0 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { CalloutModule } from "@bitwarden/components";
|
||||
|
||||
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
|
||||
import { FilePopoutUtilsService } from "../services/file-popout-utils.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "tools-file-popout-callout",
|
||||
templateUrl: "file-popout-callout.component.html",
|
||||
imports: [CommonModule, JslibModule, CalloutModule],
|
||||
})
|
||||
export class FilePopoutCalloutComponent implements OnInit {
|
||||
protected showFilePopoutMessage: boolean = false;
|
||||
protected showFirefoxFileWarning: boolean = false;
|
||||
protected showSafariFileWarning: boolean = false;
|
||||
protected showChromiumFileWarning: boolean = false;
|
||||
|
||||
constructor(private filePopoutUtilsService: FilePopoutUtilsService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.showFilePopoutMessage = this.filePopoutUtilsService.showFilePopoutMessage(window);
|
||||
this.showFirefoxFileWarning = this.filePopoutUtilsService.showFirefoxFileWarning(window);
|
||||
this.showSafariFileWarning = this.filePopoutUtilsService.showSafariFileWarning(window);
|
||||
this.showChromiumFileWarning = this.filePopoutUtilsService.showChromiumFileWarning(window);
|
||||
}
|
||||
|
||||
popOutWindow() {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
BrowserPopupUtils.openCurrentPagePopout(window);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
|
||||
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
|
||||
import { BrowserPlatformUtilsService } from "../../../platform/services/platform-utils/browser-platform-utils.service";
|
||||
|
||||
import { filePickerPopoutGuard } from "./file-picker-popout.guard";
|
||||
|
||||
describe("filePickerPopoutGuard", () => {
|
||||
let getDeviceSpy: jest.SpyInstance;
|
||||
let inPopoutSpy: jest.SpyInstance;
|
||||
let inSidebarSpy: jest.SpyInstance;
|
||||
let openPopoutSpy: jest.SpyInstance;
|
||||
let closePopupSpy: jest.SpyInstance;
|
||||
|
||||
const mockRoute = {} as ActivatedRouteSnapshot;
|
||||
const mockState: RouterStateSnapshot = {
|
||||
url: "/add-send?type=1",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
beforeEach(() => {
|
||||
getDeviceSpy = jest.spyOn(BrowserPlatformUtilsService, "getDevice");
|
||||
inPopoutSpy = jest.spyOn(BrowserPopupUtils, "inPopout");
|
||||
inSidebarSpy = jest.spyOn(BrowserPopupUtils, "inSidebar");
|
||||
openPopoutSpy = jest.spyOn(BrowserPopupUtils, "openPopout").mockImplementation();
|
||||
closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockImplementation();
|
||||
|
||||
TestBed.configureTestingModule({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Firefox browser", () => {
|
||||
beforeEach(() => {
|
||||
getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should open popout and block navigation when not in popout or sidebar", async () => {
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(getDeviceSpy).toHaveBeenCalledWith(window);
|
||||
expect(inPopoutSpy).toHaveBeenCalledWith(window);
|
||||
expect(inSidebarSpy).toHaveBeenCalledWith(window);
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow navigation when already in popout", async () => {
|
||||
inPopoutSpy.mockReturnValue(true);
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow navigation when already in sidebar", async () => {
|
||||
inSidebarSpy.mockReturnValue(true);
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Safari browser", () => {
|
||||
beforeEach(() => {
|
||||
getDeviceSpy.mockReturnValue(DeviceType.SafariExtension);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should open popout and block navigation when not in popout", async () => {
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(getDeviceSpy).toHaveBeenCalledWith(window);
|
||||
expect(inPopoutSpy).toHaveBeenCalledWith(window);
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow navigation when already in popout", async () => {
|
||||
inPopoutSpy.mockReturnValue(true);
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should not allow sidebar bypass (Safari doesn't support sidebar)", async () => {
|
||||
inSidebarSpy.mockReturnValue(true);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
// Safari requires popout, sidebar is not sufficient
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chromium browsers on Linux", () => {
|
||||
beforeEach(() => {
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
Object.defineProperty(window, "navigator", {
|
||||
value: {
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
appVersion: "5.0 (X11; Linux x86_64)",
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ deviceType: DeviceType.ChromeExtension, name: "Chrome" },
|
||||
{ deviceType: DeviceType.EdgeExtension, name: "Edge" },
|
||||
{ deviceType: DeviceType.OperaExtension, name: "Opera" },
|
||||
{ deviceType: DeviceType.VivaldiExtension, name: "Vivaldi" },
|
||||
])(
|
||||
"should open popout and block navigation for $name on Linux when not in popout or sidebar",
|
||||
async ({ deviceType }) => {
|
||||
getDeviceSpy.mockReturnValue(deviceType);
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("should allow navigation when in popout", async () => {
|
||||
getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension);
|
||||
inPopoutSpy.mockReturnValue(true);
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow navigation when in sidebar", async () => {
|
||||
getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension);
|
||||
inSidebarSpy.mockReturnValue(true);
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chromium browsers on Mac", () => {
|
||||
beforeEach(() => {
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
Object.defineProperty(window, "navigator", {
|
||||
value: {
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
appVersion: "5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ deviceType: DeviceType.ChromeExtension, name: "Chrome" },
|
||||
{ deviceType: DeviceType.EdgeExtension, name: "Edge" },
|
||||
{ deviceType: DeviceType.OperaExtension, name: "Opera" },
|
||||
{ deviceType: DeviceType.VivaldiExtension, name: "Vivaldi" },
|
||||
])(
|
||||
"should open popout and block navigation for $name on Mac when not in popout or sidebar",
|
||||
async ({ deviceType }) => {
|
||||
getDeviceSpy.mockReturnValue(deviceType);
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("should allow navigation when in popout", async () => {
|
||||
getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension);
|
||||
inPopoutSpy.mockReturnValue(true);
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow navigation when in sidebar", async () => {
|
||||
getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension);
|
||||
inSidebarSpy.mockReturnValue(true);
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Chromium browsers on Windows", () => {
|
||||
beforeEach(() => {
|
||||
getDeviceSpy.mockReturnValue(DeviceType.ChromeExtension);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
Object.defineProperty(window, "navigator", {
|
||||
value: {
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
appVersion: "5.0 (Windows NT 10.0; Win64; x64)",
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow navigation without popout on Windows", async () => {
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(getDeviceSpy).toHaveBeenCalledWith(window);
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("File picker routes", () => {
|
||||
beforeEach(() => {
|
||||
getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ route: "/import" },
|
||||
{ route: "/add-send" },
|
||||
{ route: "/edit-send" },
|
||||
{ route: "/attachments" },
|
||||
])("should open popout for $route route", async ({ route }) => {
|
||||
const importState: RouterStateSnapshot = {
|
||||
url: route,
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, importState));
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#" + route);
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Url handling", () => {
|
||||
beforeEach(() => {
|
||||
getDeviceSpy.mockReturnValue(DeviceType.FirefoxExtension);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should preserve query parameters in the popout url", async () => {
|
||||
const stateWithQuery: RouterStateSnapshot = {
|
||||
url: "/import?foo=bar&baz=qux",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithQuery));
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/import?foo=bar&baz=qux");
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
});
|
||||
|
||||
it("should handle urls without query parameters", async () => {
|
||||
const stateWithoutQuery: RouterStateSnapshot = {
|
||||
url: "/simple-path",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
await TestBed.runInInjectionContext(() => guard(mockRoute, stateWithoutQuery));
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/simple-path");
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
});
|
||||
|
||||
it("should not add autoClosePopout parameter to the url", async () => {
|
||||
const guard = filePickerPopoutGuard();
|
||||
await TestBed.runInInjectionContext(() => guard(mockRoute, mockState));
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=1");
|
||||
expect(openPopoutSpy).not.toHaveBeenCalledWith(expect.stringContaining("autoClosePopout"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Send type differentiation", () => {
|
||||
describe("Text Sends (type=0)", () => {
|
||||
it.each([
|
||||
{ deviceType: DeviceType.FirefoxExtension, name: "Firefox" },
|
||||
{ deviceType: DeviceType.SafariExtension, name: "Safari" },
|
||||
{ deviceType: DeviceType.ChromeExtension, name: "Chrome" },
|
||||
{ deviceType: DeviceType.EdgeExtension, name: "Edge" },
|
||||
])(
|
||||
"should allow navigation without popout for new text Sends on $name",
|
||||
async ({ deviceType }) => {
|
||||
getDeviceSpy.mockReturnValue(deviceType);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
|
||||
const textSendState: RouterStateSnapshot = {
|
||||
url: "/add-send?type=0&isNew=true",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, textSendState));
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ deviceType: DeviceType.FirefoxExtension, name: "Firefox" },
|
||||
{ deviceType: DeviceType.SafariExtension, name: "Safari" },
|
||||
{ deviceType: DeviceType.ChromeExtension, name: "Chrome" },
|
||||
{ deviceType: DeviceType.EdgeExtension, name: "Edge" },
|
||||
])("should allow navigation for editing text Sends on $name", async ({ deviceType }) => {
|
||||
getDeviceSpy.mockReturnValue(deviceType);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
|
||||
const editTextSendState: RouterStateSnapshot = {
|
||||
url: "/edit-send?sendId=abc123&type=0",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() =>
|
||||
guard(mockRoute, editTextSendState),
|
||||
);
|
||||
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("File Sends (type=1)", () => {
|
||||
it.each([
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.SafariExtension,
|
||||
name: "Safari",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
expectPopout: false,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
expectPopout: false,
|
||||
},
|
||||
])(
|
||||
"should require popout for a new file Send on $name $os",
|
||||
async ({ deviceType, userAgent, expectPopout }) => {
|
||||
getDeviceSpy.mockReturnValue(deviceType);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
|
||||
if (userAgent) {
|
||||
Object.defineProperty(window, "navigator", {
|
||||
value: { userAgent, appVersion: userAgent },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const fileSendState: RouterStateSnapshot = {
|
||||
url: "/add-send?type=1&isNew=true",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, fileSendState));
|
||||
|
||||
if (expectPopout === false) {
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
} else {
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith(
|
||||
"popup/index.html#/add-send?type=1&isNew=true",
|
||||
);
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.SafariExtension,
|
||||
name: "Safari",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
expectPopout: false,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
expectPopout: true,
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
expectPopout: false,
|
||||
},
|
||||
])(
|
||||
"should require popout for editing a file Send on $name $os",
|
||||
async ({ deviceType, userAgent, expectPopout }) => {
|
||||
getDeviceSpy.mockReturnValue(deviceType);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
|
||||
if (userAgent) {
|
||||
Object.defineProperty(window, "navigator", {
|
||||
value: { userAgent, appVersion: userAgent },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const editFileSendState: RouterStateSnapshot = {
|
||||
url: "/edit-send?sendId=abc123&type=1",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() =>
|
||||
guard(mockRoute, editFileSendState),
|
||||
);
|
||||
|
||||
if (expectPopout === false) {
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
} else {
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith(
|
||||
"popup/index.html#/edit-send?sendId=abc123&type=1",
|
||||
);
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Send routes without type parameter", () => {
|
||||
it.each([
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.SafariExtension,
|
||||
name: "Safari",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
},
|
||||
])(
|
||||
"should default to requiring popout on $name $os",
|
||||
async ({ deviceType, userAgent, os }) => {
|
||||
getDeviceSpy.mockReturnValue(deviceType);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
|
||||
if (userAgent) {
|
||||
Object.defineProperty(window, "navigator", {
|
||||
value: { userAgent, appVersion: userAgent },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const noTypeState: RouterStateSnapshot = {
|
||||
url: "/add-send",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, noTypeState));
|
||||
|
||||
// Windows Chrome/Edge don't need popout
|
||||
const isChromiumOnWindows =
|
||||
(deviceType === DeviceType.ChromeExtension ||
|
||||
deviceType === DeviceType.EdgeExtension) &&
|
||||
os === "Windows";
|
||||
|
||||
if (isChromiumOnWindows) {
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
} else {
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send");
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.FirefoxExtension,
|
||||
name: "Firefox",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.SafariExtension,
|
||||
name: "Safari",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.ChromeExtension,
|
||||
name: "Chrome",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Mac",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Linux",
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64)",
|
||||
},
|
||||
{
|
||||
deviceType: DeviceType.EdgeExtension,
|
||||
name: "Edge",
|
||||
os: "Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||
},
|
||||
])(
|
||||
"should default to requiring popout when type is invalid on $name $os",
|
||||
async ({ deviceType, userAgent, os }) => {
|
||||
getDeviceSpy.mockReturnValue(deviceType);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
|
||||
if (userAgent) {
|
||||
Object.defineProperty(window, "navigator", {
|
||||
value: { userAgent, appVersion: userAgent },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const invalidTypeState: RouterStateSnapshot = {
|
||||
url: "/add-send?type=invalid",
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() =>
|
||||
guard(mockRoute, invalidTypeState),
|
||||
);
|
||||
|
||||
// Windows Chrome/Edge don't need popout
|
||||
const isChromiumOnWindows =
|
||||
(deviceType === DeviceType.ChromeExtension ||
|
||||
deviceType === DeviceType.EdgeExtension) &&
|
||||
os === "Windows";
|
||||
|
||||
if (isChromiumOnWindows) {
|
||||
expect(openPopoutSpy).not.toHaveBeenCalled();
|
||||
expect(closePopupSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
} else {
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith("popup/index.html#/add-send?type=invalid");
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("non-Send routes", () => {
|
||||
it.each([
|
||||
{ deviceType: DeviceType.FirefoxExtension, name: "Firefox", route: "/import" },
|
||||
{ deviceType: DeviceType.FirefoxExtension, name: "Firefox", route: "/attachments" },
|
||||
{ deviceType: DeviceType.SafariExtension, name: "Safari", route: "/import" },
|
||||
{ deviceType: DeviceType.SafariExtension, name: "Safari", route: "/attachments" },
|
||||
])(
|
||||
"should always require popout for $route on $name regardless of query params",
|
||||
async ({ deviceType, route }) => {
|
||||
getDeviceSpy.mockReturnValue(deviceType);
|
||||
inPopoutSpy.mockReturnValue(false);
|
||||
inSidebarSpy.mockReturnValue(false);
|
||||
|
||||
const routeState: RouterStateSnapshot = {
|
||||
url: `${route}?type=0`,
|
||||
} as RouterStateSnapshot;
|
||||
|
||||
const guard = filePickerPopoutGuard();
|
||||
const result = await TestBed.runInInjectionContext(() => guard(mockRoute, routeState));
|
||||
|
||||
expect(openPopoutSpy).toHaveBeenCalledWith(`popup/index.html#${route}?type=0`);
|
||||
expect(closePopupSpy).toHaveBeenCalledWith(window);
|
||||
expect(result).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
apps/browser/src/tools/popup/guards/file-picker-popout.guard.ts
Normal file
109
apps/browser/src/tools/popup/guards/file-picker-popout.guard.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router";
|
||||
|
||||
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "@bitwarden/browser/platform/browser/browser-popup-utils";
|
||||
import { BrowserPlatformUtilsService } from "@bitwarden/browser/platform/services/platform-utils/browser-platform-utils.service";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
|
||||
/**
|
||||
* Composite guard that handles file picker popout requirements for all browsers.
|
||||
* Forces a popout window when file pickers could be exposed on browsers that require it.
|
||||
*
|
||||
* Browser-specific requirements:
|
||||
* - Firefox: Requires sidebar OR popout (crashes with file picker in popup: https://bugzilla.mozilla.org/show_bug.cgi?id=1292701)
|
||||
* - Safari: Requires popout only
|
||||
* - All Chromium browsers (Chrome, Edge, Opera, Vivaldi) on Linux/Mac: Requires sidebar OR popout
|
||||
* - Chromium on Windows: No special requirement
|
||||
*
|
||||
* Send-specific behavior:
|
||||
* - Text Sends: No popout required (no file picker needed)
|
||||
* - File Sends: Popout required on affected browsers
|
||||
*
|
||||
* @returns CanActivateFn that opens popout and blocks navigation when file picker access is needed
|
||||
*/
|
||||
export function filePickerPopoutGuard(): CanActivateFn {
|
||||
return async (_route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
|
||||
// Check if this is a text Send route (no file picker needed)
|
||||
if (isTextOnlySendRoute(state.url)) {
|
||||
return true; // Allow navigation without popout
|
||||
}
|
||||
|
||||
// Check if browser is one that needs popout for file pickers
|
||||
const deviceType = BrowserPlatformUtilsService.getDevice(window);
|
||||
|
||||
// Check current context
|
||||
const inPopout = BrowserPopupUtils.inPopout(window);
|
||||
const inSidebar = BrowserPopupUtils.inSidebar(window);
|
||||
|
||||
let needsPopout = false;
|
||||
|
||||
// Firefox: needs sidebar OR popout to avoid crash with file picker
|
||||
if (deviceType === DeviceType.FirefoxExtension && !inPopout && !inSidebar) {
|
||||
needsPopout = true;
|
||||
}
|
||||
|
||||
// Safari: needs popout only (sidebar not available)
|
||||
if (deviceType === DeviceType.SafariExtension && !inPopout) {
|
||||
needsPopout = true;
|
||||
}
|
||||
|
||||
// Chromium on Linux/Mac: needs sidebar OR popout for file picker access
|
||||
// All Chromium-based browsers (Chrome, Edge, Opera, Vivaldi)
|
||||
// Brave intentionally reports itself as Chrome for compatibility
|
||||
const isChromiumBased = [
|
||||
DeviceType.ChromeExtension,
|
||||
DeviceType.EdgeExtension,
|
||||
DeviceType.OperaExtension,
|
||||
DeviceType.VivaldiExtension,
|
||||
].includes(deviceType);
|
||||
|
||||
const isLinux = window?.navigator?.userAgent?.includes("Linux");
|
||||
const isMac = window?.navigator?.userAgent?.includes("Mac OS X");
|
||||
|
||||
if (isChromiumBased && (isLinux || isMac) && !inPopout && !inSidebar) {
|
||||
needsPopout = true;
|
||||
}
|
||||
|
||||
// Open popout if needed
|
||||
if (needsPopout) {
|
||||
// Don't add autoClosePopout for file picker scenarios - user should manually close
|
||||
await BrowserPopupUtils.openPopout(`popup/index.html#${state.url}`);
|
||||
|
||||
// Close the original popup window
|
||||
BrowserApi.closePopup(window);
|
||||
|
||||
return false; // Block navigation - popout will reload
|
||||
}
|
||||
|
||||
return true; // Allow navigation
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the route is for a text Send that doesn't require file picker display.
|
||||
*
|
||||
* @param url The route URL with query parameters
|
||||
* @returns true if this is a Send route with explicitly text type (SendType.Text = 0)
|
||||
*/
|
||||
function isTextOnlySendRoute(url: string): boolean {
|
||||
// Only apply to Send routes
|
||||
if (!url.includes("/add-send") && !url.includes("/edit-send")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse query parameters to check Send type
|
||||
const queryStartIndex = url.indexOf("?");
|
||||
if (queryStartIndex === -1) {
|
||||
// No query params - default to requiring popout for safety
|
||||
return false;
|
||||
}
|
||||
|
||||
const queryString = url.substring(queryStartIndex + 1);
|
||||
const params = new URLSearchParams(queryString);
|
||||
const typeParam = params.get("type");
|
||||
|
||||
// Only skip popout for explicitly text-based Sends (SendType.Text = 0)
|
||||
// If type is missing, null, or not text, default to requiring popout
|
||||
return typeParam === String(SendType.Text);
|
||||
}
|
||||
@@ -10,8 +10,6 @@
|
||||
>
|
||||
</tools-send-form>
|
||||
|
||||
<send-file-popout-dialog-container [config]="config"></send-file-popout-dialog-container>
|
||||
|
||||
<popup-footer slot="footer">
|
||||
<button bitButton type="submit" form="sendForm" buttonType="primary" #submitBtn>
|
||||
{{ "save" | i18n }}
|
||||
|
||||
@@ -33,7 +33,6 @@ import { PopupBackBrowserDirective } from "../../../../platform/popup/layout/pop
|
||||
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
|
||||
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||
import { SendFilePopoutDialogContainerComponent } from "../send-file-popout-dialog/send-file-popout-dialog-container.component";
|
||||
|
||||
/**
|
||||
* Helper class to parse query parameters for the AddEdit route.
|
||||
@@ -81,7 +80,6 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
|
||||
PopupPageComponent,
|
||||
PopupHeaderComponent,
|
||||
PopupFooterComponent,
|
||||
SendFilePopoutDialogContainerComponent,
|
||||
SendFormModule,
|
||||
AsyncActionsModule,
|
||||
PopupBackBrowserDirective,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, input, OnInit } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { CenterPositionStrategy, DialogService } from "@bitwarden/components";
|
||||
import { SendFormConfig } from "@bitwarden/send-ui";
|
||||
|
||||
import { FilePopoutUtilsService } from "../../services/file-popout-utils.service";
|
||||
|
||||
import { SendFilePopoutDialogComponent } from "./send-file-popout-dialog.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "send-file-popout-dialog-container",
|
||||
templateUrl: "./send-file-popout-dialog-container.component.html",
|
||||
imports: [JslibModule, CommonModule],
|
||||
})
|
||||
export class SendFilePopoutDialogContainerComponent implements OnInit {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
config = input.required<SendFormConfig>();
|
||||
|
||||
constructor(
|
||||
private dialogService: DialogService,
|
||||
private filePopoutUtilsService: FilePopoutUtilsService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
if (
|
||||
this.config().sendType === SendType.File &&
|
||||
this.config().mode === "add" &&
|
||||
this.filePopoutUtilsService.showFilePopoutMessage(window)
|
||||
) {
|
||||
this.dialogService.open(SendFilePopoutDialogComponent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<bit-simple-dialog dialogSize="default">
|
||||
<div bitDialogIcon>
|
||||
<i class="bwi bwi-info-circle bwi-2x tw-text-info" aria-hidden="true"></i>
|
||||
</div>
|
||||
<ng-container bitDialogContent>
|
||||
<div bitTypography="h3">
|
||||
{{ "sendFilePopoutDialogText" | i18n }}
|
||||
</div>
|
||||
<div bitTypography="body1">{{ "sendFilePopoutDialogDesc" | i18n }}</div>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button buttonType="primary" bitButton type="button" (click)="popOutWindow()">
|
||||
{{ "popOut" | i18n }}
|
||||
<i class="bwi bwi-popout tw-ml-1" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button bitButton buttonType="secondary" type="button" (click)="close()">
|
||||
{{ "cancel" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
@@ -1,26 +0,0 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ButtonModule, DialogModule, DialogService, TypographyModule } from "@bitwarden/components";
|
||||
|
||||
import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "send-file-popout-dialog",
|
||||
templateUrl: "./send-file-popout-dialog.component.html",
|
||||
imports: [JslibModule, CommonModule, DialogModule, ButtonModule, TypographyModule],
|
||||
})
|
||||
export class SendFilePopoutDialogComponent {
|
||||
constructor(private dialogService: DialogService) {}
|
||||
|
||||
async popOutWindow() {
|
||||
await BrowserPopupUtils.openCurrentPagePopout(window);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.dialogService.closeAll();
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
|
||||
|
||||
/**
|
||||
* Service for determining whether to display file popout callout messages.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FilePopoutUtilsService {
|
||||
/**
|
||||
* Creates an instance of FilePopoutUtilsService.
|
||||
*/
|
||||
constructor(private platformUtilsService: PlatformUtilsService) {}
|
||||
|
||||
/**
|
||||
* Determines whether to show any file popout callout message in the current browser.
|
||||
* @param win - The window context in which the check should be performed.
|
||||
* @returns True if a file popout callout message should be displayed; otherwise, false.
|
||||
*/
|
||||
showFilePopoutMessage(win: Window): boolean {
|
||||
return (
|
||||
this.showFirefoxFileWarning(win) ||
|
||||
this.showSafariFileWarning(win) ||
|
||||
this.showChromiumFileWarning(win)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether to show a file popout callout message for the Firefox browser
|
||||
* @param win - The window context in which the check should be performed.
|
||||
* @returns True if the extension is not in a sidebar or popout; otherwise, false.
|
||||
*/
|
||||
showFirefoxFileWarning(win: Window): boolean {
|
||||
return (
|
||||
this.platformUtilsService.isFirefox() &&
|
||||
!(BrowserPopupUtils.inSidebar(win) || BrowserPopupUtils.inPopout(win))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether to show a file popout message for the Safari browser
|
||||
* @param win - The window context in which the check should be performed.
|
||||
* @returns True if the extension is not in a popout; otherwise, false.
|
||||
*/
|
||||
showSafariFileWarning(win: Window): boolean {
|
||||
return this.platformUtilsService.isSafari() && !BrowserPopupUtils.inPopout(win);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether to show a file popout callout message for Chromium-based browsers in Linux and Mac OS X
|
||||
* @param win - The window context in which the check should be performed.
|
||||
* @returns True if the extension is not in a sidebar or popout; otherwise, false.
|
||||
*/
|
||||
showChromiumFileWarning(win: Window): boolean {
|
||||
return (
|
||||
(this.isLinux(win) || this.isUnsupportedMac(win)) &&
|
||||
!this.platformUtilsService.isFirefox() &&
|
||||
!(BrowserPopupUtils.inSidebar(win) || BrowserPopupUtils.inPopout(win))
|
||||
);
|
||||
}
|
||||
|
||||
private isLinux(win: Window): boolean {
|
||||
return win?.navigator?.userAgent.indexOf("Linux") !== -1;
|
||||
}
|
||||
|
||||
private isUnsupportedMac(win: Window): boolean {
|
||||
return this.platformUtilsService.isChrome() && win?.navigator?.appVersion.includes("Mac OS X");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
type="button"
|
||||
(click)="openAttachments()"
|
||||
[disabled]="parentFormDisabled"
|
||||
[title]="'popOutNewWindow' | i18n"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ "attachments" | i18n }}
|
||||
<app-premium-badge></app-premium-badge>
|
||||
</div>
|
||||
<ng-container slot="end">
|
||||
<i class="bwi bwi-popout" aria-hidden="true" *ngIf="openAttachmentsInPopout"></i>
|
||||
<i class="bwi bwi-angle-right" aria-hidden="true" *ngIf="!openAttachmentsInPopout"></i>
|
||||
<span class="tw-sr-only">{{ "popOutNewWindow" | i18n }}</span>
|
||||
<i class="bwi bwi-popout tw-text-muted" aria-hidden="true"></i>
|
||||
</ng-container>
|
||||
</button>
|
||||
</bit-item>
|
||||
|
||||
@@ -20,9 +20,6 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { CipherFormContainer } from "@bitwarden/vault";
|
||||
|
||||
import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils";
|
||||
import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service";
|
||||
|
||||
import { OpenAttachmentsComponent } from "./open-attachments.component";
|
||||
|
||||
describe("OpenAttachmentsComponent", () => {
|
||||
@@ -31,9 +28,6 @@ describe("OpenAttachmentsComponent", () => {
|
||||
let router: Router;
|
||||
const showToast = jest.fn();
|
||||
const hasPremiumFromAnySource$ = new BehaviorSubject<boolean>(true);
|
||||
const openCurrentPagePopout = jest
|
||||
.spyOn(BrowserPopupUtils, "openCurrentPagePopout")
|
||||
.mockResolvedValue(null);
|
||||
const cipherView = {
|
||||
id: "5555-444-3333",
|
||||
type: CipherType.Login,
|
||||
@@ -55,7 +49,6 @@ describe("OpenAttachmentsComponent", () => {
|
||||
|
||||
const getCipher = jest.fn().mockResolvedValue(cipherDomain);
|
||||
const organizations$ = jest.fn().mockReturnValue(of([org]));
|
||||
const showFilePopoutMessage = jest.fn().mockReturnValue(false);
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const accountService = {
|
||||
@@ -70,11 +63,9 @@ describe("OpenAttachmentsComponent", () => {
|
||||
const formStatusChange$ = new BehaviorSubject<"enabled" | "disabled">("enabled");
|
||||
|
||||
beforeEach(async () => {
|
||||
openCurrentPagePopout.mockClear();
|
||||
getCipher.mockClear();
|
||||
showToast.mockClear();
|
||||
organizations$.mockClear();
|
||||
showFilePopoutMessage.mockClear();
|
||||
hasPremiumFromAnySource$.next(true);
|
||||
formStatusChange$.next("enabled");
|
||||
|
||||
@@ -103,10 +94,6 @@ describe("OpenAttachmentsComponent", () => {
|
||||
provide: OrganizationService,
|
||||
useValue: { organizations$ },
|
||||
},
|
||||
{
|
||||
provide: FilePopoutUtilsService,
|
||||
useValue: { showFilePopoutMessage },
|
||||
},
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: accountService,
|
||||
@@ -130,8 +117,7 @@ describe("OpenAttachmentsComponent", () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("opens attachments in new popout", async () => {
|
||||
showFilePopoutMessage.mockReturnValue(true);
|
||||
it("navigates to attachments route", async () => {
|
||||
component.canAccessAttachments = true;
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -140,20 +126,6 @@ describe("OpenAttachmentsComponent", () => {
|
||||
expect(router.navigate).toHaveBeenCalledWith(["/attachments"], {
|
||||
queryParams: { cipherId: "5555-444-3333" },
|
||||
});
|
||||
expect(openCurrentPagePopout).toHaveBeenCalledWith(window);
|
||||
});
|
||||
|
||||
it("opens attachments in same window", async () => {
|
||||
showFilePopoutMessage.mockReturnValue(false);
|
||||
component.canAccessAttachments = true;
|
||||
await component.ngOnInit();
|
||||
|
||||
await component.openAttachments();
|
||||
|
||||
expect(openCurrentPagePopout).not.toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(["/attachments"], {
|
||||
queryParams: { cipherId: "5555-444-3333" },
|
||||
});
|
||||
});
|
||||
|
||||
it("routes the user to the premium page when they cannot access premium features", async () => {
|
||||
|
||||
@@ -23,9 +23,6 @@ import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstraction
|
||||
import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components";
|
||||
import { CipherFormContainer } from "@bitwarden/vault";
|
||||
|
||||
import BrowserPopupUtils from "../../../../../../platform/browser/browser-popup-utils";
|
||||
import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -46,9 +43,6 @@ export class OpenAttachmentsComponent implements OnInit {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input({ required: true }) cipherId: CipherId;
|
||||
|
||||
/** True when the attachments window should be opened in a popout */
|
||||
openAttachmentsInPopout: boolean;
|
||||
|
||||
/** True when the user has access to premium or h */
|
||||
canAccessAttachments: boolean;
|
||||
|
||||
@@ -65,7 +59,6 @@ export class OpenAttachmentsComponent implements OnInit {
|
||||
private organizationService: OrganizationService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private filePopoutUtilsService: FilePopoutUtilsService,
|
||||
private accountService: AccountService,
|
||||
private cipherFormContainer: CipherFormContainer,
|
||||
private premiumUpgradeService: PremiumUpgradePromptService,
|
||||
@@ -87,8 +80,6 @@ export class OpenAttachmentsComponent implements OnInit {
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.openAttachmentsInPopout = this.filePopoutUtilsService.showFilePopoutMessage(window);
|
||||
|
||||
if (!this.cipherId) {
|
||||
return;
|
||||
}
|
||||
@@ -131,12 +122,5 @@ export class OpenAttachmentsComponent implements OnInit {
|
||||
}
|
||||
|
||||
await this.router.navigate(["/attachments"], { queryParams: { cipherId: this.cipherId } });
|
||||
|
||||
// Open the attachments page in a popout
|
||||
// This is done after the router navigation to ensure that the navigation
|
||||
// is included in the `PopupRouterCacheService` history
|
||||
if (this.openAttachmentsInPopout) {
|
||||
await BrowserPopupUtils.openCurrentPagePopout(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -413,29 +421,13 @@ describe("VaultV2Component", () => {
|
||||
expect(PremiumUpgradeDialogComponent.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("navigateToImport navigates and opens popout if popup is open", fakeAsync(async () => {
|
||||
(BrowserApi.isPopupOpen as jest.Mock).mockResolvedValueOnce(true);
|
||||
|
||||
it("navigateToImport navigates to import route", fakeAsync(async () => {
|
||||
const ngRouter = TestBed.inject(Router);
|
||||
jest.spyOn(ngRouter, "navigate").mockResolvedValue(true as any);
|
||||
|
||||
await component["navigateToImport"]();
|
||||
|
||||
expect(ngRouter.navigate).toHaveBeenCalledWith(["/import"]);
|
||||
|
||||
expect(BrowserPopupUtils.openCurrentPagePopout).toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("navigateToImport does not popout when popup is not open", fakeAsync(async () => {
|
||||
(BrowserApi.isPopupOpen as jest.Mock).mockResolvedValueOnce(false);
|
||||
|
||||
const ngRouter = TestBed.inject(Router);
|
||||
jest.spyOn(ngRouter, "navigate").mockResolvedValue(true as any);
|
||||
|
||||
await component["navigateToImport"]();
|
||||
|
||||
expect(ngRouter.navigate).toHaveBeenCalledWith(["/import"]);
|
||||
expect(BrowserPopupUtils.openCurrentPagePopout).not.toHaveBeenCalled();
|
||||
}));
|
||||
|
||||
it("ngOnInit dismisses intro carousel and opens decryption dialog for non-deleted failures", fakeAsync(() => {
|
||||
@@ -607,6 +599,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();
|
||||
|
||||
@@ -56,8 +56,6 @@ import {
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
|
||||
import { BrowserApi } from "../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils";
|
||||
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
|
||||
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||
@@ -160,6 +158,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$;
|
||||
@@ -365,9 +368,6 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
||||
|
||||
async navigateToImport() {
|
||||
await this.router.navigate(["/import"]);
|
||||
if (await BrowserApi.isPopupOpen()) {
|
||||
await BrowserPopupUtils.openCurrentPagePopout(window);
|
||||
}
|
||||
}
|
||||
|
||||
async dismissVaultNudgeSpotlight(type: NudgeType) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</a>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
<button type="button" bit-item-content (click)="import()">
|
||||
<button type="button" bit-item-content (click)="import()" [title]="'popOutNewWindow' | i18n">
|
||||
<div class="tw-flex tw-items-center tw-justify-center tw-gap-2">
|
||||
<p>{{ "importNoun" | i18n }}</p>
|
||||
<span
|
||||
@@ -25,7 +25,10 @@
|
||||
1
|
||||
</span>
|
||||
</div>
|
||||
<i slot="end" class="bwi bwi-popout" aria-hidden="true"></i>
|
||||
<ng-container slot="end">
|
||||
<span class="tw-sr-only">{{ "popOutNewWindow" | i18n }}</span>
|
||||
<i class="bwi bwi-popout tw-text-muted" aria-hidden="true"></i>
|
||||
</ng-container>
|
||||
</button>
|
||||
</bit-item>
|
||||
<bit-item>
|
||||
|
||||
@@ -15,8 +15,6 @@ import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstraction
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { BadgeComponent, ItemModule, ToastOptions, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
|
||||
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
|
||||
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
|
||||
@@ -90,9 +88,6 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy {
|
||||
|
||||
async import() {
|
||||
await this.router.navigate(["/import"]);
|
||||
if (await BrowserApi.isPopupOpen()) {
|
||||
await BrowserPopupUtils.openCurrentPagePopout(window);
|
||||
}
|
||||
}
|
||||
|
||||
async sync() {
|
||||
|
||||
@@ -64,12 +64,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@koa/multer": "4.0.0",
|
||||
"@koa/router": "14.0.0",
|
||||
"@koa/router": "15.2.0",
|
||||
"big-integer": "1.6.52",
|
||||
"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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
|
||||
import * as koaRouter from "@koa/router";
|
||||
import { Router } from "@koa/router";
|
||||
import { OptionValues } from "commander";
|
||||
import * as koa from "koa";
|
||||
import * as koaBodyParser from "koa-bodyparser";
|
||||
@@ -29,7 +29,7 @@ export class ServeCommand {
|
||||
);
|
||||
|
||||
const server = new koa();
|
||||
const router = new koaRouter();
|
||||
const router = new Router();
|
||||
process.env.BW_SERVE = "true";
|
||||
process.env.BW_NOINTERACTION = "true";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import * as koaMulter from "@koa/multer";
|
||||
import * as koaRouter from "@koa/router";
|
||||
import { Router } from "@koa/router";
|
||||
import * as koa from "koa";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
@@ -218,7 +218,7 @@ export class OssServeConfigurator {
|
||||
);
|
||||
}
|
||||
|
||||
async configureRouter(router: koaRouter) {
|
||||
async configureRouter(router: Router) {
|
||||
router.get("/generate", async (ctx, next) => {
|
||||
const response = await this.generateCommand.run(ctx.request.query);
|
||||
this.processResponse(ctx.response, response);
|
||||
|
||||
@@ -928,6 +928,7 @@ export class ServiceContainer {
|
||||
this.logService,
|
||||
this.platformUtilsService,
|
||||
this.configService,
|
||||
this.sdkService,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
16
apps/desktop/desktop_native/Cargo.lock
generated
16
apps/desktop/desktop_native/Cargo.lock
generated
@@ -377,9 +377,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.7.3"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "basic-toml"
|
||||
@@ -3295,6 +3295,9 @@ dependencies = [
|
||||
"bcrypt-pbkdf",
|
||||
"ed25519-dalek",
|
||||
"num-bigint-dig",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
"rand_core 0.6.4",
|
||||
"rsa",
|
||||
"sec1",
|
||||
@@ -3306,6 +3309,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ssh_agent"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"ssh-key",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.0"
|
||||
|
||||
@@ -9,6 +9,7 @@ members = [
|
||||
"napi",
|
||||
"process_isolation",
|
||||
"proxy",
|
||||
"ssh_agent",
|
||||
"windows_plugin_authenticator",
|
||||
]
|
||||
|
||||
|
||||
19
apps/desktop/desktop_native/ssh_agent/Cargo.toml
Normal file
19
apps/desktop/desktop_native/ssh_agent/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "ssh_agent"
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
version = { workspace = true }
|
||||
publish = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
ssh-key = { version = "=0.6.7", features = [
|
||||
"encryption",
|
||||
"ed25519",
|
||||
"rsa",
|
||||
"rand_core",
|
||||
] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
184
apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs
Normal file
184
apps/desktop/desktop_native/ssh_agent/src/crypto/mod.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! Cryptographic key management for the SSH agent.
|
||||
//!
|
||||
//! This module provides the core primitive types and functionality for managing
|
||||
//! SSH keys in the Bitwarden SSH agent.
|
||||
//!
|
||||
//! # Supported signing algorithms
|
||||
//!
|
||||
//! - Ed25519
|
||||
//! - RSA
|
||||
//!
|
||||
//! ECDSA keys are not currently supported (PM-29894)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use ssh_key::private::{Ed25519Keypair, RsaKeypair};
|
||||
|
||||
/// Represents an SSH key and its associated metadata.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SSHKeyData {
|
||||
/// Private key of the key pair
|
||||
private_key: PrivateKey,
|
||||
/// Public key of the key pair
|
||||
public_key: PublicKey,
|
||||
/// Human-readable name
|
||||
name: String,
|
||||
/// Vault cipher ID associated with the key pair
|
||||
cipher_id: String,
|
||||
}
|
||||
|
||||
impl SSHKeyData {
|
||||
/// Creates a new `SSHKeyData` instance.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `private_key` - The private key component
|
||||
/// * `public_key` - The public key component
|
||||
/// * `name` - A human-readable name for the key
|
||||
/// * `cipher_id` - The vault cipher identifier associated with this key
|
||||
pub(crate) fn new(
|
||||
private_key: PrivateKey,
|
||||
public_key: PublicKey,
|
||||
name: String,
|
||||
cipher_id: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
private_key,
|
||||
public_key,
|
||||
name,
|
||||
cipher_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// # Returns
|
||||
///
|
||||
/// A reference to the [`PublicKey`].
|
||||
pub(crate) fn public_key(&self) -> &PublicKey {
|
||||
&self.public_key
|
||||
}
|
||||
|
||||
/// # Returns
|
||||
///
|
||||
/// A reference to the [`PrivateKey`].
|
||||
pub(crate) fn private_key(&self) -> &PrivateKey {
|
||||
&self.private_key
|
||||
}
|
||||
|
||||
/// # Returns
|
||||
///
|
||||
/// A reference to the human-readable name for this key.
|
||||
pub(crate) fn name(&self) -> &String {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// # Returns
|
||||
///
|
||||
/// A reference to the cipher ID that links this key to a vault entry.
|
||||
pub(crate) fn cipher_id(&self) -> &String {
|
||||
&self.cipher_id
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an SSH private key.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub(crate) enum PrivateKey {
|
||||
Ed25519(Ed25519Keypair),
|
||||
Rsa(RsaKeypair),
|
||||
}
|
||||
|
||||
impl TryFrom<ssh_key::private::PrivateKey> for PrivateKey {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(key: ssh_key::private::PrivateKey) -> Result<Self, Self::Error> {
|
||||
match key.algorithm() {
|
||||
ssh_key::Algorithm::Ed25519 => Ok(Self::Ed25519(
|
||||
key.key_data()
|
||||
.ed25519()
|
||||
.ok_or(anyhow!("Failed to parse ed25519 key"))?
|
||||
.to_owned(),
|
||||
)),
|
||||
ssh_key::Algorithm::Rsa { hash: _ } => Ok(Self::Rsa(
|
||||
key.key_data()
|
||||
.rsa()
|
||||
.ok_or(anyhow!("Failed to parse RSA key"))?
|
||||
.to_owned(),
|
||||
)),
|
||||
_ => Err(anyhow!("Unsupported key type")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an SSH public key.
|
||||
///
|
||||
/// Contains the algorithm identifier (e.g., "ssh-ed25519", "ssh-rsa")
|
||||
/// and the binary blob of the public key data.
|
||||
#[derive(Clone, Ord, Eq, PartialOrd, PartialEq)]
|
||||
pub(crate) struct PublicKey {
|
||||
pub alg: String,
|
||||
pub blob: Vec<u8>,
|
||||
}
|
||||
|
||||
impl PublicKey {
|
||||
pub(crate) fn alg(&self) -> &str {
|
||||
&self.alg
|
||||
}
|
||||
|
||||
pub(crate) fn blob(&self) -> &[u8] {
|
||||
&self.blob
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for PublicKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "PublicKey(\"{self}\")")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PublicKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
use base64::{prelude::BASE64_STANDARD, Engine as _};
|
||||
|
||||
write!(f, "{} {}", self.alg(), BASE64_STANDARD.encode(self.blob()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ssh_key::{
|
||||
private::{Ed25519Keypair, RsaKeypair},
|
||||
rand_core::OsRng,
|
||||
LineEnding,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
const MIN_KEY_BIT_SIZE: usize = 2048;
|
||||
|
||||
fn create_valid_ed25519_key_string() -> String {
|
||||
let ed25519_keypair = Ed25519Keypair::random(&mut OsRng);
|
||||
let ssh_key =
|
||||
ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Ed25519(ed25519_keypair), "")
|
||||
.unwrap();
|
||||
ssh_key.to_openssh(LineEnding::LF).unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_privatekey_from_ed25519() {
|
||||
let key_string = create_valid_ed25519_key_string();
|
||||
let ssh_key = ssh_key::PrivateKey::from_openssh(&key_string).unwrap();
|
||||
|
||||
let private_key = PrivateKey::try_from(ssh_key).unwrap();
|
||||
assert!(matches!(private_key, PrivateKey::Ed25519(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_privatekey_from_rsa() {
|
||||
let rsa_keypair = RsaKeypair::random(&mut OsRng, MIN_KEY_BIT_SIZE).unwrap();
|
||||
let ssh_key =
|
||||
ssh_key::PrivateKey::new(ssh_key::private::KeypairData::Rsa(rsa_keypair), "").unwrap();
|
||||
|
||||
let private_key = PrivateKey::try_from(ssh_key).unwrap();
|
||||
assert!(matches!(private_key, PrivateKey::Rsa(_)));
|
||||
}
|
||||
}
|
||||
7
apps/desktop/desktop_native/ssh_agent/src/lib.rs
Normal file
7
apps/desktop/desktop_native/ssh_agent/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! Bitwarden SSH Agent implementation
|
||||
//!
|
||||
//! <https://www.ietf.org/archive/id/draft-miller-ssh-agent-11.html#RFC4253>
|
||||
|
||||
#![allow(dead_code)] // TODO remove when all code is used in follow-up PR
|
||||
|
||||
mod crypto;
|
||||
@@ -6,6 +6,7 @@
|
||||
(onCipherClicked)="viewCipher($event)"
|
||||
(onCipherRightClicked)="viewCipherMenu($event)"
|
||||
(onAddCipher)="addCipher($event)"
|
||||
(onAddFolder)="addFolder()"
|
||||
[showPremiumCallout]="showPremiumCallout$ | async"
|
||||
>
|
||||
</app-vault-items-v2>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1318,7 +1318,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
selectedCollection?.node.id === c.id
|
||||
) {
|
||||
void this.router.navigate([], {
|
||||
queryParams: { collectionId: selectedCollection.parent.node.id ?? null },
|
||||
queryParams: { collectionId: selectedCollection.parent?.node.id ?? null },
|
||||
queryParamsHandling: "merge",
|
||||
replaceUrl: true,
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<bit-nav-item
|
||||
icon="bwi-msp"
|
||||
[text]="'integrations' | i18n"
|
||||
route="integrations"
|
||||
route="integrations/single-sign-on"
|
||||
*ngIf="integrationPageEnabled$ | async"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
|
||||
@@ -14,24 +14,70 @@
|
||||
<div class="tw-mt-4 tw-flex tw-items-center">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "from" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="datetime-local"
|
||||
placeholder="{{ 'startDate' | i18n }}"
|
||||
formControlName="start"
|
||||
(change)="dirtyDates = true"
|
||||
/>
|
||||
<div class="tw-relative">
|
||||
<input
|
||||
#startInput
|
||||
bitInput
|
||||
type="datetime-local"
|
||||
placeholder="{{ 'startDate' | i18n }}"
|
||||
formControlName="start"
|
||||
(change)="dirtyDates = true"
|
||||
class="datetime-input"
|
||||
/>
|
||||
<svg
|
||||
class="datetime-calendar-icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Open date picker"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="startInput.showPicker()"
|
||||
(keydown.enter)="startInput.showPicker()"
|
||||
(keydown.space)="startInput.showPicker()"
|
||||
>
|
||||
<path
|
||||
d="M14 2h-1V1a1 1 0 00-2 0v1H5V1a1 1 0 00-2 0v1H2a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V4a2 2 0 00-2-2zM2 14V6h12v8H2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</bit-form-field>
|
||||
<span class="tw-mx-2">-</span>
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "to" | i18n }}</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="datetime-local"
|
||||
placeholder="{{ 'endDate' | i18n }}"
|
||||
formControlName="end"
|
||||
(change)="dirtyDates = true"
|
||||
/>
|
||||
<div class="tw-relative">
|
||||
<input
|
||||
#endInput
|
||||
bitInput
|
||||
type="datetime-local"
|
||||
placeholder="{{ 'endDate' | i18n }}"
|
||||
formControlName="end"
|
||||
(change)="dirtyDates = true"
|
||||
class="datetime-input"
|
||||
/>
|
||||
<svg
|
||||
class="datetime-calendar-icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Open date picker"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="endInput.showPicker()"
|
||||
(keydown.enter)="endInput.showPicker()"
|
||||
(keydown.space)="endInput.showPicker()"
|
||||
>
|
||||
<path
|
||||
d="M14 2h-1V1a1 1 0 00-2 0v1H5V1a1 1 0 00-2 0v1H2a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V4a2 2 0 00-2-2zM2 14V6h12v8H2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</bit-form-field>
|
||||
<form>
|
||||
<button
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<bit-callout
|
||||
type="danger"
|
||||
*ngIf="nonCompliantMembers"
|
||||
*ngIf="nonCompliantMembers && !isRevoking"
|
||||
title="{{ 'nonCompliantMembersTitle' | i18n }}"
|
||||
>
|
||||
{{ "nonCompliantMembersError" | i18n }}
|
||||
|
||||
@@ -88,12 +88,9 @@ export class BulkRestoreRevokeComponent {
|
||||
const bulkMessage = this.isRevoking ? "bulkRevokedMessage" : "bulkRestoredMessage";
|
||||
|
||||
response.data.forEach(async (entry) => {
|
||||
const error =
|
||||
entry.error !== ""
|
||||
? this.i18nService.t("cannotRestoreAccessError")
|
||||
: this.i18nService.t(bulkMessage);
|
||||
this.statuses.set(entry.id, error);
|
||||
if (entry.error !== "") {
|
||||
const status = entry.error !== "" ? entry.error : this.i18nService.t(bulkMessage);
|
||||
this.statuses.set(entry.id, status);
|
||||
if (entry.error !== "" && !this.isRevoking) {
|
||||
this.nonCompliantMembers = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
*ngIf="showBulkReinviteUsers"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
|
||||
{{ "reinviteSelected" | i18n }}
|
||||
{{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -597,6 +597,16 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
.every((member) => member.managedByOrganization && validStatuses.includes(member.status));
|
||||
}
|
||||
|
||||
get selectedInvitedCount(): number {
|
||||
return this.dataSource
|
||||
.getCheckedUsers()
|
||||
.filter((member) => member.status === this.userStatusType.Invited).length;
|
||||
}
|
||||
|
||||
get isSingleInvite(): boolean {
|
||||
return this.selectedInvitedCount === 1;
|
||||
}
|
||||
|
||||
exportMembers = () => {
|
||||
const result = this.memberExportService.getMemberExport(this.dataSource.data);
|
||||
if (result.success) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@let bulkActions = bulkMenuOptions$ | async;
|
||||
@let showConfirmBanner = showConfirmBanner$ | async;
|
||||
@let isProcessing = this.isProcessing();
|
||||
@let isSingleInvite = isSingleInvite$ | async;
|
||||
|
||||
@if (organization && dataSource) {
|
||||
<app-organization-free-trial-warning
|
||||
@@ -151,7 +152,7 @@
|
||||
(click)="isProcessing ? null : bulkReinvite(organization)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
|
||||
{{ "reinviteSelected" | i18n }}
|
||||
{{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (bulkActions.showBulkConfirmUsers) {
|
||||
|
||||
@@ -125,6 +125,16 @@ export class vNextMembersComponent {
|
||||
.usersUpdated()
|
||||
.pipe(map(() => showConfirmBanner(this.dataSource())));
|
||||
|
||||
protected selectedInvitedCount$ = this.dataSource()
|
||||
.usersUpdated()
|
||||
.pipe(
|
||||
map(
|
||||
(members) => members.filter((m) => m.status === OrganizationUserStatusType.Invited).length,
|
||||
),
|
||||
);
|
||||
|
||||
protected isSingleInvite$ = this.selectedInvitedCount$.pipe(map((count) => count === 1));
|
||||
|
||||
protected isProcessing = this.memberActionsService.isProcessing;
|
||||
|
||||
protected readonly canUseSecretsManager: Signal<boolean> = computed(
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
bitTypography="h1"
|
||||
noMargin
|
||||
class="tw-m-0 tw-mr-2 tw-leading-10 tw-flex tw-gap-1"
|
||||
[title]="title || (routeData.titleId | i18n)"
|
||||
[title]="title() || (routeData.titleId | i18n)"
|
||||
>
|
||||
<div class="tw-truncate">
|
||||
<i *ngIf="icon" class="bwi {{ icon }}" aria-hidden="true"></i>
|
||||
{{ title || (routeData.titleId | i18n) }}
|
||||
<i *ngIf="icon" class="bwi {{ icon() }}" aria-hidden="true"></i>
|
||||
{{ title() || (routeData.titleId | i18n) }}
|
||||
</div>
|
||||
<div><ng-content select="[slot=title-suffix]"></ng-content></div>
|
||||
</h1>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { Component, input, InputSignal } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { map, Observable } from "rxjs";
|
||||
|
||||
@@ -25,19 +23,15 @@ export class WebHeaderComponent {
|
||||
/**
|
||||
* Custom title that overrides the route data `titleId`
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() title: string;
|
||||
readonly title: InputSignal<string | undefined> = input();
|
||||
|
||||
/**
|
||||
* Icon to show before the title
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() icon: string;
|
||||
readonly icon: InputSignal<string | undefined> = input();
|
||||
|
||||
protected routeData$: Observable<{ titleId: string }>;
|
||||
protected account$: Observable<User & { id: UserId }>;
|
||||
protected account$: Observable<(User & { id: UserId }) | null>;
|
||||
protected canLock$: Observable<boolean>;
|
||||
protected selfHosted: boolean;
|
||||
protected hostname = location.hostname;
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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([], {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
"accessIntelligence": {
|
||||
"message": "Access Intelligence"
|
||||
},
|
||||
"noApplicationsMatchTheseFilters": {
|
||||
"message": "No applications match these filters"
|
||||
},
|
||||
"passwordRisk": {
|
||||
"message": "Password Risk"
|
||||
},
|
||||
|
||||
@@ -79,6 +79,70 @@
|
||||
.theme_dark img.new-logo-themed {
|
||||
content: url("../images/logo-white.svg");
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom calendar icon for date/datetime inputs
|
||||
* Hides native macOS picker and uses custom SVG calendar icon
|
||||
*/
|
||||
|
||||
/* Hide the native calendar picker indicator completely */
|
||||
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
|
||||
input[type="time"]::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Wrapper for datetime input to ensure proper positioning */
|
||||
bit-form-field .tw-relative {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Ensure datetime inputs have proper padding and alignment */
|
||||
.datetime-input {
|
||||
padding-right: 2.5rem !important;
|
||||
padding-top: 0.625rem !important;
|
||||
padding-bottom: 0.625rem !important;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Position and style for custom datetime calendar icon */
|
||||
.datetime-calendar-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Light mode calendar icon color - dark for visibility */
|
||||
.theme_light .datetime-calendar-icon {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
/* Dark mode calendar icon color - light for visibility */
|
||||
.theme_dark .datetime-calendar-icon {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
/* Hover effect for calendar icon */
|
||||
.datetime-calendar-icon:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Focus styling for accessibility */
|
||||
.datetime-calendar-icon:focus {
|
||||
outline: 2px solid rgba(var(--color-primary-600));
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as koaRouter from "@koa/router";
|
||||
import { Router } from "@koa/router";
|
||||
|
||||
import { OssServeConfigurator } from "@bitwarden/cli/oss-serve-configurator";
|
||||
|
||||
@@ -16,7 +16,7 @@ export class BitServeConfigurator extends OssServeConfigurator {
|
||||
super(serviceContainer);
|
||||
}
|
||||
|
||||
override async configureRouter(router: koaRouter): Promise<void> {
|
||||
override async configureRouter(router: Router): Promise<void> {
|
||||
// Register OSS endpoints
|
||||
await super.configureRouter(router);
|
||||
|
||||
@@ -24,7 +24,7 @@ export class BitServeConfigurator extends OssServeConfigurator {
|
||||
this.serveDeviceApprovals(router);
|
||||
}
|
||||
|
||||
private serveDeviceApprovals(router: koaRouter) {
|
||||
private serveDeviceApprovals(router: Router) {
|
||||
router.get("/device-approval/:organizationId", async (ctx, next) => {
|
||||
if (await this.errorIfLocked(ctx.response)) {
|
||||
await next();
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
*ngIf="showBulkReinviteUsers"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
|
||||
{{ "reinviteSelected" | i18n }}
|
||||
{{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -333,4 +333,14 @@ export class MembersComponent extends BaseMembersComponent<ProviderUser> {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
get selectedInvitedCount(): number {
|
||||
return this.dataSource
|
||||
.getCheckedUsers()
|
||||
.filter((member) => member.status === this.userStatusType.Invited).length;
|
||||
}
|
||||
|
||||
get isSingleInvite(): boolean {
|
||||
return this.selectedInvitedCount === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@let showConfirmBanner = showConfirmBanner$ | async;
|
||||
@let dataSource = this.dataSource();
|
||||
@let isProcessing = this.isProcessing();
|
||||
@let isSingleInvite = isSingleInvite$ | async;
|
||||
|
||||
<app-header>
|
||||
<bit-search class="tw-grow" [formControl]="searchControl" [placeholder]="'searchMembers' | i18n">
|
||||
@@ -92,7 +93,7 @@
|
||||
(click)="isProcessing ? null : bulkReinvite(providerId)"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-envelope" aria-hidden="true"></i>
|
||||
{{ "reinviteSelected" | i18n }}
|
||||
{{ (isSingleInvite ? "resendInvitation" : "reinviteSelected") | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (bulkMenuOptions.showBulkConfirmUsers) {
|
||||
|
||||
@@ -104,6 +104,14 @@ export class vNextMembersComponent {
|
||||
.usersUpdated()
|
||||
.pipe(map(() => showConfirmBanner(this.dataSource())));
|
||||
|
||||
protected selectedInvitedCount$ = this.dataSource()
|
||||
.usersUpdated()
|
||||
.pipe(
|
||||
map((members) => members.filter((m) => m.status === ProviderUserStatusType.Invited).length),
|
||||
);
|
||||
|
||||
protected isSingleInvite$ = this.selectedInvitedCount$.pipe(map((count) => count === 1));
|
||||
|
||||
protected isProcessing = this.providerActionsService.isProcessing;
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -123,7 +123,9 @@ const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: "billing",
|
||||
canActivate: [providerPermissionsGuard()],
|
||||
canActivate: [
|
||||
providerPermissionsGuard((provider: Provider) => provider.isProviderAdmin),
|
||||
],
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@let integrationsList = integrations();
|
||||
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "deviceManagement" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "deviceManagementDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.DEVICE"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { IntegrationGridComponent } from "../integration-grid/integration-grid.component";
|
||||
import { FilterIntegrationsPipe } from "../integrations.pipe";
|
||||
import { OrganizationIntegrationsState } from "../organization-integrations.state";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "device-management",
|
||||
templateUrl: "device-management.component.html",
|
||||
imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe],
|
||||
})
|
||||
export class DeviceManagementComponent {
|
||||
integrations = this.state.integrations;
|
||||
|
||||
constructor(private state: OrganizationIntegrationsState) {}
|
||||
|
||||
get IntegrationType(): typeof IntegrationType {
|
||||
return IntegrationType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
@let integrationsList = integrations();
|
||||
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "eventManagement" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "eventManagementDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.EVENT"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { IntegrationGridComponent } from "../integration-grid/integration-grid.component";
|
||||
import { FilterIntegrationsPipe } from "../integrations.pipe";
|
||||
import { OrganizationIntegrationsState } from "../organization-integrations.state";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "event-management",
|
||||
templateUrl: "event-management.component.html",
|
||||
imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe],
|
||||
})
|
||||
export class EventManagementComponent {
|
||||
integrations = this.state.integrations;
|
||||
constructor(private state: OrganizationIntegrationsState) {}
|
||||
|
||||
get IntegrationType(): typeof IntegrationType {
|
||||
return IntegrationType;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,18 @@
|
||||
<app-header> </app-header>
|
||||
@let org = organization();
|
||||
|
||||
@let organization = organization$ | async;
|
||||
<app-header>
|
||||
@if (org) {
|
||||
<bit-tab-nav-bar slot="tabs">
|
||||
<bit-tab-link route="single-sign-on">{{ "singleSignOn" | i18n }}</bit-tab-link>
|
||||
@if (org.useScim || org.useDirectory) {
|
||||
<bit-tab-link route="user-provisioning">{{ "userProvisioning" | i18n }}</bit-tab-link>
|
||||
}
|
||||
@if (org.useEvents) {
|
||||
<bit-tab-link route="event-management">{{ "eventManagement" | i18n }}</bit-tab-link>
|
||||
}
|
||||
<bit-tab-link route="device-management">{{ "deviceManagement" | i18n }}</bit-tab-link>
|
||||
</bit-tab-nav-bar>
|
||||
}
|
||||
</app-header>
|
||||
|
||||
@if (organization) {
|
||||
<bit-tab-group [(selectedIndex)]="tabIndex">
|
||||
@if (organization?.useSso) {
|
||||
<bit-tab [label]="'singleSignOn' | i18n">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">{{ "singleSignOn" | i18n }}</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "ssoDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/sso" class="tw-lowercase">{{
|
||||
"singleSignOn" | i18n
|
||||
}}</a>
|
||||
{{ "ssoDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SSO"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
}
|
||||
|
||||
@if (organization?.useScim || organization?.useDirectory) {
|
||||
<bit-tab [label]="'userProvisioning' | i18n">
|
||||
@if (organization?.useScim) {
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "scimIntegration" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "scimIntegrationDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/scim">{{ "scimIntegration" | i18n }}</a>
|
||||
{{ "scimIntegrationDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SCIM"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
}
|
||||
@if (organization?.useDirectory) {
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "bwdc" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "bwdcDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.BWDC"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
}
|
||||
</bit-tab>
|
||||
}
|
||||
|
||||
@if (organization?.useEvents) {
|
||||
<bit-tab [label]="'eventManagement' | i18n">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "eventManagement" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "eventManagementDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.EVENT"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
}
|
||||
|
||||
<bit-tab [label]="'deviceManagement' | i18n">
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "deviceManagement" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "deviceManagementDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.DEVICE"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
</bit-tab>
|
||||
</bit-tab-group>
|
||||
}
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
@@ -1,336 +1,22 @@
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { firstValueFrom, Observable, Subject, switchMap, takeUntil, takeWhile } from "rxjs";
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type";
|
||||
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { IntegrationType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
|
||||
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { IntegrationGridComponent } from "./integration-grid/integration-grid.component";
|
||||
import { FilterIntegrationsPipe } from "./integrations.pipe";
|
||||
import { OrganizationIntegrationsState } from "./organization-integrations.state";
|
||||
|
||||
// attempted, but because bit-tab-group is not OnPush, caused more issues than it solved
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "ac-integrations",
|
||||
templateUrl: "./integrations.component.html",
|
||||
imports: [SharedModule, IntegrationGridComponent, HeaderModule, FilterIntegrationsPipe],
|
||||
imports: [SharedModule, HeaderModule],
|
||||
})
|
||||
export class AdminConsoleIntegrationsComponent implements OnInit, OnDestroy {
|
||||
tabIndex: number = 0;
|
||||
organization$: Observable<Organization> = new Observable<Organization>();
|
||||
isEventManagementForDataDogAndCrowdStrikeEnabled: boolean = false;
|
||||
isEventManagementForHuntressEnabled: boolean = false;
|
||||
private destroy$ = new Subject<void>();
|
||||
export class AdminConsoleIntegrationsComponent {
|
||||
organization = this.state.organization;
|
||||
|
||||
// initialize the integrations list with default integrations
|
||||
integrationsList: Integration[] = [
|
||||
{
|
||||
name: "AD FS",
|
||||
linkURL: "https://bitwarden.com/help/saml-adfs/",
|
||||
image: "../../../../../../../images/integrations/azure-active-directory.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Auth0",
|
||||
linkURL: "https://bitwarden.com/help/saml-auth0/",
|
||||
image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "AWS",
|
||||
linkURL: "https://bitwarden.com/help/saml-aws/",
|
||||
image: "../../../../../../../images/integrations/aws-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Entra ID",
|
||||
linkURL: "https://bitwarden.com/help/saml-azure/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Duo",
|
||||
linkURL: "https://bitwarden.com/help/saml-duo/",
|
||||
image: "../../../../../../../images/integrations/logo-duo-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Google",
|
||||
linkURL: "https://bitwarden.com/help/saml-google/",
|
||||
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "JumpCloud",
|
||||
linkURL: "https://bitwarden.com/help/saml-jumpcloud/",
|
||||
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "KeyCloak",
|
||||
linkURL: "https://bitwarden.com/help/saml-keycloak/",
|
||||
image: "../../../../../../../images/integrations/logo-keycloak-icon.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Okta",
|
||||
linkURL: "https://bitwarden.com/help/saml-okta/",
|
||||
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "OneLogin",
|
||||
linkURL: "https://bitwarden.com/help/saml-onelogin/",
|
||||
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "PingFederate",
|
||||
linkURL: "https://bitwarden.com/help/saml-pingfederate/",
|
||||
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Entra ID",
|
||||
linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "Okta",
|
||||
linkURL: "https://bitwarden.com/help/okta-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "OneLogin",
|
||||
linkURL: "https://bitwarden.com/help/onelogin-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "JumpCloud",
|
||||
linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "Ping Identity",
|
||||
linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "Active Directory",
|
||||
linkURL: "https://bitwarden.com/help/ldap-directory/",
|
||||
image: "../../../../../../../images/integrations/azure-active-directory.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Entra ID",
|
||||
linkURL: "https://bitwarden.com/help/microsoft-entra-id/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Google Workspace",
|
||||
linkURL: "https://bitwarden.com/help/workspace-directory/",
|
||||
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Okta",
|
||||
linkURL: "https://bitwarden.com/help/okta-directory/",
|
||||
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "OneLogin",
|
||||
linkURL: "https://bitwarden.com/help/onelogin-directory/",
|
||||
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Splunk",
|
||||
linkURL: "https://bitwarden.com/help/splunk-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-splunk-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Sentinel",
|
||||
linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Rapid7",
|
||||
linkURL: "https://bitwarden.com/help/rapid7-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-rapid7-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Elastic",
|
||||
linkURL: "https://bitwarden.com/help/elastic-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Panther",
|
||||
linkURL: "https://bitwarden.com/help/panther-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-panther-round-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Sumo Logic",
|
||||
linkURL: "https://bitwarden.com/help/sumo-logic-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-sumo-logic-siem.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/logo-sumo-logic-siem-darkmode.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
newBadgeExpiration: "2025-12-31",
|
||||
},
|
||||
{
|
||||
name: "Microsoft Intune",
|
||||
linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg",
|
||||
type: IntegrationType.DEVICE,
|
||||
},
|
||||
];
|
||||
|
||||
async ngOnInit() {
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
if (!userId) {
|
||||
throw new Error("User ID not found");
|
||||
}
|
||||
|
||||
this.organization$ = this.route.params.pipe(
|
||||
switchMap((params) =>
|
||||
this.organizationService.organizations$(userId).pipe(
|
||||
getById(params.organizationId),
|
||||
// Filter out undefined values
|
||||
takeWhile((org: Organization | undefined) => !!org),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Sets the organization ID which also loads the integrations$
|
||||
this.organization$
|
||||
.pipe(
|
||||
switchMap((org) => this.organizationIntegrationService.setOrganizationId(org.id)),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
private organizationIntegrationService: OrganizationIntegrationService,
|
||||
) {
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((isEnabled) => {
|
||||
this.isEventManagementForDataDogAndCrowdStrikeEnabled = isEnabled;
|
||||
});
|
||||
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.EventManagementForHuntress)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((isEnabled) => {
|
||||
this.isEventManagementForHuntressEnabled = isEnabled;
|
||||
});
|
||||
|
||||
// Add the new event based items to the list
|
||||
if (this.isEventManagementForDataDogAndCrowdStrikeEnabled) {
|
||||
const crowdstrikeIntegration: Integration = {
|
||||
name: OrganizationIntegrationServiceName.CrowdStrike,
|
||||
linkURL: "https://bitwarden.com/help/crowdstrike-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
description: "crowdstrikeEventIntegrationDesc",
|
||||
canSetupConnection: true,
|
||||
integrationType: OrganizationIntegrationType.Hec,
|
||||
};
|
||||
|
||||
this.integrationsList.push(crowdstrikeIntegration);
|
||||
|
||||
const datadogIntegration: Integration = {
|
||||
name: OrganizationIntegrationServiceName.Datadog,
|
||||
linkURL: "https://bitwarden.com/help/datadog-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-datadog-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
description: "datadogEventIntegrationDesc",
|
||||
canSetupConnection: true,
|
||||
integrationType: OrganizationIntegrationType.Datadog,
|
||||
};
|
||||
|
||||
this.integrationsList.push(datadogIntegration);
|
||||
}
|
||||
|
||||
// Add Huntress SIEM integration (separate feature flag)
|
||||
if (this.isEventManagementForHuntressEnabled) {
|
||||
const huntressIntegration: Integration = {
|
||||
name: OrganizationIntegrationServiceName.Huntress,
|
||||
linkURL: "https://bitwarden.com/help/huntress-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-huntress-siem.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
description: "huntressEventIntegrationDesc",
|
||||
canSetupConnection: true,
|
||||
integrationType: OrganizationIntegrationType.Hec,
|
||||
};
|
||||
|
||||
this.integrationsList.push(huntressIntegration);
|
||||
}
|
||||
|
||||
// For all existing event based configurations loop through and assign the
|
||||
// organizationIntegration for the correct services.
|
||||
this.organizationIntegrationService.integrations$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((integrations) => {
|
||||
// reset all event based integrations to null first - in case one was deleted
|
||||
this.integrationsList.forEach((i) => {
|
||||
i.organizationIntegration = null;
|
||||
});
|
||||
|
||||
integrations.forEach((integration) => {
|
||||
const item = this.integrationsList.find((i) => i.name === integration.serviceName);
|
||||
if (item) {
|
||||
item.organizationIntegration = integration;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
constructor(private state: OrganizationIntegrationsState) {}
|
||||
|
||||
// use in the view
|
||||
get IntegrationType(): typeof IntegrationType {
|
||||
|
||||
@@ -7,7 +7,10 @@ import { IntegrationType } from "@bitwarden/common/enums";
|
||||
name: "filterIntegrations",
|
||||
})
|
||||
export class FilterIntegrationsPipe implements PipeTransform {
|
||||
transform(integrations: Integration[], type: IntegrationType): Integration[] {
|
||||
transform(integrations: Integration[] | null | undefined, type: IntegrationType): Integration[] {
|
||||
if (!integrations) {
|
||||
return [];
|
||||
}
|
||||
return integrations.filter((integration) => integration.type === type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,31 @@ import { RouterModule, Routes } from "@angular/router";
|
||||
|
||||
import { organizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard";
|
||||
|
||||
import { DeviceManagementComponent } from "./device-management/device-management.component";
|
||||
import { EventManagementComponent } from "./event-management/event-management.component";
|
||||
import { AdminConsoleIntegrationsComponent } from "./integrations.component";
|
||||
import { OrganizationIntegrationsResolver } from "./organization-integrations.resolver";
|
||||
import { OrganizationIntegrationsState } from "./organization-integrations.state";
|
||||
import { SingleSignOnComponent } from "./single-sign-on/single-sign-on.component";
|
||||
import { UserProvisioningComponent } from "./user-provisioning/user-provisioning.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: "",
|
||||
canActivate: [organizationPermissionsGuard((org) => org.canAccessIntegrations)],
|
||||
component: AdminConsoleIntegrationsComponent,
|
||||
data: {
|
||||
titleId: "integrations",
|
||||
},
|
||||
component: AdminConsoleIntegrationsComponent,
|
||||
providers: [OrganizationIntegrationsState, OrganizationIntegrationsResolver],
|
||||
resolve: { integrations: OrganizationIntegrationsResolver },
|
||||
children: [
|
||||
{ path: "", redirectTo: "single-sign-on", pathMatch: "full" },
|
||||
{ path: "single-sign-on", component: SingleSignOnComponent },
|
||||
{ path: "user-provisioning", component: UserProvisioningComponent },
|
||||
{ path: "event-management", component: EventManagementComponent },
|
||||
{ path: "device-management", component: DeviceManagementComponent },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { DeviceManagementComponent } from "@bitwarden/angular/auth/device-management/device-management.component";
|
||||
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 { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { safeProvider } from "@bitwarden/ui-common";
|
||||
|
||||
import { EventManagementComponent } from "./event-management/event-management.component";
|
||||
import { AdminConsoleIntegrationsComponent } from "./integrations.component";
|
||||
import { OrganizationIntegrationsRoutingModule } from "./organization-integrations-routing.module";
|
||||
import { OrganizationIntegrationsResolver } from "./organization-integrations.resolver";
|
||||
import { SingleSignOnComponent } from "./single-sign-on/single-sign-on.component";
|
||||
import { UserProvisioningComponent } from "./user-provisioning/user-provisioning.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [AdminConsoleIntegrationsComponent, OrganizationIntegrationsRoutingModule],
|
||||
imports: [
|
||||
AdminConsoleIntegrationsComponent,
|
||||
OrganizationIntegrationsRoutingModule,
|
||||
SingleSignOnComponent,
|
||||
UserProvisioningComponent,
|
||||
DeviceManagementComponent,
|
||||
EventManagementComponent,
|
||||
],
|
||||
providers: [
|
||||
OrganizationIntegrationsResolver,
|
||||
safeProvider({
|
||||
provide: OrganizationIntegrationService,
|
||||
useClass: OrganizationIntegrationService,
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { ActivatedRouteSnapshot, Resolve } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { take, takeWhile } from "rxjs/operators";
|
||||
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { OrganizationIntegrationServiceName } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-service-type";
|
||||
import { OrganizationIntegrationType } from "@bitwarden/bit-common/dirt/organization-integrations/models/organization-integration-type";
|
||||
import { OrganizationIntegrationService } from "@bitwarden/bit-common/dirt/organization-integrations/services/organization-integration-service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { IntegrationType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
|
||||
import { OrganizationIntegrationsState } from "./organization-integrations.state";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationIntegrationsResolver implements Resolve<boolean> {
|
||||
constructor(
|
||||
private organizationService: OrganizationService,
|
||||
private accountService: AccountService,
|
||||
private configService: ConfigService,
|
||||
private organizationIntegrationService: OrganizationIntegrationService,
|
||||
private state: OrganizationIntegrationsState,
|
||||
) {}
|
||||
|
||||
async resolve(route: ActivatedRouteSnapshot): Promise<boolean> {
|
||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("User ID not found");
|
||||
}
|
||||
|
||||
const orgId = route.paramMap.get("organizationId")!;
|
||||
const org = await firstValueFrom(
|
||||
this.organizationService.organizations$(userId).pipe(getById(orgId), takeWhile(Boolean)),
|
||||
);
|
||||
|
||||
this.state.setOrganization(org);
|
||||
|
||||
await firstValueFrom(this.organizationIntegrationService.setOrganizationId(org.id));
|
||||
|
||||
const integrations: Integration[] = [
|
||||
{
|
||||
name: "AD FS",
|
||||
linkURL: "https://bitwarden.com/help/saml-adfs/",
|
||||
image: "../../../../../../../images/integrations/azure-active-directory.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Auth0",
|
||||
linkURL: "https://bitwarden.com/help/saml-auth0/",
|
||||
image: "../../../../../../../images/integrations/logo-auth0-badge-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "AWS",
|
||||
linkURL: "https://bitwarden.com/help/saml-aws/",
|
||||
image: "../../../../../../../images/integrations/aws-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/aws-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Entra ID",
|
||||
linkURL: "https://bitwarden.com/help/saml-azure/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Duo",
|
||||
linkURL: "https://bitwarden.com/help/saml-duo/",
|
||||
image: "../../../../../../../images/integrations/logo-duo-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Google",
|
||||
linkURL: "https://bitwarden.com/help/saml-google/",
|
||||
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "JumpCloud",
|
||||
linkURL: "https://bitwarden.com/help/saml-jumpcloud/",
|
||||
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "KeyCloak",
|
||||
linkURL: "https://bitwarden.com/help/saml-keycloak/",
|
||||
image: "../../../../../../../images/integrations/logo-keycloak-icon.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Okta",
|
||||
linkURL: "https://bitwarden.com/help/saml-okta/",
|
||||
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "OneLogin",
|
||||
linkURL: "https://bitwarden.com/help/saml-onelogin/",
|
||||
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "PingFederate",
|
||||
linkURL: "https://bitwarden.com/help/saml-pingfederate/",
|
||||
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
|
||||
type: IntegrationType.SSO,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Entra ID",
|
||||
linkURL: "https://bitwarden.com/help/microsoft-entra-id-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "Okta",
|
||||
linkURL: "https://bitwarden.com/help/okta-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "OneLogin",
|
||||
linkURL: "https://bitwarden.com/help/onelogin-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "JumpCloud",
|
||||
linkURL: "https://bitwarden.com/help/jumpcloud-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-jumpcloud-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/jumpcloud-darkmode.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "Ping Identity",
|
||||
linkURL: "https://bitwarden.com/help/ping-identity-scim-integration/",
|
||||
image: "../../../../../../../images/integrations/logo-ping-identity-badge-color.svg",
|
||||
type: IntegrationType.SCIM,
|
||||
},
|
||||
{
|
||||
name: "Active Directory",
|
||||
linkURL: "https://bitwarden.com/help/ldap-directory/",
|
||||
image: "../../../../../../../images/integrations/azure-active-directory.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Entra ID",
|
||||
linkURL: "https://bitwarden.com/help/microsoft-entra-id/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-entra-id-color.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Google Workspace",
|
||||
linkURL: "https://bitwarden.com/help/workspace-directory/",
|
||||
image: "../../../../../../../images/integrations/logo-google-badge-color.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Okta",
|
||||
linkURL: "https://bitwarden.com/help/okta-directory/",
|
||||
image: "../../../../../../../images/integrations/logo-okta-symbol-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/okta-darkmode.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "OneLogin",
|
||||
linkURL: "https://bitwarden.com/help/onelogin-directory/",
|
||||
image: "../../../../../../../images/integrations/logo-onelogin-badge-color.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/onelogin-darkmode.svg",
|
||||
type: IntegrationType.BWDC,
|
||||
},
|
||||
{
|
||||
name: "Splunk",
|
||||
linkURL: "https://bitwarden.com/help/splunk-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-splunk-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/splunk-darkmode.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Microsoft Sentinel",
|
||||
linkURL: "https://bitwarden.com/help/microsoft-sentinel-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-sentinel-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Rapid7",
|
||||
linkURL: "https://bitwarden.com/help/rapid7-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-rapid7-black.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/rapid7-darkmode.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Elastic",
|
||||
linkURL: "https://bitwarden.com/help/elastic-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-elastic-badge-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Panther",
|
||||
linkURL: "https://bitwarden.com/help/panther-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-panther-round-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
},
|
||||
{
|
||||
name: "Sumo Logic",
|
||||
linkURL: "https://bitwarden.com/help/sumo-logic-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-sumo-logic-siem.svg",
|
||||
imageDarkMode: "../../../../../../../images/integrations/logo-sumo-logic-siem-darkmode.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
newBadgeExpiration: "2025-12-31",
|
||||
},
|
||||
{
|
||||
name: "Microsoft Intune",
|
||||
linkURL: "https://bitwarden.com/help/deploy-browser-extensions-with-intune/",
|
||||
image: "../../../../../../../images/integrations/logo-microsoft-intune-color.svg",
|
||||
type: IntegrationType.DEVICE,
|
||||
},
|
||||
];
|
||||
|
||||
const featureEnabled = await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.EventManagementForDataDogAndCrowdStrike),
|
||||
);
|
||||
|
||||
if (featureEnabled) {
|
||||
integrations.push(
|
||||
{
|
||||
name: OrganizationIntegrationServiceName.CrowdStrike,
|
||||
linkURL: "https://bitwarden.com/help/crowdstrike-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-crowdstrike-black.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
canSetupConnection: true,
|
||||
integrationType: OrganizationIntegrationType.Hec,
|
||||
},
|
||||
{
|
||||
name: OrganizationIntegrationServiceName.Datadog,
|
||||
linkURL: "https://bitwarden.com/help/datadog-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-datadog-color.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
canSetupConnection: true,
|
||||
integrationType: OrganizationIntegrationType.Datadog,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Add Huntress SIEM integration (separate feature flag)
|
||||
const huntressFeatureEnabled = await firstValueFrom(
|
||||
this.configService.getFeatureFlag$(FeatureFlag.EventManagementForHuntress),
|
||||
);
|
||||
|
||||
if (huntressFeatureEnabled) {
|
||||
integrations.push({
|
||||
name: OrganizationIntegrationServiceName.Huntress,
|
||||
linkURL: "https://bitwarden.com/help/huntress-siem/",
|
||||
image: "../../../../../../../images/integrations/logo-huntress-siem.svg",
|
||||
type: IntegrationType.EVENT,
|
||||
description: "huntressEventIntegrationDesc",
|
||||
canSetupConnection: true,
|
||||
integrationType: OrganizationIntegrationType.Hec,
|
||||
});
|
||||
}
|
||||
|
||||
const orgIntegrations = await firstValueFrom(
|
||||
this.organizationIntegrationService.integrations$.pipe(take(1)),
|
||||
);
|
||||
|
||||
const merged = integrations.map((i) => ({
|
||||
...i,
|
||||
organizationIntegration: orgIntegrations.find((o) => o.serviceName === i.name) ?? null,
|
||||
}));
|
||||
|
||||
this.state.setIntegrations(merged);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable, signal } from "@angular/core";
|
||||
|
||||
import { Integration } from "@bitwarden/bit-common/dirt/organization-integrations/models/integration";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationIntegrationsState {
|
||||
private readonly _integrations = signal<Integration[]>([]);
|
||||
private readonly _organization = signal<Organization | undefined>(undefined);
|
||||
|
||||
// Signals
|
||||
integrations = this._integrations.asReadonly();
|
||||
organization = this._organization.asReadonly();
|
||||
|
||||
setOrganization(val: Organization | null) {
|
||||
this._organization.set(val ?? undefined);
|
||||
}
|
||||
|
||||
setIntegrations(val: Integration[]) {
|
||||
this._integrations.set(val);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@let integrationsList = integrations();
|
||||
<section class="tw-mb-9">
|
||||
<h2 bitTypography="h2">{{ "singleSignOn" | i18n }}</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "ssoDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/sso" class="tw-lowercase">{{ "singleSignOn" | i18n }}</a>
|
||||
{{ "ssoDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SSO"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { IntegrationGridComponent } from "../integration-grid/integration-grid.component";
|
||||
import { FilterIntegrationsPipe } from "../integrations.pipe";
|
||||
import { OrganizationIntegrationsState } from "../organization-integrations.state";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "single-sign-on",
|
||||
templateUrl: "single-sign-on.component.html",
|
||||
imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe],
|
||||
})
|
||||
export class SingleSignOnComponent {
|
||||
integrations = this.state.integrations;
|
||||
IntegrationType = IntegrationType;
|
||||
|
||||
constructor(private state: OrganizationIntegrationsState) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@let org = organization();
|
||||
@let integrationsList = integrations();
|
||||
|
||||
<section class="tw-mb-9" *ngIf="org?.useScim">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "scimIntegration" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">
|
||||
{{ "scimIntegrationDescStart" | i18n }}
|
||||
<a bitLink routerLink="../settings/scim">{{ "scimIntegration" | i18n }}</a>
|
||||
{{ "scimIntegrationDescEnd" | i18n }}
|
||||
</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.SCIM"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
<section class="tw-mb-9" *ngIf="org?.useDirectory">
|
||||
<h2 bitTypography="h2">
|
||||
{{ "bwdc" | i18n }}
|
||||
</h2>
|
||||
<p bitTypography="body1">{{ "bwdcDesc" | i18n }}</p>
|
||||
<app-integration-grid
|
||||
[integrations]="integrationsList | filterIntegrations: IntegrationType.BWDC"
|
||||
></app-integration-grid>
|
||||
</section>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { IntegrationType } from "@bitwarden/common/enums/integration-type.enum";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
import { IntegrationGridComponent } from "../integration-grid/integration-grid.component";
|
||||
import { FilterIntegrationsPipe } from "../integrations.pipe";
|
||||
import { OrganizationIntegrationsState } from "../organization-integrations.state";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "user-provisioning",
|
||||
templateUrl: "user-provisioning.component.html",
|
||||
imports: [SharedModule, IntegrationGridComponent, FilterIntegrationsPipe],
|
||||
})
|
||||
export class UserProvisioningComponent {
|
||||
organization = this.state.organization;
|
||||
integrations = this.state.integrations;
|
||||
|
||||
constructor(private state: OrganizationIntegrationsState) {}
|
||||
|
||||
get IntegrationType(): typeof IntegrationType {
|
||||
return IntegrationType;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { EncString } from "@bitwarden/sdk-internal";
|
||||
|
||||
export class OrganizationUserBulkRestoreRequest {
|
||||
userIds: string[];
|
||||
ids: string[];
|
||||
defaultUserCollectionName: EncString | undefined;
|
||||
|
||||
constructor(userIds: string[], defaultUserCollectionName?: EncString) {
|
||||
this.userIds = userIds;
|
||||
constructor(ids: string[], defaultUserCollectionName?: EncString) {
|
||||
this.ids = ids;
|
||||
this.defaultUserCollectionName = defaultUserCollectionName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ describe("DefaultOrganizationUserService", () => {
|
||||
).toHaveBeenCalledWith(
|
||||
mockOrganization.id,
|
||||
expect.objectContaining({
|
||||
userIds: mockUserIds,
|
||||
ids: mockUserIds,
|
||||
defaultUserCollectionName: mockEncryptedCollectionName.encryptedString,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CsprngArray } from "../../../types/csprng";
|
||||
|
||||
export abstract class CryptoFunctionService {
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract pbkdf2(
|
||||
@@ -17,7 +17,7 @@ export abstract class CryptoFunctionService {
|
||||
iterations: number,
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract hkdf(
|
||||
@@ -28,7 +28,7 @@ export abstract class CryptoFunctionService {
|
||||
algorithm: "sha256" | "sha512",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract hkdfExpand(
|
||||
@@ -38,7 +38,7 @@ export abstract class CryptoFunctionService {
|
||||
algorithm: "sha256" | "sha512",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract hash(
|
||||
@@ -46,7 +46,7 @@ export abstract class CryptoFunctionService {
|
||||
algorithm: "sha1" | "sha256" | "sha512" | "md5",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract hmacFast(
|
||||
@@ -56,7 +56,7 @@ export abstract class CryptoFunctionService {
|
||||
): Promise<Uint8Array | string>;
|
||||
abstract compareFast(a: Uint8Array | string, b: Uint8Array | string): Promise<boolean>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract aesDecryptFastParameters(
|
||||
@@ -66,7 +66,7 @@ export abstract class CryptoFunctionService {
|
||||
key: SymmetricCryptoKey,
|
||||
): CbcDecryptParameters<Uint8Array | string>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract aesDecryptFast({
|
||||
@@ -76,7 +76,7 @@ export abstract class CryptoFunctionService {
|
||||
| { mode: "cbc"; parameters: CbcDecryptParameters<Uint8Array | string> }
|
||||
| { mode: "ecb"; parameters: EcbDecryptParameters<Uint8Array | string> }): Promise<string>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer.
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Only used by DDG integration until DDG uses PKCS#7 padding, and by lastpass importer.
|
||||
*/
|
||||
abstract aesDecrypt(
|
||||
data: Uint8Array,
|
||||
@@ -85,7 +85,7 @@ export abstract class CryptoFunctionService {
|
||||
mode: "cbc" | "ecb",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract rsaEncrypt(
|
||||
@@ -94,7 +94,7 @@ export abstract class CryptoFunctionService {
|
||||
algorithm: "sha1",
|
||||
): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. Implement low-level crypto operations
|
||||
* in the SDK instead. Further, you should probably never find yourself using this low-level crypto function.
|
||||
*/
|
||||
abstract rsaDecrypt(
|
||||
|
||||
@@ -27,7 +27,7 @@ export abstract class KeyGenerationService {
|
||||
* Uses HKDF, see {@link https://datatracker.ietf.org/doc/html/rfc5869 RFC 5869}
|
||||
* for details.
|
||||
*
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
|
||||
* New functionality should not be built on top of it, and instead should be built in the sdk.
|
||||
*
|
||||
* @param bitLength Length of key material.
|
||||
@@ -44,7 +44,7 @@ export abstract class KeyGenerationService {
|
||||
/**
|
||||
* Derives a 64 byte key from key material.
|
||||
*
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
|
||||
* New functionality should not be built on top of it, and instead should be built in the sdk.
|
||||
*
|
||||
* @remark The key material should be generated from {@link createKey}, or {@link createKeyWithPurpose}.
|
||||
@@ -63,7 +63,7 @@ export abstract class KeyGenerationService {
|
||||
/**
|
||||
* Derives a 32 byte key from a password using a key derivation function.
|
||||
*
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
|
||||
* New functionality should not be built on top of it, and instead should be built in the sdk.
|
||||
*
|
||||
* @param password Password to derive the key from.
|
||||
@@ -80,7 +80,7 @@ export abstract class KeyGenerationService {
|
||||
/**
|
||||
* Derives a 64 byte key from a 32 byte key using a key derivation function.
|
||||
*
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. This is a low-level cryptographic function.
|
||||
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. CONTACT KEY MANAGEMENT IF YOU THINK YOU NEED TO. This is a low-level cryptographic function.
|
||||
* New functionality should not be built on top of it, and instead should be built in the sdk.
|
||||
*
|
||||
* @param key 32 byte key.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PolicyService } from "../admin-console/abstractions/policy/policy.servi
|
||||
import { ConfigService } from "../platform/abstractions/config/config.service";
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
|
||||
import { SdkService } from "../platform/abstractions/sdk/sdk.service";
|
||||
import { StateProvider } from "../platform/state";
|
||||
|
||||
import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider";
|
||||
@@ -20,6 +21,7 @@ describe("SystemServiceProvider", () => {
|
||||
let mockLogger: LogService;
|
||||
let mockEnvironment: MockProxy<PlatformUtilsService>;
|
||||
let mockConfigService: ConfigService;
|
||||
let mockSdkService: SdkService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
@@ -31,6 +33,7 @@ describe("SystemServiceProvider", () => {
|
||||
mockLogger = mock<LogService>();
|
||||
mockEnvironment = mock<PlatformUtilsService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockSdkService = mock<SdkService>();
|
||||
});
|
||||
|
||||
describe("createSystemServiceProvider", () => {
|
||||
@@ -45,6 +48,7 @@ describe("SystemServiceProvider", () => {
|
||||
mockLogger,
|
||||
mockEnvironment,
|
||||
mockConfigService,
|
||||
mockSdkService,
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty("policy", mockPolicy);
|
||||
@@ -66,6 +70,7 @@ describe("SystemServiceProvider", () => {
|
||||
mockLogger,
|
||||
mockEnvironment,
|
||||
mockConfigService,
|
||||
mockSdkService,
|
||||
);
|
||||
|
||||
expect(result.extension).toBeInstanceOf(ExtensionService);
|
||||
@@ -83,6 +88,7 @@ describe("SystemServiceProvider", () => {
|
||||
mockLogger,
|
||||
mockEnvironment,
|
||||
mockConfigService,
|
||||
mockSdkService,
|
||||
);
|
||||
|
||||
expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1);
|
||||
@@ -102,6 +108,7 @@ describe("SystemServiceProvider", () => {
|
||||
mockLogger,
|
||||
mockEnvironment,
|
||||
mockConfigService,
|
||||
mockSdkService,
|
||||
);
|
||||
|
||||
expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1);
|
||||
@@ -121,6 +128,7 @@ describe("SystemServiceProvider", () => {
|
||||
mockLogger,
|
||||
mockEnvironment,
|
||||
mockConfigService,
|
||||
mockSdkService,
|
||||
);
|
||||
|
||||
expect(result.extension).toBeInstanceOf(ExtensionService);
|
||||
@@ -138,6 +146,7 @@ describe("SystemServiceProvider", () => {
|
||||
mockLogger,
|
||||
mockEnvironment,
|
||||
mockConfigService,
|
||||
mockSdkService,
|
||||
);
|
||||
|
||||
expect(result.policy).toBe(mockPolicy);
|
||||
@@ -154,6 +163,7 @@ describe("SystemServiceProvider", () => {
|
||||
mockLogger,
|
||||
mockEnvironment,
|
||||
mockConfigService,
|
||||
mockSdkService,
|
||||
);
|
||||
|
||||
expect(result.configService).toBe(mockConfigService);
|
||||
@@ -170,6 +180,7 @@ describe("SystemServiceProvider", () => {
|
||||
mockLogger,
|
||||
mockEnvironment,
|
||||
mockConfigService,
|
||||
mockSdkService,
|
||||
);
|
||||
|
||||
expect(result.environment).toBe(mockEnvironment);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { BitwardenClient } from "@bitwarden/sdk-internal";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
|
||||
import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { ConfigService } from "../platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
|
||||
import { SdkService } from "../platform/abstractions/sdk/sdk.service";
|
||||
|
||||
import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider";
|
||||
import { ExtensionRegistry } from "./extension/extension-registry.abstraction";
|
||||
@@ -29,7 +29,7 @@ export type SystemServiceProvider = {
|
||||
readonly environment: PlatformUtilsService;
|
||||
|
||||
/** SDK Service */
|
||||
readonly sdk?: BitwardenClient;
|
||||
readonly sdk: SdkService;
|
||||
};
|
||||
|
||||
/** Constructs a system service provider. */
|
||||
@@ -41,6 +41,7 @@ export function createSystemServiceProvider(
|
||||
logger: LogService,
|
||||
environment: PlatformUtilsService,
|
||||
configService: ConfigService,
|
||||
sdk: SdkService,
|
||||
): SystemServiceProvider {
|
||||
let log: LogProvider;
|
||||
if (environment.isDev()) {
|
||||
@@ -62,5 +63,6 @@ export function createSystemServiceProvider(
|
||||
log,
|
||||
configService,
|
||||
environment,
|
||||
sdk,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user