diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 3d4de791ad..c506cc4985 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2522,6 +2522,14 @@ "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 te 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." }, @@ -2531,6 +2539,9 @@ "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" }, @@ -4503,5 +4514,8 @@ }, "noEditPermissions": { "message": "You don't have permission to edit this item" + }, + "authenticating": { + "message": "Authenticating" } } diff --git a/apps/browser/src/autofill/background/overlay-notifications.background.ts b/apps/browser/src/autofill/background/overlay-notifications.background.ts index e252bdcc4a..ca8f05b77d 100644 --- a/apps/browser/src/autofill/background/overlay-notifications.background.ts +++ b/apps/browser/src/autofill/background/overlay-notifications.background.ts @@ -6,6 +6,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { BrowserApi } from "../../platform/browser/browser-api"; +import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils"; import { ActiveFormSubmissionRequests, @@ -109,35 +110,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg */ private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) { return new Set([ - ...this.generateMatchPatterns(sender.url), - ...this.generateMatchPatterns(sender.tab.url), + ...generateDomainMatchPatterns(sender.url), + ...generateDomainMatchPatterns(sender.tab.url), ]); } - /** - * Generates the origin and subdomain match patterns for the URL. - * - * @param url - The URL of the tab - */ - private generateMatchPatterns(url: string): string[] { - try { - if (!url.startsWith("http")) { - url = `https://${url}`; - } - - const originMatchPattern = `${new URL(url).origin}/*`; - - const parsedUrl = new URL(url); - const splitHost = parsedUrl.hostname.split("."); - const domain = splitHost.slice(-2).join("."); - const subDomainMatchPattern = `${parsedUrl.protocol}//*.${domain}/*`; - - return [originMatchPattern, subDomainMatchPattern]; - } catch { - return []; - } - } - /** * Stores the login form data that was modified by the user in the content script. This data is * used to trigger the add login or change password notification when the form is submitted. @@ -329,7 +306,7 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg private handleOnCompletedRequestEvent = async (details: chrome.webRequest.WebResponseDetails) => { if ( this.requestHostIsInvalid(details) || - this.isInvalidStatusCode(details.statusCode) || + isInvalidResponseStatusCode(details.statusCode) || !this.activeFormSubmissionRequests.has(details.requestId) ) { return; @@ -472,16 +449,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg this.setupWebRequestsListeners(); }; - /** - * Determines if the status code of the web response is invalid. An invalid status code is - * any status code that is not in the 200-299 range. - * - * @param statusCode - The status code of the web response - */ - private isInvalidStatusCode = (statusCode: number) => { - return statusCode < 200 || statusCode >= 300; - }; - /** * Determines if the host of the web request is invalid. An invalid host is any host that does not * start with "http" or a tab id that is less than 0. diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 30f19e7260..b6a04f63d5 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -61,6 +61,7 @@ import { triggerPortOnDisconnectEvent, triggerPortOnMessageEvent, triggerWebNavigationOnCommittedEvent, + triggerWebRequestOnCompletedEvent, } from "../spec/testing-utils"; import { @@ -3003,37 +3004,95 @@ describe("OverlayBackground", () => { expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); }); - it("triggers passkey authentication through mediated conditional UI", async () => { - const fido2Credential = mock({ credentialId: "credential-id" }); - const cipher1 = mock({ - id: "inline-menu-cipher-1", - login: { - username: "username1", - password: "password1", - fido2Credentials: [fido2Credential], - }, - }); - overlayBackground["inlineMenuCiphers"] = new Map([["inline-menu-cipher-1", cipher1]]); - const pageDetailsForTab = { - frameId: sender.frameId, - tab: sender.tab, - details: pageDetails, - }; - overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ - [sender.frameId, pageDetailsForTab], - ]); - autofillService.isPasswordRepromptRequired.mockResolvedValue(false); - jest.spyOn(fido2ActiveRequestManager, "getActiveRequest"); + describe("triggering passkey authentication", () => { + let cipher1: CipherView; - sendPortMessage(listMessageConnectorSpy, { - command: "fillAutofillInlineMenuCipher", - inlineMenuCipherId: "inline-menu-cipher-1", - usePasskey: true, - portKey, + beforeEach(() => { + const fido2Credential = mock({ credentialId: "credential-id" }); + cipher1 = mock({ + id: "inline-menu-cipher-1", + login: { + username: "username1", + password: "password1", + fido2Credentials: [fido2Credential], + }, + }); + const pageDetailsForTab = { + frameId: sender.frameId, + tab: sender.tab, + details: pageDetails, + }; + overlayBackground["inlineMenuCiphers"] = new Map([["inline-menu-cipher-1", cipher1]]); + overlayBackground["pageDetailsForTab"][sender.tab.id] = new Map([ + [sender.frameId, pageDetailsForTab], + ]); + autofillService.isPasswordRepromptRequired.mockResolvedValue(false); }); - await flushPromises(); - expect(fido2ActiveRequestManager.getActiveRequest).toHaveBeenCalledWith(sender.tab.id); + it("logs an error if the authentication could not complete due to a missing FIDO2 request", async () => { + jest.spyOn(logService, "error"); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + usePasskey: true, + portKey, + }); + await flushPromises(); + + expect(logService.error).toHaveBeenCalled(); + }); + + describe("when the FIDO2 request is present", () => { + beforeEach(async () => { + void fido2ActiveRequestManager.newActiveRequest( + sender.tab.id, + cipher1.login.fido2Credentials, + new AbortController(), + ); + }); + + it("aborts all active FIDO2 requests if the subsequent request after the authentication is invalid", async () => { + jest.spyOn(fido2ActiveRequestManager, "removeActiveRequest"); + + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + usePasskey: true, + portKey, + }); + await flushPromises(); + triggerWebRequestOnCompletedEvent( + mock({ + statusCode: 401, + }), + ); + + expect(fido2ActiveRequestManager.removeActiveRequest).toHaveBeenCalled(); + }); + + it("triggers a closure of the inline menu if the subsequent request after the authentication is valid", async () => { + jest.useFakeTimers(); + + await initOverlayElementPorts(); + sendPortMessage(listMessageConnectorSpy, { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "inline-menu-cipher-1", + usePasskey: true, + portKey, + }); + triggerWebRequestOnCompletedEvent( + mock({ + statusCode: 200, + }), + ); + jest.advanceTimersByTime(3100); + + expect(listPortSpy.postMessage).toHaveBeenCalledWith({ + command: "triggerDelayedAutofillInlineMenuClosure", + }); + }); + }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index c8d250df50..653d31ca52 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -55,7 +55,11 @@ import { MAX_SUB_FRAME_DEPTH, } from "../enums/autofill-overlay.enum"; import { AutofillService } from "../services/abstractions/autofill.service"; -import { generateRandomChars } from "../utils"; +import { + generateDomainMatchPatterns, + generateRandomChars, + isInvalidResponseStatusCode, +} from "../utils"; import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background"; import { @@ -151,7 +155,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { addEditCipherSubmitted: () => this.updateOverlayCiphers(), editedCipher: () => this.updateOverlayCiphers(), deletedCipher: () => this.updateOverlayCiphers(), - fido2AbortRequest: ({ sender }) => this.abortFido2ActiveRequest(sender), + fido2AbortRequest: ({ sender }) => this.abortFido2ActiveRequest(sender.tab.id), }; private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = { triggerDelayedAutofillInlineMenuClosure: () => this.triggerDelayedInlineMenuClosure(), @@ -672,10 +676,10 @@ export class OverlayBackground implements OverlayBackgroundInterface { /** * Aborts an active FIDO2 request for a given tab and updates the inline menu ciphers. * - * @param sender - The sender of the message + * @param tabId - The id of the tab to abort the request for */ - private async abortFido2ActiveRequest(sender: chrome.runtime.MessageSender) { - this.fido2ActiveRequestManager.removeActiveRequest(sender.tab.id); + private async abortFido2ActiveRequest(tabId: number) { + this.fido2ActiveRequestManager.removeActiveRequest(tabId); await this.updateOverlayCiphers(false); } @@ -939,11 +943,10 @@ export class OverlayBackground implements OverlayBackgroundInterface { if (usePasskey && cipher.login?.hasFido2Credentials) { await this.authenticatePasskeyCredential( - sender.tab.id, + sender, cipher.login.fido2Credentials[0].credentialId, ); this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher); - this.closeInlineMenu(sender, { forceCloseInlineMenu: true }); return; } @@ -969,11 +972,11 @@ export class OverlayBackground implements OverlayBackgroundInterface { /** * Triggers a FIDO2 authentication from the inline menu using the passed credential ID. * - * @param tabId - The tab ID to trigger the authentication for + * @param sender - The sender of the port message * @param credentialId - The credential ID to authenticate */ - async authenticatePasskeyCredential(tabId: number, credentialId: string) { - const request = this.fido2ActiveRequestManager.getActiveRequest(tabId); + async authenticatePasskeyCredential(sender: chrome.runtime.MessageSender, credentialId: string) { + const request = this.fido2ActiveRequestManager.getActiveRequest(sender.tab.id); if (!request) { this.logService.error( "Could not complete passkey autofill due to missing active Fido2 request", @@ -981,9 +984,35 @@ export class OverlayBackground implements OverlayBackgroundInterface { return; } + chrome.webRequest.onCompleted.addListener(this.handlePasskeyAuthenticationOnCompleted, { + urls: generateDomainMatchPatterns(sender.tab.url), + }); request.subject.next({ type: Fido2ActiveRequestEvents.Continue, credentialId }); } + /** + * Handles the next web request that occurs after a passkey authentication has been completed. + * Ensures that the inline menu closes after the request, and that the FIDO2 request is aborted + * if the request is not successful. + * + * @param details - The web request details + */ + private handlePasskeyAuthenticationOnCompleted = ( + details: chrome.webRequest.WebResponseCacheDetails, + ) => { + chrome.webRequest.onCompleted.removeListener(this.handlePasskeyAuthenticationOnCompleted); + + if (isInvalidResponseStatusCode(details.statusCode)) { + this.closeInlineMenu({ tab: { id: details.tabId } } as chrome.runtime.MessageSender, { + forceCloseInlineMenu: true, + }); + this.abortFido2ActiveRequest(details.tabId).catch((error) => this.logService.error(error)); + return; + } + + globalThis.setTimeout(() => this.triggerDelayedInlineMenuClosure(), 3000); + }; + /** * Sets the most recently used cipher at the top of the list of ciphers. * @@ -1587,6 +1616,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { passkeys: this.i18nService.translate("passkeys"), passwords: this.i18nService.translate("passwords"), logInWithPasskey: this.i18nService.translate("logInWithPasskeyAriaLabel"), + authenticating: this.i18nService.translate("authenticating"), }; } diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap index 3339781fab..0a4ae8d795 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/__snapshots__/autofill-inline-menu-list.spec.ts.snap @@ -2131,6 +2131,44 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f `; +exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user fill cipher button event listeners filling a cipher displays an \`Authenticating\` loader when a passkey cipher is filled 1`] = ` +
+
+ +
+
+`; + exports[`AutofillInlineMenuList initAutofillInlineMenuList the locked inline menu for an unauthenticated user creates the views for the locked inline menu 1`] = `
{ postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey })); }); - it("allows the user to fill a cipher on click", () => { - const fillCipherButton = - autofillInlineMenuList["inlineMenuListContainer"].querySelector(".fill-cipher-button"); + describe("filling a cipher", () => { + it("allows the user to fill a cipher on click", () => { + const fillCipherButton = + autofillInlineMenuList["inlineMenuListContainer"].querySelector( + ".fill-cipher-button", + ); - fillCipherButton.dispatchEvent(new Event("click")); + fillCipherButton.dispatchEvent(new Event("click")); - expect(globalThis.parent.postMessage).toHaveBeenCalledWith( - { - command: "fillAutofillInlineMenuCipher", - inlineMenuCipherId: "1", - usePasskey: false, - portKey, - }, - "*", - ); + expect(globalThis.parent.postMessage).toHaveBeenCalledWith( + { + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: "1", + usePasskey: false, + portKey, + }, + "*", + ); + }); + + it("displays an `Authenticating` loader when a passkey cipher is filled", async () => { + postWindowMessage( + createInitAutofillInlineMenuListMessageMock({ + ciphers: [ + createAutofillOverlayCipherDataMock(1, { + name: "https://example.com", + login: { + username: "username1", + passkey: { + rpName: "https://example.com", + userName: "username1", + }, + }, + }), + ], + showPasskeysLabels: true, + portKey, + }), + ); + await flushPromises(); + + const fillCipherButton = + autofillInlineMenuList["inlineMenuListContainer"].querySelector( + ".fill-cipher-button", + ); + + fillCipherButton.dispatchEvent(new Event("click")); + + expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot(); + }); }); it("allows the user to move keyboard focus to the next cipher element on ArrowDown", () => { diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts index da8cbdd200..27a9c68d99 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/autofill-inline-menu-list.ts @@ -14,6 +14,7 @@ import { plusIcon, viewCipherIcon, passkeyIcon, + spinnerIcon, } from "../../../../utils/svg-icons"; import { AutofillInlineMenuListWindowMessageHandlers, @@ -40,6 +41,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private passkeysHeadingHeight: number; private lastPasskeysListItemHeight: number; private ciphersListHeight: number; + private isPasskeyAuthInProgress = false; private readonly showCiphersPerPage = 6; private readonly headingBorderClass = "inline-menu-list-heading--bordered"; private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers = @@ -156,15 +158,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { ciphers: InlineMenuCipherData[], showInlineMenuAccountCreation?: boolean, ) { + if (this.isPasskeyAuthInProgress) { + return; + } + this.ciphers = ciphers; this.currentCipherIndex = 0; this.showInlineMenuAccountCreation = showInlineMenuAccountCreation; - if (this.inlineMenuListContainer) { - this.inlineMenuListContainer.innerHTML = ""; - this.inlineMenuListContainer.classList.remove( - "inline-menu-list-container--with-new-item-button", - ); - } + this.resetInlineMenuContainer(); if (!ciphers?.length) { this.buildNoResultsInlineMenuList(); @@ -191,6 +192,18 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { this.newItemButtonElement.addEventListener(EVENTS.KEYUP, this.handleNewItemButtonKeyUpEvent); } + /** + * Clears and resets the inline menu list container. + */ + private resetInlineMenuContainer() { + if (this.inlineMenuListContainer) { + this.inlineMenuListContainer.innerHTML = ""; + this.inlineMenuListContainer.classList.remove( + "inline-menu-list-container--with-new-item-button", + ); + } + } + /** * Inline menu view that is presented when no ciphers are found for a given page. * Facilitates the ability to add a new vault item from the inline menu. @@ -330,7 +343,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { this.ciphersList.addEventListener( EVENTS.SCROLL, this.useEventHandlersMemo( - throttle(() => this.updatePasskeysHeadingsOnScroll(this.ciphersList.scrollTop), 50), + throttle(this.handleThrottledOnScrollEvent, 50), UPDATE_PASSKEYS_HEADINGS_ON_SCROLL, ), options, @@ -342,7 +355,10 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { * Handles updating the list of ciphers when the * user scrolls to the bottom of the list. */ - private updateCiphersListOnScroll = () => { + private updateCiphersListOnScroll = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (this.cipherListScrollIsDebounced) { return; } @@ -382,6 +398,18 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { } }; + /** + * Throttled handler for updating the passkeys and login headings when the user scrolls the ciphers list. + * + * @param event - The scroll event. + */ + private handleThrottledOnScrollEvent = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + this.updatePasskeysHeadingsOnScroll(this.ciphersList.scrollTop); + }; + /** * Updates the passkeys and login headings when the user scrolls the ciphers list. * @@ -596,16 +624,29 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => { const usePasskey = !!cipher.login?.passkey; return this.useEventHandlersMemo( - () => - this.postMessageToParent({ - command: "fillAutofillInlineMenuCipher", - inlineMenuCipherId: cipher.id, - usePasskey, - }), + () => this.triggerFillCipherClickEvent(cipher, usePasskey), `${cipher.id}-fill-cipher-button-click-handler-${usePasskey ? "passkey" : ""}`, ); }; + /** + * Triggers a fill of the currently selected cipher. + * + * @param cipher - The cipher to fill. + * @param usePasskey - Whether the cipher uses a passkey. + */ + private triggerFillCipherClickEvent = (cipher: InlineMenuCipherData, usePasskey: boolean) => { + if (usePasskey) { + this.createPasskeyAuthenticatingLoader(); + } + + this.postMessageToParent({ + command: "fillAutofillInlineMenuCipher", + inlineMenuCipherId: cipher.id, + usePasskey, + }); + }; + /** * Handles the keyup event for the fill cipher button. Facilitates * selecting the next/previous cipher item on ArrowDown/ArrowUp. Also @@ -889,6 +930,26 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement { return cipherDetailsElement; } + /** + * Creates an indicator for the user that the passkey is being authenticated. + */ + private createPasskeyAuthenticatingLoader() { + this.isPasskeyAuthInProgress = true; + this.resetInlineMenuContainer(); + + const passkeyAuthenticatingLoader = globalThis.document.createElement("div"); + passkeyAuthenticatingLoader.classList.add("passkey-authenticating-loader"); + passkeyAuthenticatingLoader.textContent = this.getTranslation("authenticating"); + passkeyAuthenticatingLoader.appendChild(buildSvgDomElement(spinnerIcon)); + + this.inlineMenuListContainer.appendChild(passkeyAuthenticatingLoader); + + globalThis.setTimeout(() => { + this.isPasskeyAuthInProgress = false; + this.postMessageToParent({ command: "checkAutofillInlineMenuButtonFocused" }); + }, 4000); + } + /** * Gets the subtitle text for a given cipher. * diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss index 9cd8ae1a73..fb12da78f4 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/list/list.scss @@ -15,6 +15,8 @@ body { width: 100%; padding: 0; margin: 0; + font-family: $font-family-sans-serif; + font-weight: 400; @include themify($themes) { color: themed("textColor"); @@ -23,8 +25,6 @@ body { } .inline-menu-list-message { - font-family: $font-family-sans-serif; - font-weight: 400; font-size: 1.4rem; line-height: 1.5; width: 100%; @@ -393,3 +393,38 @@ body { } } } + +@keyframes bwi-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(359deg); + } +} + +.passkey-authenticating-loader { + display: flex; + align-content: center; + align-items: center; + justify-content: center; + text-align: center; + padding: 1rem 0.8rem; + font-size: 1.4rem; + font-weight: 400; + + @include themify($themes) { + color: themed("passkeysAuthenticating"); + } + + svg { + animation: bwi-spin 2s infinite linear; + margin-left: 1rem; + + path { + @include themify($themes) { + fill: themed("passkeysAuthenticating") !important; + } + } + } +} diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 5d9bfa9f9d..cd49da7219 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -3017,9 +3017,11 @@ export default class AutofillService implements AutofillServiceInterface { const tabs = await BrowserApi.tabsQuery({}); for (let index = 0; index < tabs.length; index++) { const tab = tabs[index]; - if (tab.url?.startsWith("http")) { + if (tab?.id && tab.url?.startsWith("http")) { const frames = await BrowserApi.getAllFrameDetails(tab.id); - frames.forEach((frame) => this.injectAutofillScripts(tab, frame.frameId, false)); + if (frames) { + frames.forEach((frame) => this.injectAutofillScripts(tab, frame.frameId, false)); + } } } } diff --git a/apps/browser/src/autofill/shared/styles/variables.scss b/apps/browser/src/autofill/shared/styles/variables.scss index 40b0080bb0..bd75415a19 100644 --- a/apps/browser/src/autofill/shared/styles/variables.scss +++ b/apps/browser/src/autofill/shared/styles/variables.scss @@ -10,6 +10,7 @@ $border-color: #ced4dc; $border-color-dark: #ddd; $border-radius: 3px; $focus-outline-color: #1252a3; +$muted-blue: #5a6d91; $brand-primary: #175ddc; @@ -45,6 +46,7 @@ $themes: ( focusOutlineColor: $focus-outline-color, successColor: $success-color-light, errorColor: $error-color-light, + passkeysAuthenticating: $muted-blue, ), dark: ( textColor: #ffffff, @@ -60,6 +62,7 @@ $themes: ( focusOutlineColor: lighten($focus-outline-color, 25%), successColor: $success-color-dark, errorColor: $error-color-dark, + passkeysAuthenticating: #bac0ce, ), nord: ( textColor: $nord5, @@ -74,6 +77,7 @@ $themes: ( borderColor: $nord0, focusOutlineColor: lighten($focus-outline-color, 25%), successColor: $success-color-dark, + passkeysAuthenticating: $nord4, ), solarizedDark: ( textColor: $solarizedDarkBase2, @@ -89,6 +93,7 @@ $themes: ( borderColor: $solarizedDarkBase2, focusOutlineColor: lighten($focus-outline-color, 15%), successColor: $success-color-dark, + passkeysAuthenticating: $solarizedDarkBase2, ), ); diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 98c0a97ac5..f2fafac3d8 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -426,3 +426,50 @@ export function getSubmitButtonKeywordsSet(element: HTMLElement): Set { return keywordsSet; } + +/** + * Generates the origin and subdomain match patterns for the URL. + * + * @param url - The URL of the tab + */ +export function generateDomainMatchPatterns(url: string): string[] { + try { + const extensionUrlPattern = + /^(chrome|chrome-extension|moz-extension|safari-web-extension):\/\/\/?/; + if (extensionUrlPattern.test(url)) { + return []; + } + + // Add protocol to URL if it is missing to allow for parsing the hostname correctly + const urlPattern = /^(https?|file):\/\/\/?/; + if (!urlPattern.test(url)) { + url = `https://${url}`; + } + + let protocolGlob = "*://"; + if (url.startsWith("file:///")) { + protocolGlob = "*:///"; // File URLs require three slashes to be a valid match pattern + } + + const parsedUrl = new URL(url); + const originMatchPattern = `${protocolGlob}${parsedUrl.hostname}/*`; + + const splitHost = parsedUrl.hostname.split("."); + const domain = splitHost.slice(-2).join("."); + const subDomainMatchPattern = `${protocolGlob}*.${domain}/*`; + + return [originMatchPattern, subDomainMatchPattern]; + } catch { + return []; + } +} + +/** + * Determines if the status code of the web response is invalid. An invalid status code is + * any status code that is not in the 200-299 range. + * + * @param statusCode - The status code of the web response + */ +export function isInvalidResponseStatusCode(statusCode: number) { + return statusCode < 200 || statusCode >= 300; +} diff --git a/apps/browser/src/autofill/utils/svg-icons.ts b/apps/browser/src/autofill/utils/svg-icons.ts index df2cfa189f..908e57f2cb 100644 --- a/apps/browser/src/autofill/utils/svg-icons.ts +++ b/apps/browser/src/autofill/utils/svg-icons.ts @@ -27,3 +27,6 @@ export const passkeyIcon = export const circleCheckIcon = ''; + +export const spinnerIcon = + ''; diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index affa804cc7..4851541576 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -3,7 +3,9 @@ import { Component, importProvidersFrom } from "@angular/core"; import { RouterModule } from "@angular/router"; import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { AvatarModule, BadgeModule, @@ -318,6 +320,30 @@ export default { }); }, }, + { + provide: PolicyService, + useFactory: () => { + return { + policyAppliesToActiveUser$: () => { + return { + pipe: () => ({ + subscribe: () => ({}), + }), + }; + }, + }; + }, + }, + { + provide: SendService, + useFactory: () => { + return { + sends$: () => { + return { pipe: () => ({}) }; + }, + }; + }, + }, ], }), applicationConfig({ diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index ced3f6462e..8463bbe6e9 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -1,9 +1,41 @@ import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { RouterModule } from "@angular/router"; +import { filter, map, switchMap } from "rxjs"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { LinkModule } from "@bitwarden/components"; +const allNavButtons = [ + { + label: "Vault", + page: "/tabs/vault", + iconKey: "lock", + iconKeyActive: "lock-f", + }, + { + label: "Generator", + page: "/tabs/generator", + iconKey: "generate", + iconKeyActive: "generate-f", + }, + { + label: "Send", + page: "/tabs/send", + iconKey: "send", + iconKeyActive: "send-f", + }, + { + label: "Settings", + page: "/tabs/settings", + iconKey: "cog", + iconKeyActive: "cog-f", + }, +]; + @Component({ selector: "popup-tab-navigation", templateUrl: "popup-tab-navigation.component.html", @@ -14,30 +46,23 @@ import { LinkModule } from "@bitwarden/components"; }, }) export class PopupTabNavigationComponent { - navButtons = [ - { - label: "Vault", - page: "/tabs/vault", - iconKey: "lock", - iconKeyActive: "lock-f", - }, - { - label: "Generator", - page: "/tabs/generator", - iconKey: "generate", - iconKeyActive: "generate-f", - }, - { - label: "Send", - page: "/tabs/send", - iconKey: "send", - iconKeyActive: "send-f", - }, - { - label: "Settings", - page: "/tabs/settings", - iconKey: "cog", - iconKeyActive: "cog-f", - }, - ]; + navButtons = allNavButtons; + constructor( + private policyService: PolicyService, + private sendService: SendService, + ) { + this.policyService + .policyAppliesToActiveUser$(PolicyType.DisableSend) + .pipe( + filter((policyAppliesToActiveUser) => policyAppliesToActiveUser), + switchMap(() => this.sendService.sends$), + map((sends) => sends.length > 1), + takeUntilDestroyed(), + ) + .subscribe((hasSends) => { + this.navButtons = hasSends + ? allNavButtons + : allNavButtons.filter((b) => b.page !== "/tabs/send"); + }); + } } diff --git a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html index 7f723cc736..e96a0742a0 100644 --- a/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html +++ b/apps/browser/src/tools/popup/send-v2/add-edit/send-add-edit.component.html @@ -9,6 +9,8 @@ > + + + + + diff --git a/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts new file mode 100644 index 0000000000..fb21b5bb02 --- /dev/null +++ b/apps/browser/src/tools/popup/send-v2/send-file-popout-dialog/send-file-popout-dialog.component.ts @@ -0,0 +1,25 @@ +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/popup/browser-popup-utils"; + +@Component({ + selector: "send-file-popout-dialog", + templateUrl: "./send-file-popout-dialog.component.html", + standalone: true, + imports: [JslibModule, CommonModule, DialogModule, ButtonModule, TypographyModule], +}) +export class SendFilePopoutDialogComponent { + constructor(private dialogService: DialogService) {} + + async popOutWindow() { + await BrowserPopupUtils.openCurrentPagePopout(window); + } + + close() { + this.dialogService.closeAll(); + } +} diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html index a8dd3e24f2..698901d846 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.html @@ -1,12 +1,20 @@ - - + +
+ + {{ "sendDisabledWarning" | i18n }} + + + + + +
{{ "sendsNoItemsTitle" | i18n }} {{ "sendsNoItemsMessage" | i18n }} - +
@@ -31,9 +39,4 @@
- -
- - -
diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts index 50e5531743..63e3c2d2fc 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.spec.ts @@ -7,6 +7,7 @@ import { of, BehaviorSubject } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; @@ -46,6 +47,7 @@ describe("SendV2Component", () => { let sendListFiltersServiceFilters$: BehaviorSubject<{ sendType: SendType | null }>; let sendItemsServiceEmptyList$: BehaviorSubject; let sendItemsServiceNoFilteredResults$: BehaviorSubject; + let policyService: MockProxy; beforeEach(async () => { sendListFiltersServiceFilters$ = new BehaviorSubject({ sendType: null }); @@ -60,6 +62,9 @@ describe("SendV2Component", () => { latestSearchText$: of(""), }); + policyService = mock(); + policyService.policyAppliesToActiveUser$.mockReturnValue(of(true)); // Return `true` by default + sendListFiltersService = new SendListFiltersService(mock(), new FormBuilder()); sendListFiltersService.filters$ = sendListFiltersServiceFilters$; @@ -104,6 +109,7 @@ describe("SendV2Component", () => { { provide: I18nService, useValue: { t: (key: string) => key } }, { provide: SendListFiltersService, useValue: sendListFiltersService }, { provide: PopupRouterCacheService, useValue: mock() }, + { provide: PolicyService, useValue: policyService }, ], }).compileComponents(); diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index 5c1ec89fde..26a995e8c4 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -5,8 +5,10 @@ import { RouterLink } from "@angular/router"; import { combineLatest } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components"; +import { ButtonModule, CalloutModule, Icons, NoItemsModule } from "@bitwarden/components"; import { NoSendsIcon, NewSendDropdownComponent, @@ -31,6 +33,7 @@ export enum SendState { templateUrl: "send-v2.component.html", standalone: true, imports: [ + CalloutModule, PopupPageComponent, PopupHeaderComponent, PopOutComponent, @@ -48,22 +51,20 @@ export enum SendState { }) export class SendV2Component implements OnInit, OnDestroy { sendType = SendType; - sendState = SendState; protected listState: SendState | null = null; - protected sends$ = this.sendItemsService.filteredAndSortedSends$; - protected title: string = "allSends"; - protected noItemIcon = NoSendsIcon; - protected noResultsIcon = Icons.NoResults; + protected sendsDisabled = false; + constructor( protected sendItemsService: SendItemsService, protected sendListFiltersService: SendListFiltersService, + private policyService: PolicyService, ) { combineLatest([ this.sendItemsService.emptyList$, @@ -90,6 +91,13 @@ export class SendV2Component implements OnInit, OnDestroy { this.listState = null; }); + + this.policyService + .policyAppliesToActiveUser$(PolicyType.DisableSend) + .pipe(takeUntilDestroyed()) + .subscribe((sendsDisabled) => { + this.sendsDisabled = sendsDisabled; + }); } ngOnInit(): void {} diff --git a/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.ts b/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.ts index 0f05480ea1..7cdb691d56 100644 --- a/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/about-page/more-from-bitwarden-page-v2.component.ts @@ -38,7 +38,7 @@ export class MoreFromBitwardenPageV2Component { private organizationService: OrganizationService, ) { this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; - this.familySponsorshipAvailable$ = this.organizationService.canManageSponsorships$; + this.familySponsorshipAvailable$ = this.organizationService.familySponsorshipAvailable$; } async openFreeBitwardenFamiliesPage() { diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts index a02713c53f..107447c50a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -14,6 +14,7 @@ import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; @@ -30,20 +31,19 @@ import { import { PremiumUpgradePromptService } from "../../../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service"; import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view"; import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component"; -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 { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service"; -import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; +import { BrowserViewPasswordHistoryService } from "../../../services/browser-view-password-history.service"; + +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 { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service"; @Component({ selector: "app-view-v2", templateUrl: "view-v2.component.html", standalone: true, - providers: [ - { provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService }, - ], imports: [ CommonModule, SearchModule, @@ -58,6 +58,10 @@ import { VaultPopupAutofillService } from "../../../services/vault-popup-autofil AsyncActionsModule, PopOutComponent, ], + providers: [ + { provide: ViewPasswordHistoryService, useClass: BrowserViewPasswordHistoryService }, + { provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService }, + ], }) export class ViewV2Component { headerText: string; diff --git a/apps/browser/src/vault/popup/services/browser-view-password-history.service.spec.ts b/apps/browser/src/vault/popup/services/browser-view-password-history.service.spec.ts new file mode 100644 index 0000000000..ded4686477 --- /dev/null +++ b/apps/browser/src/vault/popup/services/browser-view-password-history.service.spec.ts @@ -0,0 +1,28 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { BrowserViewPasswordHistoryService } from "./browser-view-password-history.service"; + +describe("BrowserViewPasswordHistoryService", () => { + let service: BrowserViewPasswordHistoryService; + let router: MockProxy; + + beforeEach(async () => { + router = mock(); + await TestBed.configureTestingModule({ + providers: [BrowserViewPasswordHistoryService, { provide: Router, useValue: router }], + }).compileComponents(); + + service = TestBed.inject(BrowserViewPasswordHistoryService); + }); + + describe("viewPasswordHistory", () => { + it("navigates to the password history screen", async () => { + await service.viewPasswordHistory("test"); + expect(router.navigate).toHaveBeenCalledWith(["/cipher-password-history"], { + queryParams: { cipherId: "test" }, + }); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts b/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts new file mode 100644 index 0000000000..6b57b0b625 --- /dev/null +++ b/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts @@ -0,0 +1,18 @@ +import { inject } from "@angular/core"; +import { Router } from "@angular/router"; + +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; + +/** + * This class handles the premium upgrade process for the browser extension. + */ +export class BrowserViewPasswordHistoryService implements ViewPasswordHistoryService { + private router = inject(Router); + + /** + * Navigates to the password history screen. + */ + async viewPasswordHistory(cipherId: string) { + await this.router.navigate(["/cipher-password-history"], { queryParams: { cipherId } }); + } +} diff --git a/apps/web/src/app/vault/individual-vault/password-history.component.html b/apps/web/src/app/vault/individual-vault/password-history.component.html new file mode 100644 index 0000000000..bae10d85aa --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/password-history.component.html @@ -0,0 +1,40 @@ + + + {{ "passwordHistory" | i18n }} + + +
+ +
+ +
{{ h.lastUsedDate | date: "medium" }}
+
+ + + + + +
+
+
+

{{ "noPasswordsInList" | i18n }}

+
+
+ + + +
diff --git a/apps/web/src/app/vault/individual-vault/password-history.component.ts b/apps/web/src/app/vault/individual-vault/password-history.component.ts new file mode 100644 index 0000000000..21a1b01e58 --- /dev/null +++ b/apps/web/src/app/vault/individual-vault/password-history.component.ts @@ -0,0 +1,131 @@ +import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { OnInit, Inject, Component } from "@angular/core"; +import { firstValueFrom, map } from "rxjs"; + +import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view"; +import { + AsyncActionsModule, + DialogModule, + DialogService, + ToastService, + ItemModule, +} from "@bitwarden/components"; + +import { SharedModule } from "../../shared/shared.module"; + +/** + * The parameters for the password history dialog. + */ +export interface ViewPasswordHistoryDialogParams { + cipherId: CipherId; +} + +/** + * A dialog component that displays the password history for a cipher. + */ +@Component({ + selector: "app-vault-password-history", + templateUrl: "password-history.component.html", + standalone: true, + imports: [CommonModule, AsyncActionsModule, DialogModule, ItemModule, SharedModule], +}) +export class PasswordHistoryComponent implements OnInit { + /** + * The ID of the cipher to display the password history for. + */ + cipherId: CipherId; + + /** + * The password history for the cipher. + */ + history: PasswordHistoryView[] = []; + + /** + * The constructor for the password history dialog component. + * @param params The parameters passed to the password history dialog. + * @param cipherService The cipher service - used to get the cipher to display the password history for. + * @param platformUtilsService The platform utils service - used to copy passwords to the clipboard. + * @param i18nService The i18n service - used to translate strings. + * @param accountService The account service - used to get the active account to decrypt the cipher. + * @param win The window object - used to copy passwords to the clipboard. + * @param toastService The toast service - used to display feedback to the user when a password is copied. + * @param dialogRef The dialog reference - used to close the dialog. + **/ + constructor( + @Inject(DIALOG_DATA) public params: ViewPasswordHistoryDialogParams, + protected cipherService: CipherService, + protected platformUtilsService: PlatformUtilsService, + protected i18nService: I18nService, + protected accountService: AccountService, + @Inject(WINDOW) private win: Window, + protected toastService: ToastService, + private dialogRef: DialogRef, + ) { + /** + * Set the cipher ID from the parameters. + */ + this.cipherId = params.cipherId; + } + + async ngOnInit() { + await this.init(); + } + + /** + * Copies a password to the clipboard. + * @param password The password to copy. + */ + copy(password: string) { + const copyOptions = this.win != null ? { window: this.win } : undefined; + this.platformUtilsService.copyToClipboard(password, copyOptions); + this.toastService.showToast({ + variant: "info", + title: "", + message: this.i18nService.t("valueCopied", this.i18nService.t("password")), + }); + } + + /** + * Initializes the password history dialog component. + */ + protected async init() { + const cipher = await this.cipherService.get(this.cipherId); + const activeAccount = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a: { id: string | undefined }) => a)), + ); + + if (!activeAccount || !activeAccount.id) { + throw new Error("Active account is not available."); + } + + const activeUserId = activeAccount.id as UserId; + const decCipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), + ); + this.history = decCipher.passwordHistory == null ? [] : decCipher.passwordHistory; + } + + /** + * Closes the password history dialog. + */ + close() { + this.dialogRef.close(); + } +} + +/** + * Strongly typed wrapper around the dialog service to open the password history dialog. + */ +export function openPasswordHistoryDialog( + dialogService: DialogService, + config: DialogConfig, +) { + return dialogService.open(PasswordHistoryComponent, config); +} diff --git a/apps/web/src/app/vault/individual-vault/view.component.ts b/apps/web/src/app/vault/individual-vault/view.component.ts index 67a0223c73..ead52d805a 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -8,6 +8,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; @@ -22,6 +23,7 @@ import { PremiumUpgradePromptService } from "../../../../../../libs/common/src/v import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component"; import { SharedModule } from "../../shared/shared.module"; import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service"; +import { WebViewPasswordHistoryService } from "../services/web-view-password-history.service"; export interface ViewCipherDialogParams { cipher: CipherView; @@ -57,6 +59,7 @@ export interface ViewCipherDialogCloseResult { standalone: true, imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule], providers: [ + { provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService }, { provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService }, ], }) diff --git a/apps/web/src/app/vault/services/web-view-password-history.service.spec.ts b/apps/web/src/app/vault/services/web-view-password-history.service.spec.ts new file mode 100644 index 0000000000..2c5fb82c53 --- /dev/null +++ b/apps/web/src/app/vault/services/web-view-password-history.service.spec.ts @@ -0,0 +1,45 @@ +import { Overlay } from "@angular/cdk/overlay"; +import { TestBed } from "@angular/core/testing"; + +import { CipherId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; + +import { openPasswordHistoryDialog } from "../individual-vault/password-history.component"; + +import { WebViewPasswordHistoryService } from "./web-view-password-history.service"; + +jest.mock("../individual-vault/password-history.component", () => ({ + openPasswordHistoryDialog: jest.fn(), +})); + +describe("WebViewPasswordHistoryService", () => { + let service: WebViewPasswordHistoryService; + let dialogService: DialogService; + + beforeEach(async () => { + const mockDialogService = { + open: jest.fn(), + }; + + await TestBed.configureTestingModule({ + providers: [ + WebViewPasswordHistoryService, + { provide: DialogService, useValue: mockDialogService }, + Overlay, + ], + }).compileComponents(); + + service = TestBed.inject(WebViewPasswordHistoryService); + dialogService = TestBed.inject(DialogService); + }); + + describe("viewPasswordHistory", () => { + it("calls openPasswordHistoryDialog with the correct parameters", async () => { + const mockCipherId = "cipher-id" as CipherId; + await service.viewPasswordHistory(mockCipherId); + expect(openPasswordHistoryDialog).toHaveBeenCalledWith(dialogService, { + data: { cipherId: mockCipherId }, + }); + }); + }); +}); diff --git a/apps/web/src/app/vault/services/web-view-password-history.service.ts b/apps/web/src/app/vault/services/web-view-password-history.service.ts new file mode 100644 index 0000000000..cbdc3928e6 --- /dev/null +++ b/apps/web/src/app/vault/services/web-view-password-history.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from "@angular/core"; + +import { CipherId } from "@bitwarden/common/types/guid"; +import { DialogService } from "@bitwarden/components"; + +import { ViewPasswordHistoryService } from "../../../../../../libs/common/src/vault/abstractions/view-password-history.service"; +import { openPasswordHistoryDialog } from "../individual-vault/password-history.component"; + +/** + * This service is used to display the password history dialog in the web vault. + */ +@Injectable() +export class WebViewPasswordHistoryService implements ViewPasswordHistoryService { + constructor(private dialogService: DialogService) {} + + /** + * Opens the password history dialog for the given cipher ID. + * @param cipherId The ID of the cipher to view the password history for. + */ + async viewPasswordHistory(cipherId: CipherId) { + openPasswordHistoryDialog(this.dialogService, { data: { cipherId } }); + } +} diff --git a/libs/angular/src/admin-console/components/collections.component.ts b/libs/angular/src/admin-console/components/collections.component.ts index d6801aa155..93410afaae 100644 --- a/libs/angular/src/admin-console/components/collections.component.ts +++ b/libs/angular/src/admin-console/components/collections.component.ts @@ -72,7 +72,7 @@ export class CollectionsComponent implements OnInit { if (this.organization.canEditAllCiphers) { return !!(c as any).checked; } else { - return !!(c as any).checked && c.readOnly == null; + return !!(c as any).checked && !c.readOnly; } }) .map((c) => c.id); diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 0cea2aee53..a2ea6aa886 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -117,6 +117,10 @@ export abstract class OrganizationService { * Emits true if the user can create or manage a Free Bitwarden Families sponsorship. */ canManageSponsorships$: Observable; + /** + * Emits true if any of the user's organizations have a Free Bitwarden Families sponsorship available. + */ + familySponsorshipAvailable$: Observable; hasOrganizations: () => Promise; get$: (id: string) => Observable; get: (id: string) => Promise; diff --git a/libs/common/src/admin-console/services/organization/organization.service.ts b/libs/common/src/admin-console/services/organization/organization.service.ts index d8fe18dc5c..91bfcbd0d5 100644 --- a/libs/common/src/admin-console/services/organization/organization.service.ts +++ b/libs/common/src/admin-console/services/organization/organization.service.ts @@ -88,6 +88,10 @@ export class OrganizationService implements InternalOrganizationServiceAbstracti mapToBooleanHasAnyOrganizations(), ); + familySponsorshipAvailable$ = this.organizations$.pipe( + map((orgs) => orgs.some((o) => o.familySponsorshipAvailable)), + ); + async hasOrganizations(): Promise { return await firstValueFrom(this.organizations$.pipe(mapToBooleanHasAnyOrganizations())); } diff --git a/libs/common/src/vault/abstractions/view-password-history.service.ts b/libs/common/src/vault/abstractions/view-password-history.service.ts new file mode 100644 index 0000000000..d9b1306eac --- /dev/null +++ b/libs/common/src/vault/abstractions/view-password-history.service.ts @@ -0,0 +1,8 @@ +import { CipherId } from "../../types/guid"; + +/** + * The ViewPasswordHistoryService is responsible for displaying the password history for a cipher. + */ +export abstract class ViewPasswordHistoryService { + abstract viewPasswordHistory(cipherId?: CipherId): Promise; +} diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html index 53483065b7..01b96e3bc5 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.html @@ -15,12 +15,19 @@ {{ "password" | i18n }} {{ "newPassword" | i18n }} - + {{ "sendPasswordDescV2" | i18n }} diff --git a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts index 89ab9d19ba..a73a3a6ad8 100644 --- a/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts +++ b/libs/tools/send/send-ui/src/send-form/components/options/send-options.component.ts @@ -97,6 +97,7 @@ export class SendOptionsComponent implements OnInit { }); }); } + ngOnInit() { if (this.sendFormContainer.originalSendView) { this.sendOptionsForm.patchValue({ @@ -107,5 +108,8 @@ export class SendOptionsComponent implements OnInit { notes: this.sendFormContainer.originalSendView.notes, }); } + if (!this.config.areSendsAllowed) { + this.sendOptionsForm.disable(); + } } } diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/base-send-details.component.ts b/libs/tools/send/send-ui/src/send-form/components/send-details/base-send-details.component.ts deleted file mode 100644 index b5cf8ee0c7..0000000000 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/base-send-details.component.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { DatePipe } from "@angular/common"; -import { Component, Input, OnInit } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { FormBuilder, FormControl, Validators } from "@angular/forms"; - -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; - -import { SendFormConfig } from "../../abstractions/send-form-config.service"; -import { SendFormContainer } from "../../send-form-container"; - -// Value = hours -export enum DatePreset { - OneHour = 1, - OneDay = 24, - TwoDays = 48, - ThreeDays = 72, - SevenDays = 168, - FourteenDays = 336, - ThirtyDays = 720, -} - -export interface DatePresetSelectOption { - name: string; - value: DatePreset | string; -} - -@Component({ - selector: "base-send-details-behavior", - template: "", -}) -export class BaseSendDetailsComponent implements OnInit { - @Input() config: SendFormConfig; - @Input() originalSendView?: SendView; - - customDeletionDateOption: DatePresetSelectOption | null = null; - datePresetOptions: DatePresetSelectOption[] = []; - - sendDetailsForm = this.formBuilder.group({ - name: new FormControl("", Validators.required), - selectedDeletionDatePreset: new FormControl(DatePreset.SevenDays || "", Validators.required), - }); - - constructor( - protected sendFormContainer: SendFormContainer, - protected formBuilder: FormBuilder, - protected i18nService: I18nService, - protected datePipe: DatePipe, - ) { - this.sendDetailsForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { - this.sendFormContainer.patchSend((send) => { - return Object.assign(send, { - name: value.name, - deletionDate: new Date(this.formattedDeletionDate), - expirationDate: new Date(this.formattedDeletionDate), - } as SendView); - }); - }); - - this.sendFormContainer.registerChildForm("sendDetailsForm", this.sendDetailsForm); - } - - async ngOnInit() { - this.setupDeletionDatePresets(); - - if (this.originalSendView) { - this.sendDetailsForm.patchValue({ - name: this.originalSendView.name, - selectedDeletionDatePreset: this.originalSendView.deletionDate.toString(), - }); - - if (this.originalSendView.deletionDate) { - this.customDeletionDateOption = { - name: this.datePipe.transform(this.originalSendView.deletionDate, "MM/dd/yyyy, hh:mm a"), - value: this.originalSendView.deletionDate.toString(), - }; - this.datePresetOptions.unshift(this.customDeletionDateOption); - } - } - } - - setupDeletionDatePresets() { - const defaultSelections: DatePresetSelectOption[] = [ - { name: this.i18nService.t("oneHour"), value: DatePreset.OneHour }, - { name: this.i18nService.t("oneDay"), value: DatePreset.OneDay }, - { name: this.i18nService.t("days", "2"), value: DatePreset.TwoDays }, - { name: this.i18nService.t("days", "3"), value: DatePreset.ThreeDays }, - { name: this.i18nService.t("days", "7"), value: DatePreset.SevenDays }, - { name: this.i18nService.t("days", "14"), value: DatePreset.FourteenDays }, - { name: this.i18nService.t("days", "30"), value: DatePreset.ThirtyDays }, - ]; - - this.datePresetOptions = defaultSelections; - } - - get formattedDeletionDate(): string { - const now = new Date(); - const selectedValue = this.sendDetailsForm.controls.selectedDeletionDatePreset.value; - - if (typeof selectedValue === "string") { - return selectedValue; - } - - const milliseconds = now.setTime(now.getTime() + (selectedValue as number) * 60 * 60 * 1000); - return new Date(milliseconds).toString(); - } -} diff --git a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html index 98f399760b..d4c253303b 100644 --- a/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html +++ b/libs/tools/send/send-ui/src/send-form/components/send-details/send-details.component.html @@ -23,7 +23,7 @@ {{ "sendLink" | i18n }} - +