Merge branch 'main' into auth/pm-8111/browser-refresh-login-component

This commit is contained in:
Alec Rippberger 2024-09-25 22:07:51 -05:00
commit 309576f8c5
No known key found for this signature in database
GPG Key ID: 9DD8DA583B28154A
48 changed files with 1040 additions and 288 deletions

View File

@ -2522,6 +2522,14 @@
"message": "Send saved", "message": "Send saved",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." "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": { "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." "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": { "sendSafariFileWarning": {
"message": "In order to choose a file using Safari, pop out to a new window by clicking this banner." "message": "In order to choose a file using Safari, pop out to a new window by clicking this banner."
}, },
"popOut": {
"message": "Pop out"
},
"sendFileCalloutHeader": { "sendFileCalloutHeader": {
"message": "Before you start" "message": "Before you start"
}, },
@ -4503,5 +4514,8 @@
}, },
"noEditPermissions": { "noEditPermissions": {
"message": "You don't have permission to edit this item" "message": "You don't have permission to edit this item"
},
"authenticating": {
"message": "Authenticating"
} }
} }

View File

@ -6,6 +6,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserApi } from "../../platform/browser/browser-api";
import { generateDomainMatchPatterns, isInvalidResponseStatusCode } from "../utils";
import { import {
ActiveFormSubmissionRequests, ActiveFormSubmissionRequests,
@ -109,35 +110,11 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
*/ */
private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) { private getSenderUrlMatchPatterns(sender: chrome.runtime.MessageSender) {
return new Set([ return new Set([
...this.generateMatchPatterns(sender.url), ...generateDomainMatchPatterns(sender.url),
...this.generateMatchPatterns(sender.tab.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 * 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. * 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) => { private handleOnCompletedRequestEvent = async (details: chrome.webRequest.WebResponseDetails) => {
if ( if (
this.requestHostIsInvalid(details) || this.requestHostIsInvalid(details) ||
this.isInvalidStatusCode(details.statusCode) || isInvalidResponseStatusCode(details.statusCode) ||
!this.activeFormSubmissionRequests.has(details.requestId) !this.activeFormSubmissionRequests.has(details.requestId)
) { ) {
return; return;
@ -472,16 +449,6 @@ export class OverlayNotificationsBackground implements OverlayNotificationsBackg
this.setupWebRequestsListeners(); 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 * 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. * start with "http" or a tab id that is less than 0.

View File

@ -61,6 +61,7 @@ import {
triggerPortOnDisconnectEvent, triggerPortOnDisconnectEvent,
triggerPortOnMessageEvent, triggerPortOnMessageEvent,
triggerWebNavigationOnCommittedEvent, triggerWebNavigationOnCommittedEvent,
triggerWebRequestOnCompletedEvent,
} from "../spec/testing-utils"; } from "../spec/testing-utils";
import { import {
@ -3003,37 +3004,95 @@ describe("OverlayBackground", () => {
expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code"); expect(copyToClipboardSpy).toHaveBeenCalledWith("totp-code");
}); });
it("triggers passkey authentication through mediated conditional UI", async () => { describe("triggering passkey authentication", () => {
const fido2Credential = mock<Fido2CredentialView>({ credentialId: "credential-id" }); let cipher1: CipherView;
const cipher1 = mock<CipherView>({
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");
sendPortMessage(listMessageConnectorSpy, { beforeEach(() => {
command: "fillAutofillInlineMenuCipher", const fido2Credential = mock<Fido2CredentialView>({ credentialId: "credential-id" });
inlineMenuCipherId: "inline-menu-cipher-1", cipher1 = mock<CipherView>({
usePasskey: true, id: "inline-menu-cipher-1",
portKey, 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<chrome.webRequest.WebResponseCacheDetails>({
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<chrome.webRequest.WebResponseCacheDetails>({
statusCode: 200,
}),
);
jest.advanceTimersByTime(3100);
expect(listPortSpy.postMessage).toHaveBeenCalledWith({
command: "triggerDelayedAutofillInlineMenuClosure",
});
});
});
}); });
}); });

View File

@ -55,7 +55,11 @@ import {
MAX_SUB_FRAME_DEPTH, MAX_SUB_FRAME_DEPTH,
} from "../enums/autofill-overlay.enum"; } from "../enums/autofill-overlay.enum";
import { AutofillService } from "../services/abstractions/autofill.service"; import { AutofillService } from "../services/abstractions/autofill.service";
import { generateRandomChars } from "../utils"; import {
generateDomainMatchPatterns,
generateRandomChars,
isInvalidResponseStatusCode,
} from "../utils";
import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background"; import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background";
import { import {
@ -151,7 +155,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
addEditCipherSubmitted: () => this.updateOverlayCiphers(), addEditCipherSubmitted: () => this.updateOverlayCiphers(),
editedCipher: () => this.updateOverlayCiphers(), editedCipher: () => this.updateOverlayCiphers(),
deletedCipher: () => this.updateOverlayCiphers(), deletedCipher: () => this.updateOverlayCiphers(),
fido2AbortRequest: ({ sender }) => this.abortFido2ActiveRequest(sender), fido2AbortRequest: ({ sender }) => this.abortFido2ActiveRequest(sender.tab.id),
}; };
private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = { private readonly inlineMenuButtonPortMessageHandlers: InlineMenuButtonPortMessageHandlers = {
triggerDelayedAutofillInlineMenuClosure: () => this.triggerDelayedInlineMenuClosure(), 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. * 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) { private async abortFido2ActiveRequest(tabId: number) {
this.fido2ActiveRequestManager.removeActiveRequest(sender.tab.id); this.fido2ActiveRequestManager.removeActiveRequest(tabId);
await this.updateOverlayCiphers(false); await this.updateOverlayCiphers(false);
} }
@ -939,11 +943,10 @@ export class OverlayBackground implements OverlayBackgroundInterface {
if (usePasskey && cipher.login?.hasFido2Credentials) { if (usePasskey && cipher.login?.hasFido2Credentials) {
await this.authenticatePasskeyCredential( await this.authenticatePasskeyCredential(
sender.tab.id, sender,
cipher.login.fido2Credentials[0].credentialId, cipher.login.fido2Credentials[0].credentialId,
); );
this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher); this.updateLastUsedInlineMenuCipher(inlineMenuCipherId, cipher);
this.closeInlineMenu(sender, { forceCloseInlineMenu: true });
return; return;
} }
@ -969,11 +972,11 @@ export class OverlayBackground implements OverlayBackgroundInterface {
/** /**
* Triggers a FIDO2 authentication from the inline menu using the passed credential ID. * 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 * @param credentialId - The credential ID to authenticate
*/ */
async authenticatePasskeyCredential(tabId: number, credentialId: string) { async authenticatePasskeyCredential(sender: chrome.runtime.MessageSender, credentialId: string) {
const request = this.fido2ActiveRequestManager.getActiveRequest(tabId); const request = this.fido2ActiveRequestManager.getActiveRequest(sender.tab.id);
if (!request) { if (!request) {
this.logService.error( this.logService.error(
"Could not complete passkey autofill due to missing active Fido2 request", "Could not complete passkey autofill due to missing active Fido2 request",
@ -981,9 +984,35 @@ export class OverlayBackground implements OverlayBackgroundInterface {
return; return;
} }
chrome.webRequest.onCompleted.addListener(this.handlePasskeyAuthenticationOnCompleted, {
urls: generateDomainMatchPatterns(sender.tab.url),
});
request.subject.next({ type: Fido2ActiveRequestEvents.Continue, credentialId }); 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. * 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"), passkeys: this.i18nService.translate("passkeys"),
passwords: this.i18nService.translate("passwords"), passwords: this.i18nService.translate("passwords"),
logInWithPasskey: this.i18nService.translate("logInWithPasskeyAriaLabel"), logInWithPasskey: this.i18nService.translate("logInWithPasskeyAriaLabel"),
authenticating: this.i18nService.translate("authenticating"),
}; };
} }

View File

@ -2131,6 +2131,44 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
</div> </div>
`; `;
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`] = `
<div
class="inline-menu-list-container theme_light"
>
<div
class="passkey-authenticating-loader"
>
<svg
aria-hidden="true"
fill="none"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M4.869 15.015a.588.588 0 1 0 0-1.177.588.588 0 0 0 0 1.177ZM8.252 16a.588.588 0 1 0 0-1.176.588.588 0 0 0 0 1.176Zm3.683-.911a.589.589 0 1 0 0-1.177.589.589 0 0 0 0 1.177ZM2.43 12.882a.693.693 0 1 0 0-1.387.693.693 0 0 0 0 1.387ZM1.318 9.738a.82.82 0 1 0 0-1.64.82.82 0 0 0 0 1.64Zm.69-3.578a.968.968 0 1 0 0-1.937.968.968 0 0 0 0 1.937ZM4.81 3.337a1.175 1.175 0 1 0 0-2.35 1.175 1.175 0 0 0 0 2.35Zm4.597-.676a1.33 1.33 0 1 0 0-2.661 1.33 1.33 0 0 0 0 2.66Zm4.543 2.954a1.553 1.553 0 1 0 0-3.105 1.553 1.553 0 0 0 0 3.105Z"
fill="#5A6D91"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 0h16v16H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</div>
</div>
`;
exports[`AutofillInlineMenuList initAutofillInlineMenuList the locked inline menu for an unauthenticated user creates the views for the locked inline menu 1`] = ` exports[`AutofillInlineMenuList initAutofillInlineMenuList the locked inline menu for an unauthenticated user creates the views for the locked inline menu 1`] = `
<div <div
class="inline-menu-list-container theme_light" class="inline-menu-list-container theme_light"

View File

@ -230,21 +230,56 @@ describe("AutofillInlineMenuList", () => {
postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey })); postWindowMessage(createInitAutofillInlineMenuListMessageMock({ portKey }));
}); });
it("allows the user to fill a cipher on click", () => { describe("filling a cipher", () => {
const fillCipherButton = it("allows the user to fill a cipher on click", () => {
autofillInlineMenuList["inlineMenuListContainer"].querySelector(".fill-cipher-button"); const fillCipherButton =
autofillInlineMenuList["inlineMenuListContainer"].querySelector(
".fill-cipher-button",
);
fillCipherButton.dispatchEvent(new Event("click")); fillCipherButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith( expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ {
command: "fillAutofillInlineMenuCipher", command: "fillAutofillInlineMenuCipher",
inlineMenuCipherId: "1", inlineMenuCipherId: "1",
usePasskey: false, usePasskey: false,
portKey, 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", () => { it("allows the user to move keyboard focus to the next cipher element on ArrowDown", () => {

View File

@ -14,6 +14,7 @@ import {
plusIcon, plusIcon,
viewCipherIcon, viewCipherIcon,
passkeyIcon, passkeyIcon,
spinnerIcon,
} from "../../../../utils/svg-icons"; } from "../../../../utils/svg-icons";
import { import {
AutofillInlineMenuListWindowMessageHandlers, AutofillInlineMenuListWindowMessageHandlers,
@ -40,6 +41,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
private passkeysHeadingHeight: number; private passkeysHeadingHeight: number;
private lastPasskeysListItemHeight: number; private lastPasskeysListItemHeight: number;
private ciphersListHeight: number; private ciphersListHeight: number;
private isPasskeyAuthInProgress = false;
private readonly showCiphersPerPage = 6; private readonly showCiphersPerPage = 6;
private readonly headingBorderClass = "inline-menu-list-heading--bordered"; private readonly headingBorderClass = "inline-menu-list-heading--bordered";
private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers = private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers =
@ -156,15 +158,14 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
ciphers: InlineMenuCipherData[], ciphers: InlineMenuCipherData[],
showInlineMenuAccountCreation?: boolean, showInlineMenuAccountCreation?: boolean,
) { ) {
if (this.isPasskeyAuthInProgress) {
return;
}
this.ciphers = ciphers; this.ciphers = ciphers;
this.currentCipherIndex = 0; this.currentCipherIndex = 0;
this.showInlineMenuAccountCreation = showInlineMenuAccountCreation; this.showInlineMenuAccountCreation = showInlineMenuAccountCreation;
if (this.inlineMenuListContainer) { this.resetInlineMenuContainer();
this.inlineMenuListContainer.innerHTML = "";
this.inlineMenuListContainer.classList.remove(
"inline-menu-list-container--with-new-item-button",
);
}
if (!ciphers?.length) { if (!ciphers?.length) {
this.buildNoResultsInlineMenuList(); this.buildNoResultsInlineMenuList();
@ -191,6 +192,18 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
this.newItemButtonElement.addEventListener(EVENTS.KEYUP, this.handleNewItemButtonKeyUpEvent); 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. * 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. * 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( this.ciphersList.addEventListener(
EVENTS.SCROLL, EVENTS.SCROLL,
this.useEventHandlersMemo( this.useEventHandlersMemo(
throttle(() => this.updatePasskeysHeadingsOnScroll(this.ciphersList.scrollTop), 50), throttle(this.handleThrottledOnScrollEvent, 50),
UPDATE_PASSKEYS_HEADINGS_ON_SCROLL, UPDATE_PASSKEYS_HEADINGS_ON_SCROLL,
), ),
options, options,
@ -342,7 +355,10 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* Handles updating the list of ciphers when the * Handles updating the list of ciphers when the
* user scrolls to the bottom of the list. * user scrolls to the bottom of the list.
*/ */
private updateCiphersListOnScroll = () => { private updateCiphersListOnScroll = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (this.cipherListScrollIsDebounced) { if (this.cipherListScrollIsDebounced) {
return; 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. * 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) => { private handleFillCipherClickEvent = (cipher: InlineMenuCipherData) => {
const usePasskey = !!cipher.login?.passkey; const usePasskey = !!cipher.login?.passkey;
return this.useEventHandlersMemo( return this.useEventHandlersMemo(
() => () => this.triggerFillCipherClickEvent(cipher, usePasskey),
this.postMessageToParent({
command: "fillAutofillInlineMenuCipher",
inlineMenuCipherId: cipher.id,
usePasskey,
}),
`${cipher.id}-fill-cipher-button-click-handler-${usePasskey ? "passkey" : ""}`, `${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 * Handles the keyup event for the fill cipher button. Facilitates
* selecting the next/previous cipher item on ArrowDown/ArrowUp. Also * selecting the next/previous cipher item on ArrowDown/ArrowUp. Also
@ -889,6 +930,26 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
return cipherDetailsElement; 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. * Gets the subtitle text for a given cipher.
* *

View File

@ -15,6 +15,8 @@ body {
width: 100%; width: 100%;
padding: 0; padding: 0;
margin: 0; margin: 0;
font-family: $font-family-sans-serif;
font-weight: 400;
@include themify($themes) { @include themify($themes) {
color: themed("textColor"); color: themed("textColor");
@ -23,8 +25,6 @@ body {
} }
.inline-menu-list-message { .inline-menu-list-message {
font-family: $font-family-sans-serif;
font-weight: 400;
font-size: 1.4rem; font-size: 1.4rem;
line-height: 1.5; line-height: 1.5;
width: 100%; 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;
}
}
}
}

View File

@ -3017,9 +3017,11 @@ export default class AutofillService implements AutofillServiceInterface {
const tabs = await BrowserApi.tabsQuery({}); const tabs = await BrowserApi.tabsQuery({});
for (let index = 0; index < tabs.length; index++) { for (let index = 0; index < tabs.length; index++) {
const tab = tabs[index]; const tab = tabs[index];
if (tab.url?.startsWith("http")) { if (tab?.id && tab.url?.startsWith("http")) {
const frames = await BrowserApi.getAllFrameDetails(tab.id); 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));
}
} }
} }
} }

View File

@ -10,6 +10,7 @@ $border-color: #ced4dc;
$border-color-dark: #ddd; $border-color-dark: #ddd;
$border-radius: 3px; $border-radius: 3px;
$focus-outline-color: #1252a3; $focus-outline-color: #1252a3;
$muted-blue: #5a6d91;
$brand-primary: #175ddc; $brand-primary: #175ddc;
@ -45,6 +46,7 @@ $themes: (
focusOutlineColor: $focus-outline-color, focusOutlineColor: $focus-outline-color,
successColor: $success-color-light, successColor: $success-color-light,
errorColor: $error-color-light, errorColor: $error-color-light,
passkeysAuthenticating: $muted-blue,
), ),
dark: ( dark: (
textColor: #ffffff, textColor: #ffffff,
@ -60,6 +62,7 @@ $themes: (
focusOutlineColor: lighten($focus-outline-color, 25%), focusOutlineColor: lighten($focus-outline-color, 25%),
successColor: $success-color-dark, successColor: $success-color-dark,
errorColor: $error-color-dark, errorColor: $error-color-dark,
passkeysAuthenticating: #bac0ce,
), ),
nord: ( nord: (
textColor: $nord5, textColor: $nord5,
@ -74,6 +77,7 @@ $themes: (
borderColor: $nord0, borderColor: $nord0,
focusOutlineColor: lighten($focus-outline-color, 25%), focusOutlineColor: lighten($focus-outline-color, 25%),
successColor: $success-color-dark, successColor: $success-color-dark,
passkeysAuthenticating: $nord4,
), ),
solarizedDark: ( solarizedDark: (
textColor: $solarizedDarkBase2, textColor: $solarizedDarkBase2,
@ -89,6 +93,7 @@ $themes: (
borderColor: $solarizedDarkBase2, borderColor: $solarizedDarkBase2,
focusOutlineColor: lighten($focus-outline-color, 15%), focusOutlineColor: lighten($focus-outline-color, 15%),
successColor: $success-color-dark, successColor: $success-color-dark,
passkeysAuthenticating: $solarizedDarkBase2,
), ),
); );

View File

@ -426,3 +426,50 @@ export function getSubmitButtonKeywordsSet(element: HTMLElement): Set<string> {
return keywordsSet; 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;
}

View File

@ -27,3 +27,6 @@ export const passkeyIcon =
export const circleCheckIcon = export const circleCheckIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g clip-path="url(#a)"><path fill="#017E45" d="M8 15.5a8.383 8.383 0 0 1-4.445-1.264A7.627 7.627 0 0 1 .61 10.87a7.063 7.063 0 0 1-.455-4.333 7.368 7.368 0 0 1 2.19-3.84A8.181 8.181 0 0 1 6.438.644a8.498 8.498 0 0 1 4.623.427 7.912 7.912 0 0 1 3.59 2.762A7.171 7.171 0 0 1 16 8c-.002 1.988-.846 3.895-2.345 5.3-1.5 1.406-3.534 2.198-5.655 2.2ZM8 1.437a7.337 7.337 0 0 0-3.889 1.106 6.672 6.672 0 0 0-2.578 2.945 6.182 6.182 0 0 0-.399 3.792 6.448 6.448 0 0 0 1.916 3.36 7.156 7.156 0 0 0 3.584 1.796 7.434 7.434 0 0 0 4.044-.374 6.924 6.924 0 0 0 3.142-2.417A6.275 6.275 0 0 0 15 8c-.002-1.74-.74-3.407-2.053-4.638C11.635 2.131 9.856 1.44 8 1.437Zm-1.351 9.905a.361.361 0 0 1-.245-.094l-2.257-2.07a.326.326 0 0 1-.103-.232c0-.043.009-.085.027-.125a.334.334 0 0 1 .076-.107.366.366 0 0 1 .246-.097c.093 0 .182.033.249.093l1.843 1.687a.166.166 0 0 0 .126.044.17.17 0 0 0 .066-.018.157.157 0 0 0 .052-.041l4.623-5.636a.34.34 0 0 1 .102-.088.375.375 0 0 1 .27-.038.34.34 0 0 1 .216.156.311.311 0 0 1-.033.37L6.93 11.21a.344.344 0 0 1-.112.09.376.376 0 0 1-.141.039l-.03.003h.001Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 .5h16v15H0z"/></clipPath></defs></svg>'; '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><g clip-path="url(#a)"><path fill="#017E45" d="M8 15.5a8.383 8.383 0 0 1-4.445-1.264A7.627 7.627 0 0 1 .61 10.87a7.063 7.063 0 0 1-.455-4.333 7.368 7.368 0 0 1 2.19-3.84A8.181 8.181 0 0 1 6.438.644a8.498 8.498 0 0 1 4.623.427 7.912 7.912 0 0 1 3.59 2.762A7.171 7.171 0 0 1 16 8c-.002 1.988-.846 3.895-2.345 5.3-1.5 1.406-3.534 2.198-5.655 2.2ZM8 1.437a7.337 7.337 0 0 0-3.889 1.106 6.672 6.672 0 0 0-2.578 2.945 6.182 6.182 0 0 0-.399 3.792 6.448 6.448 0 0 0 1.916 3.36 7.156 7.156 0 0 0 3.584 1.796 7.434 7.434 0 0 0 4.044-.374 6.924 6.924 0 0 0 3.142-2.417A6.275 6.275 0 0 0 15 8c-.002-1.74-.74-3.407-2.053-4.638C11.635 2.131 9.856 1.44 8 1.437Zm-1.351 9.905a.361.361 0 0 1-.245-.094l-2.257-2.07a.326.326 0 0 1-.103-.232c0-.043.009-.085.027-.125a.334.334 0 0 1 .076-.107.366.366 0 0 1 .246-.097c.093 0 .182.033.249.093l1.843 1.687a.166.166 0 0 0 .126.044.17.17 0 0 0 .066-.018.157.157 0 0 0 .052-.041l4.623-5.636a.34.34 0 0 1 .102-.088.375.375 0 0 1 .27-.038.34.34 0 0 1 .216.156.311.311 0 0 1-.033.37L6.93 11.21a.344.344 0 0 1-.112.09.376.376 0 0 1-.141.039l-.03.003h.001Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 .5h16v15H0z"/></clipPath></defs></svg>';
export const spinnerIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><g clip-path="url(#a)"><path fill="#5A6D91" d="M4.869 15.015a.588.588 0 1 0 0-1.177.588.588 0 0 0 0 1.177ZM8.252 16a.588.588 0 1 0 0-1.176.588.588 0 0 0 0 1.176Zm3.683-.911a.589.589 0 1 0 0-1.177.589.589 0 0 0 0 1.177ZM2.43 12.882a.693.693 0 1 0 0-1.387.693.693 0 0 0 0 1.387ZM1.318 9.738a.82.82 0 1 0 0-1.64.82.82 0 0 0 0 1.64Zm.69-3.578a.968.968 0 1 0 0-1.937.968.968 0 0 0 0 1.937ZM4.81 3.337a1.175 1.175 0 1 0 0-2.35 1.175 1.175 0 0 0 0 2.35Zm4.597-.676a1.33 1.33 0 1 0 0-2.661 1.33 1.33 0 0 0 0 2.66Zm4.543 2.954a1.553 1.553 0 1 0 0-3.105 1.553 1.553 0 0 0 0 3.105Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>';

View File

@ -3,7 +3,9 @@ import { Component, importProvidersFrom } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular"; 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { import {
AvatarModule, AvatarModule,
BadgeModule, BadgeModule,
@ -318,6 +320,30 @@ export default {
}); });
}, },
}, },
{
provide: PolicyService,
useFactory: () => {
return {
policyAppliesToActiveUser$: () => {
return {
pipe: () => ({
subscribe: () => ({}),
}),
};
},
};
},
},
{
provide: SendService,
useFactory: () => {
return {
sends$: () => {
return { pipe: () => ({}) };
},
};
},
},
], ],
}), }),
applicationConfig({ applicationConfig({

View File

@ -1,9 +1,41 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component } from "@angular/core"; import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { RouterModule } from "@angular/router"; 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"; 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({ @Component({
selector: "popup-tab-navigation", selector: "popup-tab-navigation",
templateUrl: "popup-tab-navigation.component.html", templateUrl: "popup-tab-navigation.component.html",
@ -14,30 +46,23 @@ import { LinkModule } from "@bitwarden/components";
}, },
}) })
export class PopupTabNavigationComponent { export class PopupTabNavigationComponent {
navButtons = [ navButtons = allNavButtons;
{ constructor(
label: "Vault", private policyService: PolicyService,
page: "/tabs/vault", private sendService: SendService,
iconKey: "lock", ) {
iconKeyActive: "lock-f", this.policyService
}, .policyAppliesToActiveUser$(PolicyType.DisableSend)
{ .pipe(
label: "Generator", filter((policyAppliesToActiveUser) => policyAppliesToActiveUser),
page: "/tabs/generator", switchMap(() => this.sendService.sends$),
iconKey: "generate", map((sends) => sends.length > 1),
iconKeyActive: "generate-f", takeUntilDestroyed(),
}, )
{ .subscribe((hasSends) => {
label: "Send", this.navButtons = hasSends
page: "/tabs/send", ? allNavButtons
iconKey: "send", : allNavButtons.filter((b) => b.page !== "/tabs/send");
iconKeyActive: "send-f", });
}, }
{
label: "Settings",
page: "/tabs/settings",
iconKey: "cog",
iconKeyActive: "cog-f",
},
];
} }

View File

@ -9,6 +9,8 @@
> >
</tools-send-form> </tools-send-form>
<send-file-popout-dialog-container [config]="config"></send-file-popout-dialog-container>
<popup-footer slot="footer"> <popup-footer slot="footer">
<button bitButton type="submit" form="sendForm" buttonType="primary" #submitBtn> <button bitButton type="submit" form="sendForm" buttonType="primary" #submitBtn>
{{ "save" | i18n }} {{ "save" | i18n }}

View File

@ -29,6 +29,7 @@ import { SendFormModule } from "../../../../../../../libs/tools/send/send-ui/src
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component"; import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.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. * Helper class to parse query parameters for the AddEdit route.
@ -70,6 +71,7 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
PopupPageComponent, PopupPageComponent,
PopupHeaderComponent, PopupHeaderComponent,
PopupFooterComponent, PopupFooterComponent,
SendFilePopoutDialogContainerComponent,
SendFormModule, SendFormModule,
AsyncActionsModule, AsyncActionsModule,
], ],

View File

@ -0,0 +1,31 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { 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";
@Component({
selector: "send-file-popout-dialog-container",
templateUrl: "./send-file-popout-dialog-container.component.html",
standalone: true,
imports: [JslibModule, CommonModule],
})
export class SendFilePopoutDialogContainerComponent implements OnInit {
@Input() config: SendFormConfig;
constructor(
private dialogService: DialogService,
private filePopoutUtilsService: FilePopoutUtilsService,
) {}
ngOnInit() {
if (this.config.mode === "add" && this.filePopoutUtilsService.showFilePopoutMessage(window)) {
this.dialogService.open(SendFilePopoutDialogComponent);
}
}
}

View File

@ -0,0 +1,20 @@
<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>

View File

@ -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();
}
}

View File

@ -1,12 +1,20 @@
<popup-page> <popup-page>
<popup-header slot="header" [pageTitle]="'send' | i18n"> <popup-header slot="header" [pageTitle]="'send' | i18n">
<ng-container slot="end"> <ng-container slot="end">
<tools-new-send-dropdown></tools-new-send-dropdown> <tools-new-send-dropdown *ngIf="!sendsDisabled"></tools-new-send-dropdown>
<app-pop-out></app-pop-out> <app-pop-out></app-pop-out>
<app-current-account></app-current-account> <app-current-account></app-current-account>
</ng-container> </ng-container>
</popup-header> </popup-header>
<div slot="above-scroll-area" class="tw-p-4">
<bit-callout *ngIf="sendsDisabled" [title]="'sendDisabled' | i18n">
{{ "sendDisabledWarning" | i18n }}
</bit-callout>
<ng-container *ngIf="!sendsDisabled">
<tools-send-search></tools-send-search>
<app-send-list-filters></app-send-list-filters>
</ng-container>
</div>
<div <div
*ngIf="listState === sendState.Empty" *ngIf="listState === sendState.Empty"
@ -15,7 +23,7 @@
<bit-no-items [icon]="noItemIcon" class="tw-text-main"> <bit-no-items [icon]="noItemIcon" class="tw-text-main">
<ng-container slot="title">{{ "sendsNoItemsTitle" | i18n }}</ng-container> <ng-container slot="title">{{ "sendsNoItemsTitle" | i18n }}</ng-container>
<ng-container slot="description">{{ "sendsNoItemsMessage" | i18n }}</ng-container> <ng-container slot="description">{{ "sendsNoItemsMessage" | i18n }}</ng-container>
<tools-new-send-dropdown slot="button"></tools-new-send-dropdown> <tools-new-send-dropdown *ngIf="!sendsDisabled" slot="button"></tools-new-send-dropdown>
</bit-no-items> </bit-no-items>
</div> </div>
@ -31,9 +39,4 @@
</div> </div>
<app-send-list-items-container [headerText]="title | i18n" [sends]="sends$ | async" /> <app-send-list-items-container [headerText]="title | i18n" [sends]="sends$ | async" />
</ng-container> </ng-container>
<div slot="above-scroll-area" class="tw-p-4" *ngIf="listState !== sendState.Empty">
<tools-send-search></tools-send-search>
<app-send-list-filters></app-send-list-filters>
</div>
</popup-page> </popup-page>

View File

@ -7,6 +7,7 @@ import { of, BehaviorSubject } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SearchService } from "@bitwarden/common/abstractions/search.service"; 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
@ -46,6 +47,7 @@ describe("SendV2Component", () => {
let sendListFiltersServiceFilters$: BehaviorSubject<{ sendType: SendType | null }>; let sendListFiltersServiceFilters$: BehaviorSubject<{ sendType: SendType | null }>;
let sendItemsServiceEmptyList$: BehaviorSubject<boolean>; let sendItemsServiceEmptyList$: BehaviorSubject<boolean>;
let sendItemsServiceNoFilteredResults$: BehaviorSubject<boolean>; let sendItemsServiceNoFilteredResults$: BehaviorSubject<boolean>;
let policyService: MockProxy<PolicyService>;
beforeEach(async () => { beforeEach(async () => {
sendListFiltersServiceFilters$ = new BehaviorSubject({ sendType: null }); sendListFiltersServiceFilters$ = new BehaviorSubject({ sendType: null });
@ -60,6 +62,9 @@ describe("SendV2Component", () => {
latestSearchText$: of(""), latestSearchText$: of(""),
}); });
policyService = mock<PolicyService>();
policyService.policyAppliesToActiveUser$.mockReturnValue(of(true)); // Return `true` by default
sendListFiltersService = new SendListFiltersService(mock(), new FormBuilder()); sendListFiltersService = new SendListFiltersService(mock(), new FormBuilder());
sendListFiltersService.filters$ = sendListFiltersServiceFilters$; sendListFiltersService.filters$ = sendListFiltersServiceFilters$;
@ -104,6 +109,7 @@ describe("SendV2Component", () => {
{ provide: I18nService, useValue: { t: (key: string) => key } }, { provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: SendListFiltersService, useValue: sendListFiltersService }, { provide: SendListFiltersService, useValue: sendListFiltersService },
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() }, { provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
{ provide: PolicyService, useValue: policyService },
], ],
}).compileComponents(); }).compileComponents();

View File

@ -5,8 +5,10 @@ import { RouterLink } from "@angular/router";
import { combineLatest } from "rxjs"; import { combineLatest } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; 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 { 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 { import {
NoSendsIcon, NoSendsIcon,
NewSendDropdownComponent, NewSendDropdownComponent,
@ -31,6 +33,7 @@ export enum SendState {
templateUrl: "send-v2.component.html", templateUrl: "send-v2.component.html",
standalone: true, standalone: true,
imports: [ imports: [
CalloutModule,
PopupPageComponent, PopupPageComponent,
PopupHeaderComponent, PopupHeaderComponent,
PopOutComponent, PopOutComponent,
@ -48,22 +51,20 @@ export enum SendState {
}) })
export class SendV2Component implements OnInit, OnDestroy { export class SendV2Component implements OnInit, OnDestroy {
sendType = SendType; sendType = SendType;
sendState = SendState; sendState = SendState;
protected listState: SendState | null = null; protected listState: SendState | null = null;
protected sends$ = this.sendItemsService.filteredAndSortedSends$; protected sends$ = this.sendItemsService.filteredAndSortedSends$;
protected title: string = "allSends"; protected title: string = "allSends";
protected noItemIcon = NoSendsIcon; protected noItemIcon = NoSendsIcon;
protected noResultsIcon = Icons.NoResults; protected noResultsIcon = Icons.NoResults;
protected sendsDisabled = false;
constructor( constructor(
protected sendItemsService: SendItemsService, protected sendItemsService: SendItemsService,
protected sendListFiltersService: SendListFiltersService, protected sendListFiltersService: SendListFiltersService,
private policyService: PolicyService,
) { ) {
combineLatest([ combineLatest([
this.sendItemsService.emptyList$, this.sendItemsService.emptyList$,
@ -90,6 +91,13 @@ export class SendV2Component implements OnInit, OnDestroy {
this.listState = null; this.listState = null;
}); });
this.policyService
.policyAppliesToActiveUser$(PolicyType.DisableSend)
.pipe(takeUntilDestroyed())
.subscribe((sendsDisabled) => {
this.sendsDisabled = sendsDisabled;
});
} }
ngOnInit(): void {} ngOnInit(): void {}

View File

@ -38,7 +38,7 @@ export class MoreFromBitwardenPageV2Component {
private organizationService: OrganizationService, private organizationService: OrganizationService,
) { ) {
this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$; this.canAccessPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$;
this.familySponsorshipAvailable$ = this.organizationService.canManageSponsorships$; this.familySponsorshipAvailable$ = this.organizationService.familySponsorshipAvailable$;
} }
async openFreeBitwardenFamiliesPage() { async openFreeBitwardenFamiliesPage() {

View File

@ -14,6 +14,7 @@ import { EventType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.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 { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.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 { PremiumUpgradePromptService } from "../../../../../../../../libs/common/src/vault/abstractions/premium-upgrade-prompt.service";
import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view"; import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view";
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component"; 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 { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.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({ @Component({
selector: "app-view-v2", selector: "app-view-v2",
templateUrl: "view-v2.component.html", templateUrl: "view-v2.component.html",
standalone: true, standalone: true,
providers: [
{ provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService },
],
imports: [ imports: [
CommonModule, CommonModule,
SearchModule, SearchModule,
@ -58,6 +58,10 @@ import { VaultPopupAutofillService } from "../../../services/vault-popup-autofil
AsyncActionsModule, AsyncActionsModule,
PopOutComponent, PopOutComponent,
], ],
providers: [
{ provide: ViewPasswordHistoryService, useClass: BrowserViewPasswordHistoryService },
{ provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService },
],
}) })
export class ViewV2Component { export class ViewV2Component {
headerText: string; headerText: string;

View File

@ -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<Router>;
beforeEach(async () => {
router = mock<Router>();
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" },
});
});
});
});

View File

@ -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 } });
}
}

View File

@ -0,0 +1,40 @@
<bit-dialog dialogSize="small" background="alt">
<span bitDialogTitle>
{{ "passwordHistory" | i18n }}
</span>
<ng-container bitDialogContent>
<div *ngIf="history && history.length">
<bit-item *ngFor="let h of history">
<div class="tw-pl-3 tw-py-2">
<bit-color-password
class="tw-text-base"
[password]="h.password"
[showCount]="false"
></bit-color-password>
<div class="tw-text-sm tw-text-muted">{{ h.lastUsedDate | date: "medium" }}</div>
</div>
<ng-container slot="end">
<bit-item-action>
<button
type="button"
bitIconButton="bwi-clone"
aria-label="Copy"
appStopClick
(click)="copy(h.password)"
>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-item-action>
</ng-container>
</bit-item>
</div>
<div class="no-items" *ngIf="!history || !history.length">
<p>{{ "noPasswordsInList" | i18n }}</p>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton (click)="close()" buttonType="primary" type="button">
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@ -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<PasswordHistoryComponent>,
) {
/**
* 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<ViewPasswordHistoryDialogParams>,
) {
return dialogService.open(PasswordHistoryComponent, config);
}

View File

@ -8,6 +8,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.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 { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.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 { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component";
import { SharedModule } from "../../shared/shared.module"; import { SharedModule } from "../../shared/shared.module";
import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service"; import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service";
import { WebViewPasswordHistoryService } from "../services/web-view-password-history.service";
export interface ViewCipherDialogParams { export interface ViewCipherDialogParams {
cipher: CipherView; cipher: CipherView;
@ -57,6 +59,7 @@ export interface ViewCipherDialogCloseResult {
standalone: true, standalone: true,
imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule], imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule],
providers: [ providers: [
{ provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService },
{ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService }, { provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService },
], ],
}) })

View File

@ -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 },
});
});
});
});

View File

@ -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 } });
}
}

View File

@ -72,7 +72,7 @@ export class CollectionsComponent implements OnInit {
if (this.organization.canEditAllCiphers) { if (this.organization.canEditAllCiphers) {
return !!(c as any).checked; return !!(c as any).checked;
} else { } else {
return !!(c as any).checked && c.readOnly == null; return !!(c as any).checked && !c.readOnly;
} }
}) })
.map((c) => c.id); .map((c) => c.id);

View File

@ -117,6 +117,10 @@ export abstract class OrganizationService {
* Emits true if the user can create or manage a Free Bitwarden Families sponsorship. * Emits true if the user can create or manage a Free Bitwarden Families sponsorship.
*/ */
canManageSponsorships$: Observable<boolean>; canManageSponsorships$: Observable<boolean>;
/**
* Emits true if any of the user's organizations have a Free Bitwarden Families sponsorship available.
*/
familySponsorshipAvailable$: Observable<boolean>;
hasOrganizations: () => Promise<boolean>; hasOrganizations: () => Promise<boolean>;
get$: (id: string) => Observable<Organization | undefined>; get$: (id: string) => Observable<Organization | undefined>;
get: (id: string) => Promise<Organization>; get: (id: string) => Promise<Organization>;

View File

@ -88,6 +88,10 @@ export class OrganizationService implements InternalOrganizationServiceAbstracti
mapToBooleanHasAnyOrganizations(), mapToBooleanHasAnyOrganizations(),
); );
familySponsorshipAvailable$ = this.organizations$.pipe(
map((orgs) => orgs.some((o) => o.familySponsorshipAvailable)),
);
async hasOrganizations(): Promise<boolean> { async hasOrganizations(): Promise<boolean> {
return await firstValueFrom(this.organizations$.pipe(mapToBooleanHasAnyOrganizations())); return await firstValueFrom(this.organizations$.pipe(mapToBooleanHasAnyOrganizations()));
} }

View File

@ -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<void>;
}

View File

@ -15,12 +15,19 @@
<bit-label *ngIf="!hasPassword">{{ "password" | i18n }}</bit-label> <bit-label *ngIf="!hasPassword">{{ "password" | i18n }}</bit-label>
<bit-label *ngIf="hasPassword">{{ "newPassword" | i18n }}</bit-label> <bit-label *ngIf="hasPassword">{{ "newPassword" | i18n }}</bit-label>
<input bitInput type="password" formControlName="password" /> <input bitInput type="password" formControlName="password" />
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button> <button
data-testid="toggle-visibility-for-password"
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
></button>
<button <button
type="button" type="button"
bitIconButton="bwi-generate" bitIconButton="bwi-generate"
bitSuffix bitSuffix
[appA11yTitle]="'generatePassword' | i18n" [appA11yTitle]="'generatePassword' | i18n"
data-testid="generate-password"
></button> ></button>
<bit-hint>{{ "sendPasswordDescV2" | i18n }}</bit-hint> <bit-hint>{{ "sendPasswordDescV2" | i18n }}</bit-hint>
</bit-form-field> </bit-form-field>

View File

@ -97,6 +97,7 @@ export class SendOptionsComponent implements OnInit {
}); });
}); });
} }
ngOnInit() { ngOnInit() {
if (this.sendFormContainer.originalSendView) { if (this.sendFormContainer.originalSendView) {
this.sendOptionsForm.patchValue({ this.sendOptionsForm.patchValue({
@ -107,5 +108,8 @@ export class SendOptionsComponent implements OnInit {
notes: this.sendFormContainer.originalSendView.notes, notes: this.sendFormContainer.originalSendView.notes,
}); });
} }
if (!this.config.areSendsAllowed) {
this.sendOptionsForm.disable();
}
} }
} }

View File

@ -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();
}
}

View File

@ -23,7 +23,7 @@
<bit-form-field *ngIf="sendLink"> <bit-form-field *ngIf="sendLink">
<bit-label>{{ "sendLink" | i18n }}</bit-label> <bit-label>{{ "sendLink" | i18n }}</bit-label>
<input bitInput type="text" [value]="sendLink" readonly /> <input data-testid="send-link" bitInput type="text" [value]="sendLink" readonly />
<button <button
type="button" type="button"
bitSuffix bitSuffix

View File

@ -1,12 +1,14 @@
import { CommonModule, DatePipe } from "@angular/common"; import { CommonModule, DatePipe } from "@angular/common";
import { Component, OnInit } from "@angular/core"; import { Component, OnInit, Input } from "@angular/core";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { import {
SectionComponent, SectionComponent,
SectionHeaderComponent, SectionHeaderComponent,
@ -18,13 +20,29 @@ import {
SelectModule, SelectModule,
} from "@bitwarden/components"; } from "@bitwarden/components";
import { SendFormConfig } from "../../abstractions/send-form-config.service";
import { SendFormContainer } from "../../send-form-container"; import { SendFormContainer } from "../../send-form-container";
import { SendOptionsComponent } from "../options/send-options.component"; import { SendOptionsComponent } from "../options/send-options.component";
import { BaseSendDetailsComponent } from "./base-send-details.component";
import { SendFileDetailsComponent } from "./send-file-details.component"; import { SendFileDetailsComponent } from "./send-file-details.component";
import { SendTextDetailsComponent } from "./send-text-details.component"; import { SendTextDetailsComponent } from "./send-text-details.component";
// 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({ @Component({
selector: "tools-send-details", selector: "tools-send-details",
templateUrl: "./send-details.component.html", templateUrl: "./send-details.component.html",
@ -46,10 +64,20 @@ import { SendTextDetailsComponent } from "./send-text-details.component";
SelectModule, SelectModule,
], ],
}) })
export class SendDetailsComponent extends BaseSendDetailsComponent implements OnInit { export class SendDetailsComponent implements OnInit {
@Input() config: SendFormConfig;
@Input() originalSendView?: SendView;
FileSendType = SendType.File; FileSendType = SendType.File;
TextSendType = SendType.Text; TextSendType = SendType.Text;
sendLink: string | null = null; sendLink: string | null = null;
customDeletionDateOption: DatePresetSelectOption | null = null;
datePresetOptions: DatePresetSelectOption[] = [];
sendDetailsForm = this.formBuilder.group({
name: new FormControl("", Validators.required),
selectedDeletionDatePreset: new FormControl(DatePreset.SevenDays || "", Validators.required),
});
constructor( constructor(
protected sendFormContainer: SendFormContainer, protected sendFormContainer: SendFormContainer,
@ -58,18 +86,69 @@ export class SendDetailsComponent extends BaseSendDetailsComponent implements On
protected datePipe: DatePipe, protected datePipe: DatePipe,
protected environmentService: EnvironmentService, protected environmentService: EnvironmentService,
) { ) {
super(sendFormContainer, formBuilder, i18nService, 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);
});
});
async getSendLink() {} this.sendFormContainer.registerChildForm("sendDetailsForm", this.sendDetailsForm);
}
async ngOnInit() { async ngOnInit() {
await super.ngOnInit(); this.setupDeletionDatePresets();
if (!this.originalSendView) {
return; 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, "short"),
value: this.originalSendView.deletionDate.toString(),
};
this.datePresetOptions.unshift(this.customDeletionDateOption);
}
const env = await firstValueFrom(this.environmentService.environment$);
this.sendLink =
env.getSendUrl() + this.originalSendView.accessId + "/" + this.originalSendView.urlB64Key;
} }
const env = await firstValueFrom(this.environmentService.environment$);
this.sendLink = if (!this.config.areSendsAllowed) {
env.getSendUrl() + this.originalSendView.accessId + "/" + this.originalSendView.urlB64Key; this.sendDetailsForm.disable();
}
}
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();
} }
} }

View File

@ -1,8 +1,8 @@
<bit-section [formGroup]="sendFileDetailsForm"> <bit-section [formGroup]="sendFileDetailsForm">
<div *ngIf="config.mode === 'edit'"> <div *ngIf="config.mode === 'edit'">
<div bitTypography="body2" class="tw-text-muted">{{ "file" | i18n }}</div> <div bitTypography="body2" class="tw-text-muted">{{ "file" | i18n }}</div>
<div>{{ originalSendView.file.fileName }}</div> <div data-testid="file-name">{{ originalSendView.file.fileName }}</div>
<div class="tw-text-muted">{{ originalSendView.file.sizeName }}</div> <div data-testid="file-size" class="tw-text-muted">{{ originalSendView.file.sizeName }}</div>
</div> </div>
<bit-form-field *ngIf="config.mode !== 'edit'"> <bit-form-field *ngIf="config.mode !== 'edit'">
<bit-label for="file">{{ "fileToShare" | i18n }}</bit-label> <bit-label for="file">{{ "fileToShare" | i18n }}</bit-label>

View File

@ -73,5 +73,9 @@ export class SendFileDetailsComponent implements OnInit {
file: this.originalSendView.file, file: this.originalSendView.file,
}); });
} }
if (!this.config.areSendsAllowed) {
this.sendFileDetailsForm.disable();
}
} }
} }

View File

@ -57,5 +57,9 @@ export class SendTextDetailsComponent implements OnInit {
hidden: this.originalSendView.text?.hidden || false, hidden: this.originalSendView.text?.hidden || false,
}); });
} }
if (!this.config.areSendsAllowed) {
this.sendTextDetailsForm.disable();
}
} }
} }

View File

@ -27,10 +27,8 @@
</p> </p>
<a <a
*ngIf="cipher.hasPasswordHistory && isLogin" *ngIf="cipher.hasPasswordHistory && isLogin"
bitLink class="tw-font-bold tw-no-underline tw-cursor-pointer"
class="tw-font-bold tw-no-underline" (click)="viewPasswordHistory()"
routerLink="/cipher-password-history"
[queryParams]="{ cipherId: cipher.id }"
bitTypography="body2" bitTypography="body2"
> >
{{ "passwordHistory" | i18n }} {{ "passwordHistory" | i18n }}

View File

@ -3,6 +3,8 @@ import { Component, Input } from "@angular/core";
import { RouterModule } from "@angular/router"; import { RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CipherId } from "@bitwarden/common/types/guid";
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { import {
@ -31,7 +33,16 @@ import {
export class ItemHistoryV2Component { export class ItemHistoryV2Component {
@Input() cipher: CipherView; @Input() cipher: CipherView;
constructor(private viewPasswordHistoryService: ViewPasswordHistoryService) {}
get isLogin() { get isLogin() {
return this.cipher.type === CipherType.Login; return this.cipher.type === CipherType.Login;
} }
/**
* View the password history for the cipher.
*/
async viewPasswordHistory() {
await this.viewPasswordHistoryService.viewPasswordHistory(this.cipher?.id as CipherId);
}
} }

8
package-lock.json generated
View File

@ -95,7 +95,7 @@
"@storybook/manager-api": "8.2.9", "@storybook/manager-api": "8.2.9",
"@storybook/theming": "8.2.9", "@storybook/theming": "8.2.9",
"@types/argon2-browser": "1.18.4", "@types/argon2-browser": "1.18.4",
"@types/chrome": "0.0.270", "@types/chrome": "0.0.272",
"@types/firefox-webext-browser": "111.0.5", "@types/firefox-webext-browser": "111.0.5",
"@types/inquirer": "8.2.10", "@types/inquirer": "8.2.10",
"@types/jest": "29.5.12", "@types/jest": "29.5.12",
@ -8901,9 +8901,9 @@
} }
}, },
"node_modules/@types/chrome": { "node_modules/@types/chrome": {
"version": "0.0.270", "version": "0.0.272",
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.270.tgz", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.272.tgz",
"integrity": "sha512-ADvkowV7YnJfycZZxL2brluZ6STGW+9oKG37B422UePf2PCXuFA/XdERI0T18wtuWPx0tmFeZqq6MOXVk1IC+Q==", "integrity": "sha512-9cxDmmgyhXV8gsZvlRjqaDizNjIjbV0spsR0fIEaQUoHtbl9D8VkTOLyONgiBKK+guR38x5eMO3E3avUYOXwcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -57,7 +57,7 @@
"@storybook/manager-api": "8.2.9", "@storybook/manager-api": "8.2.9",
"@storybook/theming": "8.2.9", "@storybook/theming": "8.2.9",
"@types/argon2-browser": "1.18.4", "@types/argon2-browser": "1.18.4",
"@types/chrome": "0.0.270", "@types/chrome": "0.0.272",
"@types/firefox-webext-browser": "111.0.5", "@types/firefox-webext-browser": "111.0.5",
"@types/inquirer": "8.2.10", "@types/inquirer": "8.2.10",
"@types/jest": "29.5.12", "@types/jest": "29.5.12",