1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 15:03:26 +00:00

resolve merge conflicts

This commit is contained in:
William Martin
2024-07-25 13:54:06 -04:00
215 changed files with 4255 additions and 10070 deletions

1
.github/CODEOWNERS vendored
View File

@@ -6,6 +6,7 @@
## Secrets Manager team files ##
bitwarden_license/bit-web/src/app/secrets-manager @bitwarden/team-secrets-manager-dev
apps/web/src/app/secrets-manager/ @bitwarden/team-secrets-manager-dev
## Auth team files ##
apps/browser/src/auth @bitwarden/team-auth-dev

View File

@@ -13,6 +13,9 @@
"loginOrCreateNewAccount": {
"message": "Log in or create a new account to access your secure vault."
},
"inviteAccepted": {
"message": "Invitation accepted"
},
"createAccount": {
"message": "Create account"
},
@@ -68,6 +71,12 @@
"masterPassHint": {
"message": "Master password hint (optional)"
},
"joinOrganization": {
"message": "Join organization"
},
"finishJoiningThisOrganizationBySettingAMasterPassword": {
"message": "Finish joining this organization by setting a master password."
},
"tab": {
"message": "Tab"
},
@@ -3037,6 +3046,10 @@
"message": "Unlock your account to view matching logins",
"description": "Text to display in overlay when the account is locked."
},
"unlockYourAccountToViewAutofillSuggestions": {
"message": "Unlock your account to view autofill suggestions",
"description": "Text to display in overlay when the account is locked."
},
"unlockAccount": {
"message": "Unlock account",
"description": "Button text to display in overlay when the account is locked."
@@ -3061,6 +3074,22 @@
"message": "Add new vault item",
"description": "Screen reader text (aria-label) for new item button in overlay"
},
"newLogin": {
"message": "New login",
"description": "Button text to display within inline menu when there are no matching items on a login field"
},
"addNewLoginItem": {
"message": "Add new vault login item",
"description": "Screen reader text (aria-label) for new login button within inline menu"
},
"newCard": {
"message": "New card",
"description": "Button text to display within inline menu when there are no matching items on a credit card field"
},
"addNewCardItem": {
"message": "Add new vault card item",
"description": "Screen reader text (aria-label) for new card button within inline menu"
},
"bitwardenOverlayMenuAvailable": {
"message": "Bitwarden auto-fill menu available. Press the down arrow key to select.",
"description": "Screen reader text for announcing when the overlay opens on the page"
@@ -3734,6 +3763,10 @@
}
}
},
"cardNumberEndsWith": {
"message": "card number ends with",
"description": "Used within the inline menu to provide an aria description when users are attempting to fill a card cipher."
},
"loginCredentials": {
"message": "Login credentials"
},

View File

@@ -34,6 +34,7 @@ export type WebsiteIconData = {
export type FocusedFieldData = {
focusedFieldStyles: Partial<CSSStyleDeclaration>;
focusedFieldRects: Partial<DOMRect>;
filledByCipherType?: CipherType;
tabId?: number;
frameId?: number;
};
@@ -50,13 +51,26 @@ export type InlineMenuPosition = {
list?: InlineMenuElementPosition;
};
export type NewLoginCipherData = {
uri?: string;
hostname: string;
username: string;
password: string;
};
export type NewCardCipherData = {
cardholderName: string;
number: string;
expirationMonth: string;
expirationYear: string;
expirationDate?: string;
cvv: string;
};
export type OverlayAddNewItemMessage = {
login?: {
uri?: string;
hostname: string;
username: string;
password: string;
};
addNewCipherType?: CipherType;
login?: NewLoginCipherData;
card?: NewCardCipherData;
};
export type CloseInlineMenuMessage = {
@@ -91,6 +105,7 @@ export type OverlayPortMessage = {
command: string;
direction?: string;
inlineMenuCipherId?: string;
addNewCipherType?: CipherType;
};
export type InlineMenuCipherData = {
@@ -178,7 +193,7 @@ export type InlineMenuListPortMessageHandlers = {
autofillInlineMenuBlurred: () => void;
unlockVault: ({ port }: PortConnectionParam) => void;
fillAutofillInlineMenuCipher: ({ message, port }: PortOnMessageHandlerParams) => void;
addNewVaultItem: ({ port }: PortConnectionParam) => void;
addNewVaultItem: ({ message, port }: PortOnMessageHandlerParams) => void;
viewSelectedCipher: ({ message, port }: PortOnMessageHandlerParams) => void;
redirectAutofillInlineMenuFocusOut: ({ message, port }: PortOnMessageHandlerParams) => void;
updateAutofillInlineMenuListHeight: ({ message, port }: PortOnMessageHandlerParams) => void;
@@ -187,5 +202,5 @@ export type InlineMenuListPortMessageHandlers = {
export interface OverlayBackground {
init(): Promise<void>;
removePageDetails(tabId: number): void;
updateOverlayCiphers(): Promise<void>;
updateOverlayCiphers(updateAllCipherTypes?: boolean): Promise<void>;
}

View File

@@ -322,6 +322,7 @@ describe("OverlayBackground", () => {
it("removes the page details and port key for a specific tab from the pageDetailsForTab object", async () => {
await initOverlayElementPorts();
const tabId = 1;
portKeyForTabSpy[tabId] = "portKey";
sendMockExtensionMessage(
{ command: "collectPageDetailsResponse", details: createAutofillPageDetailsMock() },
mock<chrome.runtime.MessageSender>({ tab: createChromeTabMock({ id: tabId }), frameId: 1 }),
@@ -705,6 +706,13 @@ describe("OverlayBackground", () => {
type: CipherType.Card,
card: { subTitle: "subtitle-2" },
});
const cipher3 = mock<CipherView>({
id: "id-3",
localData: { lastUsedDate: 222 },
name: "name-3",
type: CipherType.Login,
login: { username: "username-3", uri: url },
});
beforeEach(() => {
activeAccountStatusMock$.next(AuthenticationStatus.Unlocked);
@@ -751,16 +759,53 @@ describe("OverlayBackground", () => {
);
});
it("queries all ciphers for the given url, sort them by last used, and format them for usage in the overlay", async () => {
it("queries all cipher types, sorts them by last used, and formats them for usage in the overlay", async () => {
getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab);
cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]);
cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1);
await overlayBackground.updateOverlayCiphers();
expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled();
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url, [CipherType.Card]);
expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled();
expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual(
new Map([
["inline-menu-cipher-0", cipher2],
["inline-menu-cipher-1", cipher1],
]),
);
});
it("queries only login ciphers when not updating all cipher types", async () => {
overlayBackground["cardAndIdentityCiphers"] = new Set([]);
getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab);
cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher3, cipher1]);
cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1);
await overlayBackground.updateOverlayCiphers(false);
expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled();
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url);
expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled();
expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual(
new Map([
["inline-menu-cipher-0", cipher1],
["inline-menu-cipher-1", cipher3],
]),
);
});
it("queries all cipher types when the card and identity ciphers set is not built when only updating login ciphers", async () => {
getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab);
cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]);
cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1);
await overlayBackground.updateOverlayCiphers(false);
expect(BrowserApi.getTabFromCurrentWindowId).toHaveBeenCalled();
expect(cipherService.getAllDecryptedForUrl).toHaveBeenCalledWith(url, [CipherType.Card]);
expect(cipherService.sortCiphersByLastUsedThenName).toHaveBeenCalled();
expect(overlayBackground["inlineMenuCiphers"]).toStrictEqual(
new Map([
["inline-menu-cipher-0", cipher2],
@@ -771,6 +816,7 @@ describe("OverlayBackground", () => {
it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateAutofillInlineMenuListCiphers` message to the tab indicating that the list of ciphers is populated", async () => {
overlayBackground["inlineMenuListPort"] = mock<chrome.runtime.Port>();
overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id });
cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]);
cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1);
getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab);
@@ -780,21 +826,6 @@ describe("OverlayBackground", () => {
expect(overlayBackground["inlineMenuListPort"].postMessage).toHaveBeenCalledWith({
command: "updateAutofillInlineMenuListCiphers",
ciphers: [
{
card: cipher2.card.subTitle,
favorite: cipher2.favorite,
icon: {
fallbackImage: "",
icon: "bwi-credit-card",
image: undefined,
imageEnabled: true,
},
id: "inline-menu-cipher-0",
login: null,
name: "name-2",
reprompt: cipher2.reprompt,
type: 3,
},
{
card: null,
favorite: cipher1.favorite,
@@ -810,7 +841,7 @@ describe("OverlayBackground", () => {
},
name: "name-1",
reprompt: cipher1.reprompt,
type: 1,
type: CipherType.Login,
},
],
});
@@ -884,6 +915,7 @@ describe("OverlayBackground", () => {
sendMockExtensionMessage(
{
command: "autofillOverlayAddNewVaultItem",
addNewCipherType: CipherType.Login,
login: {
uri: "https://tacos.com",
hostname: "",
@@ -899,6 +931,29 @@ describe("OverlayBackground", () => {
expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher");
expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled();
});
it("creates a new card cipher", async () => {
sendMockExtensionMessage(
{
command: "autofillOverlayAddNewVaultItem",
addNewCipherType: CipherType.Card,
card: {
cardholderName: "cardholderName",
number: "4242424242424242",
expirationMonth: "12",
expirationYear: "2025",
expirationDate: "12/25",
cvv: "123",
},
},
sender,
);
await flushPromises();
expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledWith("inlineAutofillMenuRefreshAddEditCipher");
expect(openAddEditVaultItemPopoutSpy).toHaveBeenCalled();
});
});
describe("checkIsInlineMenuCiphersPopulated message handler", () => {
@@ -929,8 +984,9 @@ describe("OverlayBackground", () => {
it("returns true if the overlay login ciphers are populated", async () => {
overlayBackground["inlineMenuCiphers"] = new Map([
["inline-menu-cipher-0", mock<CipherView>()],
["inline-menu-cipher-0", mock<CipherView>({ type: CipherType.Login })],
]);
await overlayBackground["getInlineMenuCipherData"]();
sendMockExtensionMessage(
{ command: "checkIsInlineMenuCiphersPopulated" },
@@ -2029,12 +2085,16 @@ describe("OverlayBackground", () => {
sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender);
await flushPromises();
sendPortMessage(listMessageConnectorSpy, { command: "addNewVaultItem", portKey });
sendPortMessage(listMessageConnectorSpy, {
command: "addNewVaultItem",
portKey,
addNewCipherType: CipherType.Login,
});
await flushPromises();
expect(tabsSendMessageSpy).toHaveBeenCalledWith(
sender.tab,
{ command: "addNewVaultItemFromOverlay" },
{ command: "addNewVaultItemFromOverlay", addNewCipherType: CipherType.Login },
{ frameId: sender.frameId },
);
});

View File

@@ -19,6 +19,7 @@ import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-stat
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
@@ -54,6 +55,8 @@ import {
CloseInlineMenuMessage,
InlineMenuPosition,
ToggleInlineMenuHiddenMessage,
NewLoginCipherData,
NewCardCipherData,
} from "./abstractions/overlay.background";
export class OverlayBackground implements OverlayBackgroundInterface {
@@ -69,6 +72,8 @@ export class OverlayBackground implements OverlayBackgroundInterface {
private inlineMenuCiphers: Map<string, CipherView> = new Map();
private inlineMenuPageTranslations: Record<string, string>;
private inlineMenuPosition: InlineMenuPosition = {};
private cardAndIdentityCiphers: Set<CipherView> | null = null;
private currentInlineMenuCiphersCount: number = 0;
private delayedCloseTimeout: number | NodeJS.Timeout;
private startInlineMenuFadeInSubject = new Subject<void>();
private cancelInlineMenuFadeInSubject = new Subject<boolean>();
@@ -132,7 +137,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
autofillInlineMenuBlurred: () => this.checkInlineMenuButtonFocused(),
unlockVault: ({ port }) => this.unlockVault(port),
fillAutofillInlineMenuCipher: ({ message, port }) => this.fillInlineMenuCipher(message, port),
addNewVaultItem: ({ port }) => this.getNewVaultItemDetails(port),
addNewVaultItem: ({ message, port }) => this.getNewVaultItemDetails(message, port),
viewSelectedCipher: ({ message, port }) => this.viewSelectedCipher(message, port),
redirectAutofillInlineMenuFocusOut: ({ message, port }) =>
this.redirectInlineMenuFocusOut(message, port),
@@ -220,7 +225,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
* Queries all ciphers for the given url, and sorts them by last used. Will not update the
* list of ciphers if the extension is not unlocked.
*/
async updateOverlayCiphers() {
async updateOverlayCiphers(updateAllCipherTypes = true) {
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
if (authStatus !== AuthenticationStatus.Unlocked) {
if (this.focusedFieldData) {
@@ -235,9 +240,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
}
this.inlineMenuCiphers = new Map();
const ciphersViews = (
await this.cipherService.getAllDecryptedForUrl(currentTab?.url || "")
).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b));
const ciphersViews = await this.getCipherViews(currentTab, updateAllCipherTypes);
for (let cipherIndex = 0; cipherIndex < ciphersViews.length; cipherIndex++) {
this.inlineMenuCiphers.set(`inline-menu-cipher-${cipherIndex}`, ciphersViews[cipherIndex]);
}
@@ -249,6 +252,51 @@ export class OverlayBackground implements OverlayBackgroundInterface {
});
}
/**
* Gets the decrypted ciphers within a user's vault based on the current tab's URL.
*
* @param currentTab - The current tab
* @param updateAllCipherTypes - Identifies credit card and identity cipher types should also be updated
*/
private async getCipherViews(
currentTab: chrome.tabs.Tab,
updateAllCipherTypes: boolean,
): Promise<CipherView[]> {
if (updateAllCipherTypes || !this.cardAndIdentityCiphers) {
return this.getAllCipherTypeViews(currentTab);
}
const cipherViews = (
await this.cipherService.getAllDecryptedForUrl(currentTab?.url || "")
).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b));
return cipherViews.concat(...this.cardAndIdentityCiphers);
}
/**
* Queries all cipher types from the user's vault returns them sorted by last used.
*
* @param currentTab - The current tab
*/
private async getAllCipherTypeViews(currentTab: chrome.tabs.Tab): Promise<CipherView[]> {
if (!this.cardAndIdentityCiphers) {
this.cardAndIdentityCiphers = new Set([]);
}
this.cardAndIdentityCiphers.clear();
const cipherViews = (
await this.cipherService.getAllDecryptedForUrl(currentTab.url, [CipherType.Card])
).sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b));
for (let cipherIndex = 0; cipherIndex < cipherViews.length; cipherIndex++) {
const cipherView = cipherViews[cipherIndex];
if (cipherView.type === CipherType.Card && !this.cardAndIdentityCiphers.has(cipherView)) {
this.cardAndIdentityCiphers.add(cipherView);
}
}
return cipherViews;
}
/**
* Strips out unnecessary data from the ciphers and returns an array of
* objects that contain the cipher data needed for the inline menu list.
@@ -260,6 +308,9 @@ export class OverlayBackground implements OverlayBackgroundInterface {
for (let cipherIndex = 0; cipherIndex < inlineMenuCiphersArray.length; cipherIndex++) {
const [inlineMenuCipherId, cipher] = inlineMenuCiphersArray[cipherIndex];
if (this.focusedFieldData?.filledByCipherType !== cipher.type) {
continue;
}
inlineMenuCipherData.push({
id: inlineMenuCipherId,
@@ -273,6 +324,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
});
}
this.currentInlineMenuCiphersCount = inlineMenuCipherData.length;
return inlineMenuCipherData;
}
@@ -1062,7 +1114,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
buttonPageTitle: this.i18nService.translate("bitwardenOverlayButton"),
toggleBitwardenVaultOverlay: this.i18nService.translate("toggleBitwardenVaultOverlay"),
listPageTitle: this.i18nService.translate("bitwardenVault"),
unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewMatchingLogins"),
unlockYourAccount: this.i18nService.translate("unlockYourAccountToViewAutofillSuggestions"),
unlockAccount: this.i18nService.translate("unlockAccount"),
fillCredentialsFor: this.i18nService.translate("fillCredentialsFor"),
username: this.i18nService.translate("username")?.toLowerCase(),
@@ -1070,6 +1122,11 @@ export class OverlayBackground implements OverlayBackgroundInterface {
noItemsToShow: this.i18nService.translate("noItemsToShow"),
newItem: this.i18nService.translate("newItem"),
addNewVaultItem: this.i18nService.translate("addNewVaultItem"),
newLogin: this.i18nService.translate("newLogin"),
addNewLoginItem: this.i18nService.translate("addNewLoginItem"),
newCard: this.i18nService.translate("newCard"),
addNewCardItem: this.i18nService.translate("addNewCardItem"),
cardNumberEndsWith: this.i18nService.translate("cardNumberEndsWith"),
};
}
@@ -1100,16 +1157,20 @@ export class OverlayBackground implements OverlayBackgroundInterface {
* Triggers adding a new vault item from the overlay. Gathers data
* input by the user before calling to open the add/edit window.
*
* @param addNewCipherType - The type of cipher to add
* @param sender - The sender of the port message
*/
private getNewVaultItemDetails({ sender }: chrome.runtime.Port) {
if (!this.senderTabHasFocusedField(sender)) {
private getNewVaultItemDetails(
{ addNewCipherType }: OverlayPortMessage,
{ sender }: chrome.runtime.Port,
) {
if (!addNewCipherType || !this.senderTabHasFocusedField(sender)) {
return;
}
void BrowserApi.tabSendMessage(
sender.tab,
{ command: "addNewVaultItemFromOverlay" },
{ command: "addNewVaultItemFromOverlay", addNewCipherType },
{
frameId: this.focusedFieldData.frameId || 0,
},
@@ -1120,18 +1181,60 @@ export class OverlayBackground implements OverlayBackgroundInterface {
* Handles adding a new vault item from the overlay. Gathers data login
* data captured in the extension message.
*
* @param addNewCipherType - The type of cipher to add
* @param login - The login data captured from the extension message
* @param card - The card data captured from the extension message
* @param sender - The sender of the extension message
*/
private async addNewVaultItem(
{ login }: OverlayAddNewItemMessage,
{ addNewCipherType, login, card }: OverlayAddNewItemMessage,
sender: chrome.runtime.MessageSender,
) {
if (!login) {
if (!addNewCipherType) {
return;
}
this.closeInlineMenu(sender);
const cipherView: CipherView = this.buildNewVaultItemCipherView({
addNewCipherType,
login,
card,
});
if (cipherView) {
this.closeInlineMenu(sender);
await this.cipherService.setAddEditCipherInfo({
cipher: cipherView,
collectionIds: cipherView.collectionIds,
});
await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id });
await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher");
}
}
/**
* Builds and returns a new cipher view with the provided vault item data.
*
* @param addNewCipherType - The type of cipher to add
* @param login - The login data captured from the extension message
* @param card - The card data captured from the extension message
*/
private buildNewVaultItemCipherView({ addNewCipherType, login, card }: OverlayAddNewItemMessage) {
if (login && addNewCipherType === CipherType.Login) {
return this.buildLoginCipherView(login);
}
if (card && addNewCipherType === CipherType.Card) {
return this.buildCardCipherView(card);
}
}
/**
* Builds a new login cipher view with the provided login data.
*
* @param login - The login data captured from the extension message
*/
private buildLoginCipherView(login: NewLoginCipherData) {
const uriView = new LoginUriView();
uriView.uri = login.uri;
@@ -1146,13 +1249,30 @@ export class OverlayBackground implements OverlayBackgroundInterface {
cipherView.type = CipherType.Login;
cipherView.login = loginView;
await this.cipherService.setAddEditCipherInfo({
cipher: cipherView,
collectionIds: cipherView.collectionIds,
});
return cipherView;
}
await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id });
await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher");
/**
* Builds a new card cipher view with the provided card data.
*
* @param card - The card data captured from the extension message
*/
private buildCardCipherView(card: NewCardCipherData) {
const cardView = new CardView();
cardView.cardholderName = card.cardholderName || "";
cardView.number = card.number || "";
cardView.expMonth = card.expirationMonth || "";
cardView.expYear = card.expirationYear || "";
cardView.code = card.cvv || "";
cardView.brand = card.number ? CardView.getCardBrandByPatterns(card.number) : "";
const cipherView = new CipherView();
cipherView.name = "";
cipherView.folderId = null;
cipherView.type = CipherType.Card;
cipherView.card = cardView;
return cipherView;
}
/**
@@ -1209,7 +1329,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
* @param sender - The sender of the message
*/
private checkIsInlineMenuCiphersPopulated(sender: chrome.runtime.MessageSender) {
return this.senderTabHasFocusedField(sender) && this.inlineMenuCiphers.size > 0;
return this.senderTabHasFocusedField(sender) && this.currentInlineMenuCiphersCount > 0;
}
/**
@@ -1477,6 +1597,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
portName: isInlineMenuListPort
? AutofillOverlayPort.ListMessageConnector
: AutofillOverlayPort.ButtonMessageConnector,
filledByCipherType: this.focusedFieldData?.filledByCipherType,
});
void this.updateInlineMenuPosition(
{

View File

@@ -104,7 +104,7 @@ export default class TabsBackground {
return;
}
await this.overlayBackground.updateOverlayCiphers();
await this.overlayBackground.updateOverlayCiphers(false);
if (this.main.onUpdatedRan) {
return;
@@ -134,7 +134,7 @@ export default class TabsBackground {
await Promise.all([
this.main.refreshBadge(),
this.main.refreshMenu(),
this.overlayBackground.updateOverlayCiphers(),
this.overlayBackground.updateOverlayCiphers(false),
]);
};
}

View File

@@ -1,4 +1,5 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CipherType } from "@bitwarden/common/vault/enums";
import { AutofillOverlayElementType } from "../../enums/autofill-overlay.enum";
import AutofillScript from "../../models/autofill-script";
@@ -18,6 +19,7 @@ export type AutofillExtensionMessage = {
isFocusingFieldElement?: boolean;
authStatus?: AuthenticationStatus;
isOpeningFullInlineMenu?: boolean;
addNewCipherType?: CipherType;
data?: {
direction?: "previous" | "next" | "current";
forceCloseInlineMenu?: boolean;

View File

@@ -1,3 +1,5 @@
import { CipherType } from "@bitwarden/common/vault/enums";
/**
* Represents a single field that is collected from the page source and is potentially autofilled.
*/
@@ -106,4 +108,6 @@ export default class AutofillField {
rel?: string | null;
checked?: boolean;
filledByCipherType?: CipherType;
}

View File

@@ -1,4 +1,5 @@
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CipherType } from "@bitwarden/common/vault/enums";
import { InlineMenuCipherData } from "../../../background/abstractions/overlay.background";
@@ -14,6 +15,7 @@ export type InitAutofillInlineMenuListMessage = AutofillInlineMenuListMessage &
theme: string;
translations: Record<string, string>;
ciphers?: InlineMenuCipherData[];
filledByCipherType?: CipherType;
portKey: string;
};

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with an empty list of ciphers creates the views for the no results inline menu 1`] = `
exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with an empty list of ciphers creates the views for the no results inline menu that does not have a fill by cipher type 1`] = `
<div
class="inline-menu-list-container theme_light"
>
@@ -13,7 +13,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with
class="inline-menu-list-button-container"
>
<button
aria-label="addNewVaultItem, opensInANewWindow"
aria-label=", opensInANewWindow"
class="add-new-item-button inline-menu-list-button"
id="new-item-button"
tabindex="-1"
@@ -45,13 +45,112 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with
</clippath>
</defs>
</svg>
newItem
</button>
</div>
</div>
`;
exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user creates the view for a list of ciphers 1`] = `
exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with an empty list of ciphers creates the views for the no results inline menu that should be filled by a card cipher 1`] = `
<div
class="inline-menu-list-container theme_light"
>
<div
class="no-items inline-menu-list-message"
>
noItemsToShow
</div>
<div
class="inline-menu-list-button-container"
>
<button
aria-label=", opensInANewWindow"
class="add-new-item-button inline-menu-list-button"
id="new-item-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="17"
viewBox="0 0 16 17"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M15.222 7.914H8.963a.471.471 0 0 1-.34-.147.512.512 0 0 1-.142-.353V.99c0-.133-.05-.26-.14-.354a.471.471 0 0 0-.68 0 .51.51 0 0 0-.142.354v6.424c0 .132-.051.26-.142.353a.474.474 0 0 1-.34.147H.777a.47.47 0 0 0-.34.146.5.5 0 0 0-.14.354.522.522 0 0 0 .14.353.48.48 0 0 0 .34.147h6.26c.128 0 .25.052.34.146.09.094.142.221.142.354v6.576c0 .132.05.26.14.353a.471.471 0 0 0 .68 0 .512.512 0 0 0 .142-.353V9.414c0-.133.051-.26.142-.354a.474.474 0 0 1 .34-.146h6.26c.127 0 .25-.053.34-.147a.511.511 0 0 0 0-.707.472.472 0 0 0-.34-.146Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .49h16v16H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</div>
`;
exports[`AutofillInlineMenuList initAutofillInlineMenuList the inline menu with an empty list of ciphers creates the views for the no results inline menu that should be filled by a login cipher 1`] = `
<div
class="inline-menu-list-container theme_light"
>
<div
class="no-items inline-menu-list-message"
>
noItemsToShow
</div>
<div
class="inline-menu-list-button-container"
>
<button
aria-label=", opensInANewWindow"
class="add-new-item-button inline-menu-list-button"
id="new-item-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="17"
viewBox="0 0 16 17"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M15.222 7.914H8.963a.471.471 0 0 1-.34-.147.512.512 0 0 1-.142-.353V.99c0-.133-.05-.26-.14-.354a.471.471 0 0 0-.68 0 .51.51 0 0 0-.142.354v6.424c0 .132-.051.26-.142.353a.474.474 0 0 1-.34.147H.777a.47.47 0 0 0-.34.146.5.5 0 0 0-.14.354.522.522 0 0 0 .14.353.48.48 0 0 0 .34.147h6.26c.128 0 .25.052.34.146.09.094.142.221.142.354v6.576c0 .132.05.26.14.353a.471.471 0 0 0 .68 0 .512.512 0 0 0 .142-.353V9.414c0-.133.051-.26.142-.354a.474.474 0 0 1 .34-.146h6.26c.127 0 .25-.053.34-.147a.511.511 0 0 0 0-.707.472.472 0 0 0-.34-.146Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .49h16v16H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</div>
`;
exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user creates the view for a list of login ciphers 1`] = `
<div
class="inline-menu-list-container theme_light"
>
@@ -87,7 +186,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
website login 1
</span>
<span
class="cipher-user-login"
class="cipher-subtitle"
title="username1"
>
username1
@@ -156,7 +255,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
website login 2
</span>
<span
class="cipher-user-login"
class="cipher-subtitle"
title="username2"
>
username2
@@ -297,7 +396,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
website login 4
</span>
<span
class="cipher-user-login"
class="cipher-subtitle"
title="username4"
>
username4
@@ -367,7 +466,7 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
website login 5
</span>
<span
class="cipher-user-login"
class="cipher-subtitle"
title="username5"
>
username5
@@ -437,7 +536,439 @@ exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers f
website login 6
</span>
<span
class="cipher-user-login"
class="cipher-subtitle"
title="username6"
>
username6
</span>
</span>
</button>
<button
aria-label="view website login 6, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
</ul>
</div>
`;
exports[`AutofillInlineMenuList initAutofillInlineMenuList the list of ciphers for an authenticated user creates the views for a list of card ciphers 1`] = `
<div
class="inline-menu-list-container theme_light"
>
<ul
class="inline-menu-list-actions"
role="list"
>
<li
class="inline-menu-list-actions-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="username, username1"
aria-label="fillCredentialsFor website login 1"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 1"
>
website login 1
</span>
<span
class="cipher-subtitle"
title="username1"
>
username1
</span>
</span>
</button>
<button
aria-label="view website login 1, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="inline-menu-list-actions-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="username, username2"
aria-label="fillCredentialsFor website login 2"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon bwi bw-icon"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 2"
>
website login 2
</span>
<span
class="cipher-subtitle"
title="username2"
>
username2
</span>
</span>
</button>
<button
aria-label="view website login 2, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="inline-menu-list-actions-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="username, "
aria-label="fillCredentialsFor "
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon bwi bw-icon"
/>
<span
class="cipher-details"
/>
</button>
<button
aria-label="view , opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="inline-menu-list-actions-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="username, username4"
aria-label="fillCredentialsFor website login 4"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
>
<svg
aria-hidden="true"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M18.026 17.842c-1.418 1.739-3.517 2.84-5.86 2.84a7.364 7.364 0 0 1-3.431-.848l.062-.15.062-.151.063-.157c.081-.203.17-.426.275-.646.133-.28.275-.522.426-.68.026-.028.101-.075.275-.115.165-.037.376-.059.629-.073.138-.008.288-.014.447-.02.399-.016.847-.034 1.266-.092.314-.044.566-.131.755-.271a.884.884 0 0 0 .352-.555c.037-.2.008-.392-.03-.543-.035-.135-.084-.264-.12-.355l-.01-.03a4.26 4.26 0 0 0-.145-.33c-.126-.264-.237-.497-.288-1.085-.03-.344.09-.73.251-1.138l.089-.22c.05-.123.1-.247.14-.355.064-.171.129-.375.129-.566a1.51 1.51 0 0 0-.134-.569 2.573 2.573 0 0 0-.319-.547c-.246-.323-.635-.669-1.093-.669-.44 0-1.006.169-1.487.368-.246.102-.48.216-.68.33-.192.111-.372.235-.492.359-.93.96-1.48 1.239-1.81 1.258-.277.017-.478-.15-.736-.525a9.738 9.738 0 0 1-.19-.29l-.006-.01a11.568 11.568 0 0 0-.198-.305 2.76 2.76 0 0 0-.521-.6 1.39 1.39 0 0 0-1.088-.314 8.302 8.302 0 0 1 1.987-3.936c.055.342.146.626.272.856.23.42.561.64.926.716.406.086.857-.061 1.26-.216.125-.047.248-.097.372-.147.309-.125.618-.25.947-.341.26-.072.581-.057.959.012.264.049.529.118.8.19l.36.091c.379.094.782.178 1.135.148.374-.032.733-.197.934-.623a.874.874 0 0 0 .024-.752c-.087-.197-.24-.355-.35-.47-.26-.267-.412-.427-.412-.685 0-.125.037-.2.09-.263a.982.982 0 0 1 .303-.211c.059-.03.119-.058.183-.089l.036-.016a3.79 3.79 0 0 0 .236-.118c.047-.026.098-.056.148-.093 1.936.747 3.51 2.287 4.368 4.249a7.739 7.739 0 0 0-.031-.004c-.38-.047-.738-.056-1.063.061-.34.123-.603.368-.817.74-.122.211-.284.43-.463.67l-.095.129c-.207.281-.431.595-.58.92-.15.326-.245.705-.142 1.103.104.397.387.738.837 1.036.099.065.225.112.314.145l.02.008c.108.04.195.074.268.117.07.042.106.08.124.114.017.03.037.087.022.206-.047.376-.069.73-.052 1.034.017.292.071.59.218.809.118.174.12.421.108.786v.01a2.46 2.46 0 0 0 .021.518.809.809 0 0 0 .15.35Zm1.357.059a9.654 9.654 0 0 0 1.62-5.386c0-5.155-3.957-9.334-8.836-9.334-4.88 0-8.836 4.179-8.836 9.334 0 3.495 1.82 6.543 4.513 8.142v.093h.161a8.426 8.426 0 0 0 4.162 1.098c2.953 0 5.568-1.53 7.172-3.882a.569.569 0 0 0 .048-.062l-.004-.003ZM8.152 19.495a43.345 43.345 0 0 1 .098-.238l.057-.142c.082-.205.182-.455.297-.698.143-.301.323-.624.552-.864.163-.172.392-.254.602-.302.219-.05.473-.073.732-.088.162-.01.328-.016.495-.023.386-.015.782-.03 1.168-.084.255-.036.392-.099.461-.15.06-.045.076-.084.083-.12a.534.534 0 0 0-.02-.223 2.552 2.552 0 0 0-.095-.278l-.01-.027a3.128 3.128 0 0 0-.104-.232c-.134-.282-.31-.65-.374-1.381-.046-.533.138-1.063.3-1.472.035-.09.069-.172.1-.249.046-.11.086-.21.123-.31.062-.169.083-.264.083-.312a.812.812 0 0 0-.076-.283 1.867 1.867 0 0 0-.23-.394c-.21-.274-.428-.408-.577-.408-.315 0-.788.13-1.246.32a5.292 5.292 0 0 0-.603.293 1.727 1.727 0 0 0-.347.244c-.936.968-1.641 1.421-2.235 1.457-.646.04-1.036-.413-1.31-.813-.07-.103-.139-.21-.203-.311l-.005-.007c-.064-.101-.125-.197-.188-.29a2.098 2.098 0 0 0-.387-.453.748.748 0 0 0-.436-.18c-.1-.006-.22.005-.365.046a8.707 8.707 0 0 0-.056.992c0 2.957 1.488 5.547 3.716 6.98Zm10.362-2.316.003-.192.002-.046c.01-.305.026-.786-.232-1.169-.036-.054-.082-.189-.096-.444-.014-.243.003-.55.047-.9a1.051 1.051 0 0 0-.105-.649.987.987 0 0 0-.374-.374 2.285 2.285 0 0 0-.367-.166h-.003a1.243 1.243 0 0 1-.205-.088c-.369-.244-.505-.46-.549-.629-.044-.168-.015-.364.099-.61.115-.25.297-.511.507-.796l.089-.12c.178-.239.368-.495.512-.745.152-.263.302-.382.466-.441.18-.065.416-.073.77-.03.142.018.275.04.397.063.274.837.423 1.736.423 2.671a8.45 8.45 0 0 1-1.384 4.665Zm-4.632-12.63a7.362 7.362 0 0 0-1.715-.201c-1.89 0-3.621.716-4.965 1.905.025.54.12.887.24 1.105.13.238.295.34.482.38.2.042.484-.027.905-.188l.328-.13c.32-.13.681-.275 1.048-.377.398-.111.833-.075 1.24 0 .289.053.59.132.871.205l.326.084c.383.094.694.151.932.13.216-.017.326-.092.395-.237.039-.083.027-.114.014-.144-.027-.062-.088-.136-.212-.264l-.043-.044c-.218-.222-.567-.578-.567-1.142 0-.305.101-.547.262-.734.137-.159.308-.267.46-.347Z"
fill="#777"
fill-rule="evenodd"
/>
</svg>
</span>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 4"
>
website login 4
</span>
<span
class="cipher-subtitle"
title="username4"
>
username4
</span>
</span>
</button>
<button
aria-label="view website login 4, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="inline-menu-list-actions-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="username, username5"
aria-label="fillCredentialsFor website login 5"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 5"
>
website login 5
</span>
<span
class="cipher-subtitle"
title="username5"
>
username5
</span>
</span>
</button>
<button
aria-label="view website login 5, opensInANewWindow"
class="view-cipher-button"
tabindex="-1"
>
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M16.587 7.932H5.9a.455.455 0 0 1-.31-.12.393.393 0 0 1-.127-.287c0-.108.046-.211.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.076.128.179.128.287a.393.393 0 0 1-.128.288.455.455 0 0 1-.31.119Zm0 2.474H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm0 2.468H5.9a.455.455 0 0 1-.31-.119.393.393 0 0 1-.127-.287c0-.108.046-.212.128-.288a.455.455 0 0 1 .309-.119h10.687c.117 0 .228.043.31.12.082.075.128.179.128.287a.393.393 0 0 1-.128.287.455.455 0 0 1-.31.12Zm2.163-8.103v10.457H1.25V4.771h17.5Zm0-1.162H1.25a1.3 1.3 0 0 0-.884.34A1.122 1.122 0 0 0 0 4.772v10.457c0 .308.132.604.366.822a1.3 1.3 0 0 0 .884.34h17.5a1.3 1.3 0 0 0 .884-.34c.234-.218.366-.514.366-.822V4.771c0-.308-.132-.603-.366-.821a1.3 1.3 0 0 0-.884-.34ZM3.213 8.01c.287 0 .52-.217.52-.484s-.234-.483-.52-.483c-.288 0-.52.216-.52.483s.233.483.52.483Zm0 4.903c.287 0 .52-.217.52-.484 0-.266-.234-.483-.52-.483-.287 0-.52.216-.52.483s.233.484.52.484Zm0-2.452c.287 0 .52-.216.52-.483 0-.268-.234-.484-.52-.484-.288 0-.52.216-.52.484 0 .267.233.483.52.483Z"
fill="#175DDC"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 .113h20v19.773H0z"
fill="#fff"
/>
</clippath>
</defs>
</svg>
</button>
</div>
</li>
<li
class="inline-menu-list-actions-item"
role="listitem"
>
<div
class="cipher-container"
>
<button
aria-description="username, username6"
aria-label="fillCredentialsFor website login 6"
class="fill-cipher-button"
tabindex="-1"
>
<span
aria-hidden="true"
class="cipher-icon"
style="background-image: url(https://jest-testing-website.com/image.png);"
/>
<span
class="cipher-details"
>
<span
class="cipher-name"
title="website login 6"
>
website login 6
</span>
<span
class="cipher-subtitle"
title="username6"
>
username6

View File

@@ -1,8 +1,12 @@
import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { CipherType } from "@bitwarden/common/vault/enums";
import { createInitAutofillInlineMenuListMessageMock } from "../../../../spec/autofill-mocks";
import {
createAutofillOverlayCipherDataMock,
createInitAutofillInlineMenuListMessageMock,
} from "../../../../spec/autofill-mocks";
import { flushPromises, postWindowMessage } from "../../../../spec/testing-utils";
import { AutofillInlineMenuList } from "./autofill-inline-menu-list";
@@ -69,7 +73,33 @@ describe("AutofillInlineMenuList", () => {
);
});
it("creates the views for the no results inline menu", () => {
it("creates the views for the no results inline menu that should be filled by a login cipher", () => {
expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot();
});
it("creates the views for the no results inline menu that should be filled by a card cipher", () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
authStatus: AuthenticationStatus.Unlocked,
ciphers: [],
filledByCipherType: CipherType.Card,
portKey,
}),
);
expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot();
});
it("creates the views for the no results inline menu that does not have a fill by cipher type", () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
authStatus: AuthenticationStatus.Unlocked,
ciphers: [],
filledByCipherType: undefined,
portKey,
}),
);
expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot();
});
@@ -80,7 +110,7 @@ describe("AutofillInlineMenuList", () => {
addVaultItemButton.dispatchEvent(new Event("click"));
expect(globalThis.parent.postMessage).toHaveBeenCalledWith(
{ command: "addNewVaultItem", portKey },
{ command: "addNewVaultItem", portKey, addNewCipherType: CipherType.Login },
"*",
);
});
@@ -91,7 +121,37 @@ describe("AutofillInlineMenuList", () => {
postWindowMessage(createInitAutofillInlineMenuListMessageMock());
});
it("creates the view for a list of ciphers", () => {
it("creates the view for a list of login ciphers", () => {
expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot();
});
it("creates the views for a list of card ciphers", () => {
postWindowMessage(
createInitAutofillInlineMenuListMessageMock({
filledByCipherType: CipherType.Card,
ciphers: [
createAutofillOverlayCipherDataMock(1, {
type: CipherType.Card,
card: "Visa, *4234",
login: null,
icon: {
imageEnabled: true,
icon: "bw-id-card card-visa",
},
}),
createAutofillOverlayCipherDataMock(1, {
type: CipherType.Card,
card: "*2234",
login: null,
icon: {
imageEnabled: true,
icon: "bw-id-card card-visa",
},
}),
],
}),
);
expect(autofillInlineMenuList["inlineMenuListContainer"]).toMatchSnapshot();
});

View File

@@ -2,13 +2,14 @@ import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { CipherType } from "@bitwarden/common/vault/enums";
import { InlineMenuCipherData } from "../../../../background/abstractions/overlay.background";
import { buildSvgDomElement } from "../../../../utils";
import { globeIcon, lockIcon, plusIcon, viewCipherIcon } from "../../../../utils/svg-icons";
import {
InitAutofillInlineMenuListMessage,
AutofillInlineMenuListWindowMessageHandlers,
InitAutofillInlineMenuListMessage,
} from "../../abstractions/autofill-inline-menu-list";
import { AutofillInlineMenuPageElement } from "../shared/autofill-inline-menu-page-element";
@@ -21,6 +22,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
private cipherListScrollIsDebounced = false;
private cipherListScrollDebounceTimeout: number | NodeJS.Timeout;
private currentCipherIndex = 0;
private filledByCipherType: CipherType;
private readonly showCiphersPerPage = 6;
private readonly inlineMenuListWindowMessageHandlers: AutofillInlineMenuListWindowMessageHandlers =
{
@@ -46,6 +48,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
* @param authStatus - The current authentication status.
* @param ciphers - The ciphers to display in the inline menu list.
* @param portKey - Background generated key that allows the port to communicate with the background.
* @param filledByCipherType - The type of cipher that fills the current field.
*/
private async initAutofillInlineMenuList({
translations,
@@ -54,6 +57,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
authStatus,
ciphers,
portKey,
filledByCipherType,
}: InitAutofillInlineMenuListMessage) {
const linkElement = await this.initAutofillInlineMenuPage(
"list",
@@ -62,6 +66,8 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
portKey,
);
this.filledByCipherType = filledByCipherType;
const themeClass = `theme_${theme}`;
globalThis.document.documentElement.classList.add(themeClass);
@@ -157,10 +163,10 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
newItemButton.tabIndex = -1;
newItemButton.id = "new-item-button";
newItemButton.classList.add("add-new-item-button", "inline-menu-list-button");
newItemButton.textContent = this.getTranslation("newItem");
newItemButton.textContent = this.getNewItemButtonText();
newItemButton.setAttribute(
"aria-label",
`${this.getTranslation("addNewVaultItem")}, ${this.getTranslation("opensInANewWindow")}`,
`${this.getNewItemAriaLabel()}, ${this.getTranslation("opensInANewWindow")}`,
);
newItemButton.prepend(buildSvgDomElement(plusIcon));
newItemButton.addEventListener(EVENTS.CLICK, this.handeNewItemButtonClick);
@@ -172,12 +178,45 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
this.inlineMenuListContainer.append(noItemsMessage, inlineMenuListButtonContainer);
}
/**
* Gets the new item text for the button based on the cipher type the focused field is filled by.
*/
private getNewItemButtonText() {
if (this.filledByCipherType === CipherType.Login) {
return this.getTranslation("newLogin");
}
if (this.filledByCipherType === CipherType.Card) {
return this.getTranslation("newCard");
}
return this.getTranslation("newItem");
}
/**
* Gets the aria label for the new item button based on the cipher type the focused field is filled by.
*/
private getNewItemAriaLabel() {
if (this.filledByCipherType === CipherType.Login) {
return this.getTranslation("addNewLoginItem");
}
if (this.filledByCipherType === CipherType.Card) {
return this.getTranslation("addNewCardItem");
}
return this.getTranslation("addNewVaultItem");
}
/**
* Handles the click event for the new item button.
* Sends a message to the parent window to add a new vault item.
*/
private handeNewItemButtonClick = () => {
this.postMessageToParent({ command: "addNewVaultItem" });
this.postMessageToParent({
command: "addNewVaultItem",
addNewCipherType: this.filledByCipherType,
});
};
/**
@@ -267,10 +306,7 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
"aria-label",
`${this.getTranslation("fillCredentialsFor")} ${cipher.name}`,
);
fillCipherElement.setAttribute(
"aria-description",
`${this.getTranslation("username")}, ${cipher.login.username}`,
);
this.addFillCipherElementAriaDescription(fillCipherElement, cipher);
fillCipherElement.append(cipherIcon, cipherDetailsElement);
fillCipherElement.addEventListener(EVENTS.CLICK, this.handleFillCipherClickEvent(cipher));
fillCipherElement.addEventListener(EVENTS.KEYUP, this.handleFillCipherKeyUpEvent);
@@ -278,6 +314,44 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
return fillCipherElement;
}
/**
* Adds an aria description to the fill cipher button for a given cipher.
*
* @param fillCipherElement - The fill cipher button element.
* @param cipher - The cipher to add the aria description for.
*/
private addFillCipherElementAriaDescription(
fillCipherElement: HTMLButtonElement,
cipher: InlineMenuCipherData,
) {
if (cipher.login) {
fillCipherElement.setAttribute(
"aria-description",
`${this.getTranslation("username")}, ${cipher.login.username}`,
);
return;
}
if (cipher.card) {
const cardParts = cipher.card.split(", *");
if (cardParts.length === 1) {
const cardDigits = cardParts[0].startsWith("*") ? cardParts[0].substring(1) : cardParts[0];
fillCipherElement.setAttribute(
"aria-description",
`${this.getTranslation("cardNumberEndsWith")} ${cardDigits}`,
);
return;
}
const cardBrand = cardParts[0];
const cardDigits = cardParts[1];
fillCipherElement.setAttribute(
"aria-description",
`${cardBrand}, ${this.getTranslation("cardNumberEndsWith")} ${cardDigits}`,
);
}
}
/**
* Handles the click event for the fill cipher button.
* Sends a message to the parent window to fill the selected cipher.
@@ -412,7 +486,8 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
dummyImageElement.src = url.href;
dummyImageElement.addEventListener("error", () => {
cipherIcon.style.backgroundImage = "";
cipherIcon.classList.add("cipher-icon", "bwi", cipher.icon.icon);
const iconClasses = cipher.icon.icon.split(" ");
cipherIcon.classList.add("cipher-icon", "bwi", ...iconClasses);
});
dummyImageElement.remove();
@@ -423,7 +498,8 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
}
if (cipher.icon?.icon) {
cipherIcon.classList.add("cipher-icon", "bwi", cipher.icon.icon);
const iconClasses = cipher.icon.icon.split(" ");
cipherIcon.classList.add("cipher-icon", "bwi", ...iconClasses);
return cipherIcon;
}
@@ -432,21 +508,21 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
}
/**
* Builds the details for a given cipher. Includes the cipher name and username login.
* Builds the details for a given cipher. Includes the cipher name and subtitle.
*
* @param cipher - The cipher to build the details for.
*/
private buildCipherDetailsElement(cipher: InlineMenuCipherData) {
const cipherNameElement = this.buildCipherNameElement(cipher);
const cipherUserLoginElement = this.buildCipherUserLoginElement(cipher);
const cipherSubtitleElement = this.buildCipherSubtitleElement(cipher);
const cipherDetailsElement = globalThis.document.createElement("span");
cipherDetailsElement.classList.add("cipher-details");
if (cipherNameElement) {
cipherDetailsElement.appendChild(cipherNameElement);
}
if (cipherUserLoginElement) {
cipherDetailsElement.appendChild(cipherUserLoginElement);
if (cipherSubtitleElement) {
cipherDetailsElement.appendChild(cipherSubtitleElement);
}
return cipherDetailsElement;
@@ -471,21 +547,22 @@ export class AutofillInlineMenuList extends AutofillInlineMenuPageElement {
}
/**
* Builds the username login element for a given cipher.
* Builds the subtitle element for a given cipher.
*
* @param cipher - The cipher to build the username login element for.
*/
private buildCipherUserLoginElement(cipher: InlineMenuCipherData): HTMLSpanElement | null {
if (!cipher.login?.username) {
private buildCipherSubtitleElement(cipher: InlineMenuCipherData): HTMLSpanElement | null {
const subTitleText = cipher.login?.username || cipher.card;
if (!subTitleText) {
return null;
}
const cipherUserLoginElement = globalThis.document.createElement("span");
cipherUserLoginElement.classList.add("cipher-user-login");
cipherUserLoginElement.textContent = cipher.login.username;
cipherUserLoginElement.setAttribute("title", cipher.login.username);
const cipherSubtitleElement = globalThis.document.createElement("span");
cipherSubtitleElement.classList.add("cipher-subtitle");
cipherSubtitleElement.textContent = subTitleText;
cipherSubtitleElement.setAttribute("title", subTitleText);
return cipherUserLoginElement;
return cipherSubtitleElement;
}
/**

View File

@@ -263,7 +263,7 @@ body {
}
.cipher-name,
.cipher-user-login {
.cipher-subtitle {
display: block;
width: 100%;
line-height: 1.5;
@@ -283,7 +283,7 @@ body {
}
}
.cipher-user-login {
.cipher-subtitle {
font-size: 1.4rem;
@include themify($themes) {

View File

@@ -19,7 +19,7 @@ export type SubFrameDataFromWindowMessage = SubFrameOffsetData & {
export type AutofillOverlayContentExtensionMessageHandlers = {
[key: string]: CallableFunction;
openAutofillInlineMenu: ({ message }: AutofillExtensionMessageParam) => void;
addNewVaultItemFromOverlay: () => void;
addNewVaultItemFromOverlay: ({ message }: AutofillExtensionMessageParam) => void;
blurMostRecentlyFocusedField: () => void;
unsetMostRecentlyFocusedField: () => void;
checkIsMostRecentlyFocusedFieldWithinViewport: () => Promise<boolean>;

View File

@@ -1,6 +1,21 @@
import AutofillField from "../../models/autofill-field";
import AutofillPageDetails from "../../models/autofill-page-details";
export type AutofillKeywordsMap = WeakMap<
AutofillField,
{
keywordsSet: Set<string>;
stringValue: string;
}
>;
export interface InlineMenuFieldQualificationService {
isFieldForLoginForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean;
isFieldForCreditCardForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean;
isFieldForCardholderName(field: AutofillField): boolean;
isFieldForCardNumber(field: AutofillField): boolean;
isFieldForCardExpirationDate(field: AutofillField): boolean;
isFieldForCardExpirationMonth(field: AutofillField): boolean;
isFieldForCardExpirationYear(field: AutofillField): boolean;
isFieldForCardCvv(field: AutofillField): boolean;
}

View File

@@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AutofillOverlayVisibility, EVENTS } from "@bitwarden/common/autofill/constants";
import { CipherType } from "@bitwarden/common/vault/enums";
import AutofillInit from "../content/autofill-init";
import {
@@ -14,7 +15,7 @@ import AutofillForm from "../models/autofill-form";
import AutofillPageDetails from "../models/autofill-page-details";
import { createAutofillFieldMock } from "../spec/autofill-mocks";
import { flushPromises, postWindowMessage, sendMockExtensionMessage } from "../spec/testing-utils";
import { ElementWithOpId, FormFieldElement } from "../types";
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
import { AutoFillConstants } from "./autofill-constants";
import { AutofillOverlayContentService } from "./autofill-overlay-content.service";
@@ -147,7 +148,7 @@ describe("AutofillOverlayContentService", () => {
opid: "password-field",
form: "validFormId",
elementNumber: 2,
autocompleteType: "current-password",
autoCompleteType: "current-password",
type: "password",
});
pageDetailsMock = mock<AutofillPageDetails>({
@@ -180,7 +181,7 @@ describe("AutofillOverlayContentService", () => {
});
});
it("ignores fields that do not appear as a login field", async () => {
it("ignores fields that do not appear as a login or card field", async () => {
autofillFieldData.htmlName = "another-type-of-field";
autofillFieldData.htmlID = "another-type-of-field";
autofillFieldData.placeholder = "another-type-of-field";
@@ -196,7 +197,10 @@ describe("AutofillOverlayContentService", () => {
});
it("skips setup on fields that have been previously set up", async () => {
autofillOverlayContentService["formFieldElements"].add(autofillFieldElement);
autofillOverlayContentService["formFieldElements"].set(
autofillFieldElement,
autofillFieldData,
);
await autofillOverlayContentService.setupInlineMenu(
autofillFieldElement,
@@ -414,6 +418,17 @@ describe("AutofillOverlayContentService", () => {
expect(autofillOverlayContentService["storeModifiedFormElement"]).not.toHaveBeenCalled();
});
it("skips storing the element if it is not present in the set of qualified autofill fields", () => {
const randomElement = document.createElement(
"input",
) as ElementWithOpId<FillableFormFieldElement>;
jest.spyOn(autofillOverlayContentService as any, "storeUserFilledLoginField");
autofillOverlayContentService["storeModifiedFormElement"](randomElement);
expect(autofillOverlayContentService["storeUserFilledLoginField"]).not.toHaveBeenCalled();
});
it("sets the field as the most recently focused form field element", async () => {
autofillOverlayContentService["mostRecentlyFocusedField"] =
mock<ElementWithOpId<FormFieldElement>>();
@@ -551,6 +566,155 @@ describe("AutofillOverlayContentService", () => {
expect(autofillOverlayContentService["openInlineMenu"]).toHaveBeenCalled();
});
describe("input changes on a field filled by a card cipher", () => {
let inputFieldElement: ElementWithOpId<FillableFormFieldElement>;
let inputFieldData: AutofillField;
let selectFieldElement: ElementWithOpId<FillableFormFieldElement>;
let selectFieldData: AutofillField;
beforeEach(() => {
inputFieldElement = document.createElement(
"input",
) as ElementWithOpId<FillableFormFieldElement>;
inputFieldData = createAutofillFieldMock({
opid: "input-field",
form: "validFormId",
elementNumber: 3,
autoCompleteType: "cc-number",
type: "text",
filledByCipherType: CipherType.Card,
viewable: true,
});
selectFieldElement = document.createElement(
"select",
) as ElementWithOpId<FillableFormFieldElement>;
selectFieldData = createAutofillFieldMock({
opid: "select-field",
form: "validFormId",
elementNumber: 4,
autoCompleteType: "cc-type",
type: "select",
filledByCipherType: CipherType.Card,
viewable: true,
});
pageDetailsMock.fields = [inputFieldData, selectFieldData];
});
it("only stores the element if the form field is a select element", async () => {
jest.spyOn(autofillOverlayContentService as any, "storeModifiedFormElement");
jest.spyOn(autofillOverlayContentService as any, "hideInlineMenuListOnFilledField");
await autofillOverlayContentService.setupInlineMenu(
selectFieldElement,
selectFieldData,
pageDetailsMock,
);
selectFieldElement.dispatchEvent(new Event("input"));
expect(autofillOverlayContentService["storeModifiedFormElement"]).toHaveBeenCalledWith(
selectFieldElement,
);
expect(
autofillOverlayContentService["hideInlineMenuListOnFilledField"],
).not.toHaveBeenCalled();
});
it("stores cardholder name fields", async () => {
inputFieldData.autoCompleteType = "cc-name";
await autofillOverlayContentService.setupInlineMenu(
inputFieldElement,
inputFieldData,
pageDetailsMock,
);
inputFieldElement.dispatchEvent(new Event("input"));
expect(autofillOverlayContentService["userFilledFields"].cardholderName).toEqual(
inputFieldElement,
);
});
it("stores card number fields", async () => {
await autofillOverlayContentService.setupInlineMenu(
inputFieldElement,
inputFieldData,
pageDetailsMock,
);
inputFieldElement.dispatchEvent(new Event("input"));
expect(autofillOverlayContentService["userFilledFields"].cardNumber).toEqual(
inputFieldElement,
);
});
it("stores card expiration month fields", async () => {
inputFieldData.autoCompleteType = "cc-exp-month";
await autofillOverlayContentService.setupInlineMenu(
inputFieldElement,
inputFieldData,
pageDetailsMock,
);
inputFieldElement.dispatchEvent(new Event("input"));
expect(autofillOverlayContentService["userFilledFields"].cardExpirationMonth).toEqual(
inputFieldElement,
);
});
it("stores card expiration year fields", async () => {
inputFieldData.autoCompleteType = "cc-exp-year";
await autofillOverlayContentService.setupInlineMenu(
inputFieldElement,
inputFieldData,
pageDetailsMock,
);
inputFieldElement.dispatchEvent(new Event("input"));
expect(autofillOverlayContentService["userFilledFields"].cardExpirationYear).toEqual(
inputFieldElement,
);
});
it("stores card expiration date fields", async () => {
inputFieldData.autoCompleteType = "cc-exp";
await autofillOverlayContentService.setupInlineMenu(
inputFieldElement,
inputFieldData,
pageDetailsMock,
);
inputFieldElement.dispatchEvent(new Event("input"));
expect(autofillOverlayContentService["userFilledFields"].cardExpirationDate).toEqual(
inputFieldElement,
);
});
it("stores card cvv fields", async () => {
inputFieldData.autoCompleteType = "cc-csc";
await autofillOverlayContentService.setupInlineMenu(
inputFieldElement,
inputFieldData,
pageDetailsMock,
);
inputFieldElement.dispatchEvent(new Event("input"));
expect(autofillOverlayContentService["userFilledFields"].cardCvv).toEqual(
inputFieldElement,
);
});
});
});
describe("form field click event listener", () => {
@@ -627,6 +791,24 @@ describe("AutofillOverlayContentService", () => {
expect(updateMostRecentlyFocusedFieldSpy).not.toHaveBeenCalled();
});
it("closes the inline menu if the focused element is a select element", async () => {
const selectFieldElement = document.createElement(
"select",
) as ElementWithOpId<HTMLSelectElement>;
autofillFieldData.type = "select";
autofillFieldData.autoCompleteType = "cc-type";
await autofillOverlayContentService.setupInlineMenu(
selectFieldElement,
autofillFieldData,
pageDetailsMock,
);
selectFieldElement.dispatchEvent(new Event("focus"));
await flushPromises();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu");
});
it("updates the most recently focused field", async () => {
await autofillOverlayContentService.setupInlineMenu(
autofillFieldElement,
@@ -801,6 +983,85 @@ describe("AutofillOverlayContentService", () => {
expect(autofillFieldElement.removeEventListener).toHaveBeenCalled();
});
});
describe("setting up the form field listeners on card fields", () => {
const inputCardFieldData = createAutofillFieldMock({
opid: "card-field",
form: "validFormId",
elementNumber: 3,
autoCompleteType: "cc-number",
type: "text",
});
const selectCardFieldData = createAutofillFieldMock({
opid: "card-field",
form: "validFormId",
elementNumber: 3,
autoCompleteType: "cc-type",
type: "select-one",
});
beforeEach(() => {
pageDetailsMock.fields = [inputCardFieldData, selectCardFieldData];
});
it("sets up the input card field listeners", async () => {
await autofillOverlayContentService.setupInlineMenu(
autofillFieldElement,
inputCardFieldData,
pageDetailsMock,
);
await flushPromises();
expect(autofillFieldElement.addEventListener).toHaveBeenCalledWith(
EVENTS.BLUR,
expect.any(Function),
);
expect(autofillFieldElement.addEventListener).toHaveBeenCalledWith(
EVENTS.KEYUP,
expect.any(Function),
);
expect(autofillFieldElement.addEventListener).toHaveBeenCalledWith(
EVENTS.INPUT,
expect.any(Function),
);
expect(autofillFieldElement.addEventListener).toHaveBeenCalledWith(
EVENTS.CLICK,
expect.any(Function),
);
expect(autofillFieldElement.addEventListener).toHaveBeenCalledWith(
EVENTS.FOCUS,
expect.any(Function),
);
expect(autofillFieldElement.removeEventListener).toHaveBeenCalled();
});
it("sets up the input and focus listeners on a select card field", async () => {
const selectCardFieldElement = document.createElement(
"select",
) as ElementWithOpId<HTMLSelectElement>;
selectCardFieldElement.opid = "op-2";
jest.spyOn(selectCardFieldElement, "addEventListener");
await autofillOverlayContentService.setupInlineMenu(
selectCardFieldElement,
selectCardFieldData,
pageDetailsMock,
);
expect(selectCardFieldElement.addEventListener).toHaveBeenCalledWith(
EVENTS.FOCUS,
expect.any(Function),
);
expect(selectCardFieldElement.addEventListener).toHaveBeenCalledWith(
EVENTS.INPUT,
expect.any(Function),
);
expect(selectCardFieldElement.addEventListener).not.toHaveBeenCalledWith(
EVENTS.KEYUP,
expect.any(Function),
);
});
});
});
it("skips triggering the form field focused handler if the document is not focused", async () => {
@@ -1065,10 +1326,14 @@ describe("AutofillOverlayContentService", () => {
.spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible")
.mockResolvedValue(true);
sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" });
sendMockExtensionMessage({
command: "addNewVaultItemFromOverlay",
addNewCipherType: CipherType.Login,
});
await flushPromises();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayAddNewVaultItem", {
addNewCipherType: CipherType.Login,
login: {
username: "",
password: "",
@@ -1101,10 +1366,14 @@ describe("AutofillOverlayContentService", () => {
password: passwordField,
};
sendMockExtensionMessage({ command: "addNewVaultItemFromOverlay" });
sendMockExtensionMessage({
command: "addNewVaultItemFromOverlay",
addNewCipherType: CipherType.Login,
});
await flushPromises();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayAddNewVaultItem", {
addNewCipherType: CipherType.Login,
login: {
username: "test-username",
password: "test-password",
@@ -1113,6 +1382,30 @@ describe("AutofillOverlayContentService", () => {
},
});
});
it("sends a message that facilitates adding a card cipher vault item", async () => {
jest
.spyOn(autofillOverlayContentService as any, "isInlineMenuListVisible")
.mockResolvedValue(true);
sendMockExtensionMessage({
command: "addNewVaultItemFromOverlay",
addNewCipherType: CipherType.Card,
});
await flushPromises();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("autofillOverlayAddNewVaultItem", {
addNewCipherType: CipherType.Card,
card: {
cardholderName: "",
cvv: "",
expirationDate: "",
expirationMonth: "",
expirationYear: "",
number: "",
},
});
});
});
describe("unsetMostRecentlyFocusedField message handler", () => {
@@ -1138,6 +1431,7 @@ describe("AutofillOverlayContentService", () => {
autofillOverlayContentService["focusedFieldData"] = {
focusedFieldStyles: { paddingRight: "10", paddingLeft: "10" },
focusedFieldRects: { width: 10, height: 10, top: 10, left: 10 },
filledByCipherType: CipherType.Login,
};
});
@@ -1587,7 +1881,7 @@ describe("AutofillOverlayContentService", () => {
});
it("skips setup when no form fields exist on the current frame", async () => {
autofillOverlayContentService["formFieldElements"] = new Set();
autofillOverlayContentService["formFieldElements"] = new Map();
sendMockExtensionMessage({ command: "setupRebuildSubFrameOffsetsListeners" });
await flushPromises();
@@ -1604,7 +1898,10 @@ describe("AutofillOverlayContentService", () => {
});
it("sets up the sub frame rebuild listeners when the sub frame contains fields", async () => {
autofillOverlayContentService["formFieldElements"].add(autofillFieldElement);
autofillOverlayContentService["formFieldElements"].set(
autofillFieldElement,
createAutofillFieldMock(),
);
sendMockExtensionMessage({ command: "setupRebuildSubFrameOffsetsListeners" });
await flushPromises();
@@ -1621,7 +1918,10 @@ describe("AutofillOverlayContentService", () => {
describe("triggering the sub frame listener", () => {
beforeEach(async () => {
autofillOverlayContentService["formFieldElements"].add(autofillFieldElement);
autofillOverlayContentService["formFieldElements"].set(
autofillFieldElement,
createAutofillFieldMock(),
);
await sendMockExtensionMessage({ command: "setupRebuildSubFrameOffsetsListeners" });
});
@@ -1672,7 +1972,7 @@ describe("AutofillOverlayContentService", () => {
opid: "password-field",
form: "validFormId",
elementNumber: 2,
autocompleteType: "current-password",
autoCompleteType: "current-password",
type: "password",
});
pageDetailsMock = mock<AutofillPageDetails>({

View File

@@ -8,6 +8,7 @@ import {
AutofillOverlayVisibility,
AUTOFILL_OVERLAY_HANDLE_REPOSITION,
} from "@bitwarden/common/autofill/constants";
import { CipherType } from "@bitwarden/common/vault/enums";
import {
FocusedFieldData,
@@ -24,6 +25,7 @@ import AutofillPageDetails from "../models/autofill-page-details";
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
import {
elementIsFillableFormField,
elementIsSelectElement,
getAttributeBoolean,
sendExtensionMessage,
throttle,
@@ -43,7 +45,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
inlineMenuVisibility: number;
private readonly findTabs = tabbable;
private readonly sendExtensionMessage = sendExtensionMessage;
private formFieldElements: Set<ElementWithOpId<FormFieldElement>> = new Set([]);
private formFieldElements: Map<ElementWithOpId<FormFieldElement>, AutofillField> = new Map();
private hiddenFormFieldElements: WeakMap<ElementWithOpId<FormFieldElement>, AutofillField> =
new WeakMap();
private ignoredFieldTypes: Set<string> = new Set(AutoFillConstants.ExcludedInlineMenuTypes);
@@ -57,7 +59,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
private eventHandlersMemo: { [key: string]: EventListener } = {};
private readonly extensionMessageHandlers: AutofillOverlayContentExtensionMessageHandlers = {
openAutofillInlineMenu: ({ message }) => this.openInlineMenu(message),
addNewVaultItemFromOverlay: () => this.addNewVaultItem(),
addNewVaultItemFromOverlay: ({ message }) => this.addNewVaultItem(message),
blurMostRecentlyFocusedField: () => this.blurMostRecentlyFocusedField(),
unsetMostRecentlyFocusedField: () => this.unsetMostRecentlyFocusedField(),
checkIsMostRecentlyFocusedFieldWithinViewport: () =>
@@ -122,7 +124,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
return;
}
await this.setupInlineMenuOnQualifiedField(formFieldElement);
await this.setupInlineMenuOnQualifiedField(formFieldElement, autofillFieldData);
}
/**
@@ -194,19 +196,38 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* Formats any found user filled fields for a login cipher and sends a message
* to the background script to add a new cipher.
*/
async addNewVaultItem() {
async addNewVaultItem({ addNewCipherType }: AutofillExtensionMessage) {
if (!(await this.isInlineMenuListVisible())) {
return;
}
const login = {
username: this.userFilledFields["username"]?.value || "",
password: this.userFilledFields["password"]?.value || "",
uri: globalThis.document.URL,
hostname: globalThis.document.location.hostname,
};
const command = "autofillOverlayAddNewVaultItem";
void this.sendExtensionMessage("autofillOverlayAddNewVaultItem", { login });
if (addNewCipherType === CipherType.Login) {
const login = {
username: this.userFilledFields["username"]?.value || "",
password: this.userFilledFields["password"]?.value || "",
uri: globalThis.document.URL,
hostname: globalThis.document.location.hostname,
};
void this.sendExtensionMessage(command, { addNewCipherType, login });
return;
}
if (addNewCipherType === CipherType.Card) {
const card = {
cardholderName: this.userFilledFields["cardholderName"]?.value || "",
number: this.userFilledFields["cardNumber"]?.value || "",
expirationMonth: this.userFilledFields["cardExpirationMonth"]?.value || "",
expirationYear: this.userFilledFields["cardExpirationYear"]?.value || "",
expirationDate: this.userFilledFields["cardExpirationDate"]?.value || "",
cvv: this.userFilledFields["cardCvv"]?.value || "",
};
void this.sendExtensionMessage(command, { addNewCipherType, card });
}
}
/**
@@ -253,20 +274,25 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
private setupFormFieldElementEventListeners(formFieldElement: ElementWithOpId<FormFieldElement>) {
this.removeCachedFormFieldEventListeners(formFieldElement);
formFieldElement.addEventListener(EVENTS.BLUR, this.handleFormFieldBlurEvent);
formFieldElement.addEventListener(EVENTS.KEYUP, this.handleFormFieldKeyupEvent);
formFieldElement.addEventListener(
EVENTS.INPUT,
this.handleFormFieldInputEvent(formFieldElement),
);
formFieldElement.addEventListener(
EVENTS.CLICK,
this.handleFormFieldClickEvent(formFieldElement),
);
formFieldElement.addEventListener(
EVENTS.FOCUS,
this.handleFormFieldFocusEvent(formFieldElement),
);
if (elementIsSelectElement(formFieldElement)) {
return;
}
formFieldElement.addEventListener(EVENTS.BLUR, this.handleFormFieldBlurEvent);
formFieldElement.addEventListener(EVENTS.KEYUP, this.handleFormFieldKeyupEvent);
formFieldElement.addEventListener(
EVENTS.CLICK,
this.handleFormFieldClickEvent(formFieldElement),
);
}
/**
@@ -400,6 +426,9 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
}
this.storeModifiedFormElement(formFieldElement);
if (elementIsSelectElement(formFieldElement)) {
return;
}
if (await this.hideInlineMenuListOnFilledField(formFieldElement)) {
void this.sendExtensionMessage("closeAutofillInlineMenu", {
@@ -425,6 +454,26 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
void this.updateMostRecentlyFocusedField(formFieldElement);
}
const autofillFieldData = this.formFieldElements.get(formFieldElement);
if (!autofillFieldData) {
return;
}
if (autofillFieldData.filledByCipherType === CipherType.Login) {
this.storeUserFilledLoginField(formFieldElement);
}
if (autofillFieldData.filledByCipherType === CipherType.Card) {
this.storeUserFilledCardField(formFieldElement, autofillFieldData);
}
}
/**
* Handles storing the user field login field to be used when adding a new vault item.
*
* @param formFieldElement - The form field element that triggered the input event.
*/
private storeUserFilledLoginField(formFieldElement: ElementWithOpId<FillableFormFieldElement>) {
if (formFieldElement.type === "password") {
this.userFilledFields.password = formFieldElement;
return;
@@ -433,6 +482,46 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
this.userFilledFields.username = formFieldElement;
}
/**
* Handles storing the user field card field to be used when adding a new vault item.
*
* @param formFieldElement - The form field element that triggered the input event.
* @param autofillFieldData - Autofill field data captured from the form field element.
*/
private storeUserFilledCardField(
formFieldElement: ElementWithOpId<FillableFormFieldElement>,
autofillFieldData: AutofillField,
) {
if (this.inlineMenuFieldQualificationService.isFieldForCardholderName(autofillFieldData)) {
this.userFilledFields.cardholderName = formFieldElement;
return;
}
if (this.inlineMenuFieldQualificationService.isFieldForCardNumber(autofillFieldData)) {
this.userFilledFields.cardNumber = formFieldElement;
return;
}
if (this.inlineMenuFieldQualificationService.isFieldForCardExpirationMonth(autofillFieldData)) {
this.userFilledFields.cardExpirationMonth = formFieldElement;
return;
}
if (this.inlineMenuFieldQualificationService.isFieldForCardExpirationYear(autofillFieldData)) {
this.userFilledFields.cardExpirationYear = formFieldElement;
return;
}
if (this.inlineMenuFieldQualificationService.isFieldForCardExpirationDate(autofillFieldData)) {
this.userFilledFields.cardExpirationDate = formFieldElement;
return;
}
if (this.inlineMenuFieldQualificationService.isFieldForCardCvv(autofillFieldData)) {
this.userFilledFields.cardCvv = formFieldElement;
}
}
/**
* Sets up and memoizes the form field click event handler.
*
@@ -483,16 +572,23 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
return;
}
if (elementIsSelectElement(formFieldElement)) {
await this.sendExtensionMessage("closeAutofillInlineMenu");
return;
}
await this.sendExtensionMessage("updateIsFieldCurrentlyFocused", {
isFieldCurrentlyFocused: true,
});
const initiallyFocusedField = this.mostRecentlyFocusedField;
await this.updateMostRecentlyFocusedField(formFieldElement);
const hideInlineMenuListOnFilledField = await this.hideInlineMenuListOnFilledField(
formFieldElement as FillableFormFieldElement,
);
if (
this.inlineMenuVisibility === AutofillOverlayVisibility.OnButtonClick ||
(initiallyFocusedField !== this.mostRecentlyFocusedField &&
(await this.hideInlineMenuListOnFilledField(formFieldElement as FillableFormFieldElement)))
(initiallyFocusedField !== this.mostRecentlyFocusedField && hideInlineMenuListOnFilledField)
) {
await this.sendExtensionMessage("closeAutofillInlineMenu", {
overlayElement: AutofillOverlayElement.List,
@@ -500,7 +596,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
});
}
if (await this.hideInlineMenuListOnFilledField(formFieldElement as FillableFormFieldElement)) {
if (hideInlineMenuListOnFilledField) {
this.updateInlineMenuButtonPosition();
return;
}
@@ -568,9 +664,11 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
const { paddingRight, paddingLeft } = globalThis.getComputedStyle(formFieldElement);
const { width, height, top, left } =
await this.getMostRecentlyFocusedFieldRects(formFieldElement);
const autofillFieldData = this.formFieldElements.get(formFieldElement);
this.focusedFieldData = {
focusedFieldStyles: { paddingRight, paddingLeft },
focusedFieldRects: { width, height, top, left },
filledByCipherType: autofillFieldData?.filledByCipherType,
};
await this.sendExtensionMessage("updateFocusedFieldData", {
@@ -648,10 +746,24 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
return true;
}
return !this.inlineMenuFieldQualificationService.isFieldForLoginForm(
autofillFieldData,
pageDetails,
);
if (
this.inlineMenuFieldQualificationService.isFieldForLoginForm(autofillFieldData, pageDetails)
) {
autofillFieldData.filledByCipherType = CipherType.Login;
return false;
}
if (
this.inlineMenuFieldQualificationService.isFieldForCreditCardForm(
autofillFieldData,
pageDetails,
)
) {
autofillFieldData.filledByCipherType = CipherType.Card;
return false;
}
return true;
}
/**
@@ -714,7 +826,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
autofillFieldData.readonly = getAttributeBoolean(formFieldElement, "disabled");
autofillFieldData.disabled = getAttributeBoolean(formFieldElement, "disabled");
autofillFieldData.viewable = true;
void this.setupInlineMenuOnQualifiedField(formFieldElement);
void this.setupInlineMenuOnQualifiedField(formFieldElement, autofillFieldData);
}
this.removeHiddenFieldFallbackListener(formFieldElement);
@@ -724,11 +836,13 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* Sets up the inline menu on a qualified form field element.
*
* @param formFieldElement - The form field element to set up the inline menu on.
* @param autofillFieldData - Autofill field data captured from the form field element.
*/
private async setupInlineMenuOnQualifiedField(
formFieldElement: ElementWithOpId<FormFieldElement>,
autofillFieldData: AutofillField,
) {
this.formFieldElements.add(formFieldElement);
this.formFieldElements.set(formFieldElement, autofillFieldData);
if (!this.mostRecentlyFocusedField) {
await this.updateMostRecentlyFocusedField(formFieldElement);
@@ -1198,7 +1312,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
destroy() {
this.clearFocusInlineMenuListTimeout();
this.clearCloseInlineMenuOnRedirectTimeout();
this.formFieldElements.forEach((formFieldElement) => {
this.formFieldElements.forEach((_autofillField, formFieldElement) => {
this.removeCachedFormFieldEventListeners(formFieldElement);
formFieldElement.removeEventListener(EVENTS.BLUR, this.handleFormFieldBlurEvent);
formFieldElement.removeEventListener(EVENTS.KEYUP, this.handleFormFieldKeyupEvent);

View File

@@ -713,4 +713,155 @@ describe("InlineMenuFieldQualificationService", () => {
});
});
});
describe("isFieldForCreditCardForm", () => {
describe("an invalid credit card field", () => {
it("has reference to a `new field` keyword", () => {
const field = mock<AutofillField>({
placeholder: "new credit card",
});
expect(
inlineMenuFieldQualificationService.isFieldForCreditCardForm(field, pageDetails),
).toBe(false);
});
describe("does not have a parent form", () => {
it("has no credit card number fields in the page details", () => {
const field = mock<AutofillField>({
placeholder: "name",
});
const secondField = mock<AutofillField>({
placeholder: "card cvv",
autoCompleteType: "cc-csc",
});
pageDetails.forms = {};
pageDetails.fields = [field, secondField];
expect(
inlineMenuFieldQualificationService.isFieldForCreditCardForm(field, pageDetails),
).toBe(false);
});
it("has no credit card cvv fields in the page details", () => {
const field = mock<AutofillField>({
placeholder: "name",
});
const secondField = mock<AutofillField>({
placeholder: "card number",
autoCompleteType: "cc-number",
});
pageDetails.forms = {};
pageDetails.fields = [field, secondField];
expect(
inlineMenuFieldQualificationService.isFieldForCreditCardForm(field, pageDetails),
).toBe(false);
});
});
describe("has a parent form", () => {
let form: MockProxy<AutofillForm>;
beforeEach(() => {
form = mock<AutofillForm>({ opid: "validFormId" });
pageDetails.forms = {
validFormId: form,
};
});
it("does not have a credit card number field within the same form", () => {
const field = mock<AutofillField>({
placeholder: "name",
form: "validFormId",
});
const cardCvvField = mock<AutofillField>({
placeholder: "card cvv",
autoCompleteType: "cc-csc",
form: "validFormId",
});
pageDetails.fields = [field, cardCvvField];
expect(
inlineMenuFieldQualificationService.isFieldForCreditCardForm(field, pageDetails),
).toBe(false);
});
it("does not contain a cvv field within the same form", () => {
const field = mock<AutofillField>({
placeholder: "name",
form: "validFormId",
});
const cardNumberField = mock<AutofillField>({
placeholder: "card number",
autoCompleteType: "cc-number",
form: "validFormId",
});
pageDetails.fields = [field, cardNumberField];
expect(
inlineMenuFieldQualificationService.isFieldForCreditCardForm(field, pageDetails),
).toBe(false);
});
});
});
describe("a valid credit card field", () => {
describe("does not have a parent form", () => {
it("is structured on a page with a single credit card number field and a single cvv field", () => {
const field = mock<AutofillField>({
placeholder: "name",
});
const cardNumberField = mock<AutofillField>({
placeholder: "card number",
autoCompleteType: "cc-number",
});
const cardCvvField = mock<AutofillField>({
placeholder: "card cvv",
autoCompleteType: "cc-csc",
});
pageDetails.forms = {};
pageDetails.fields = [field, cardNumberField, cardCvvField];
expect(
inlineMenuFieldQualificationService.isFieldForCreditCardForm(field, pageDetails),
).toBe(true);
});
});
describe("has a parent form", () => {
let form: MockProxy<AutofillForm>;
beforeEach(() => {
form = mock<AutofillForm>({ opid: "validFormId" });
pageDetails.forms = {
validFormId: form,
};
});
it("has a credit card number field and cvv field structured within the same form", () => {
const field = mock<AutofillField>({
placeholder: "name",
form: "validFormId",
});
const cardNumberField = mock<AutofillField>({
placeholder: "card number",
autoCompleteType: "cc-number",
form: "validFormId",
});
const cardCvvField = mock<AutofillField>({
placeholder: "card cvv",
autoCompleteType: "cc-csc",
form: "validFormId",
});
pageDetails.fields = [field, cardNumberField, cardCvvField];
expect(
inlineMenuFieldQualificationService.isFieldForCreditCardForm(field, pageDetails),
).toBe(true);
});
});
});
});
});

View File

@@ -2,8 +2,11 @@ import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
import { sendExtensionMessage } from "../utils";
import { InlineMenuFieldQualificationService as InlineMenuFieldQualificationServiceInterface } from "./abstractions/inline-menu-field-qualifications.service";
import { AutoFillConstants } from "./autofill-constants";
import {
AutofillKeywordsMap,
InlineMenuFieldQualificationService as InlineMenuFieldQualificationServiceInterface,
} from "./abstractions/inline-menu-field-qualifications.service";
import { AutoFillConstants, CreditCardAutoFillConstants } from "./autofill-constants";
export class InlineMenuFieldQualificationService
implements InlineMenuFieldQualificationServiceInterface
@@ -14,9 +17,13 @@ export class InlineMenuFieldQualificationService
private usernameAutocompleteValues = new Set(["username", "email"]);
private fieldIgnoreListString = AutoFillConstants.FieldIgnoreList.join(",");
private passwordFieldExcludeListString = AutoFillConstants.PasswordFieldExcludeList.join(",");
private currentPasswordAutocompleteValues = new Set(["current-password"]);
private newPasswordAutoCompleteValues = new Set(["new-password"]);
private autofillFieldKeywordsMap: WeakMap<AutofillField, string> = new WeakMap();
private currentPasswordAutocompleteValue = "current-password";
private newPasswordAutoCompleteValue = "new-password";
private passwordAutoCompleteValues = new Set([
this.currentPasswordAutocompleteValue,
this.newPasswordAutoCompleteValue,
]);
private autofillFieldKeywordsMap: AutofillKeywordsMap = new WeakMap();
private autocompleteDisabledValues = new Set(["off", "false"]);
private newFieldKeywords = new Set(["new", "change", "neue", "ändern"]);
private accountCreationFieldKeywords = new Set([
@@ -26,6 +33,36 @@ export class InlineMenuFieldQualificationService
"confirm",
...this.newFieldKeywords,
]);
private creditCardFieldKeywords = new Set([
...CreditCardAutoFillConstants.CardHolderFieldNames,
...CreditCardAutoFillConstants.CardNumberFieldNames,
...CreditCardAutoFillConstants.CardExpiryFieldNames,
...CreditCardAutoFillConstants.ExpiryMonthFieldNames,
...CreditCardAutoFillConstants.ExpiryYearFieldNames,
...CreditCardAutoFillConstants.CVVFieldNames,
...CreditCardAutoFillConstants.CardBrandFieldNames,
]);
private creditCardNameAutocompleteValues = new Set([
"cc-name",
"cc-given-name,",
"cc-additional-name",
"cc-family-name",
]);
private creditCardExpirationDateAutocompleteValue = "cc-exp";
private creditCardExpirationMonthAutocompleteValue = "cc-exp-month";
private creditCardExpirationYearAutocompleteValue = "cc-exp-year";
private creditCardCvvAutocompleteValue = "cc-csc";
private creditCardNumberAutocompleteValue = "cc-number";
private creditCardTypeAutocompleteValue = "cc-type";
private creditCardAutocompleteValues = new Set([
...this.creditCardNameAutocompleteValues,
this.creditCardExpirationDateAutocompleteValue,
this.creditCardExpirationMonthAutocompleteValue,
this.creditCardExpirationYearAutocompleteValue,
this.creditCardNumberAutocompleteValue,
this.creditCardCvvAutocompleteValue,
this.creditCardTypeAutocompleteValue,
]);
private inlineMenuFieldQualificationFlagSet = false;
constructor() {
@@ -59,6 +96,72 @@ export class InlineMenuFieldQualificationService
return this.isUsernameFieldForLoginForm(field, pageDetails);
}
/**
* Validates the provided field as a field for a credit card form.
*
* @param field - The field to validate
* @param pageDetails - The details of the page that the field is on.
*/
isFieldForCreditCardForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean {
// If the field contains any of the standardized autocomplete attribute values
// for credit card fields, we should assume that the field is part of a credit card form.
if (this.fieldContainsAutocompleteValues(field, this.creditCardAutocompleteValues)) {
return true;
}
// If the field contains any keywords indicating this is for a "new" or "changed" credit card
// field, we should assume that the field is not going to be autofilled.
if (this.keywordsFoundInFieldData(field, [...this.newFieldKeywords])) {
return false;
}
const parentForm = pageDetails.forms[field.form];
// If the field does not have a parent form
if (!parentForm) {
// If a credit card number field is not present on the page or there are multiple credit
// card number fields, this field is not part of a credit card form.
const numberFieldsInPageDetails = pageDetails.fields.filter(this.isFieldForCardNumber);
if (numberFieldsInPageDetails.length !== 1) {
return false;
}
// If a credit card CVV field is not present on the page or there are multiple credit card
// CVV fields, this field is not part of a credit card form.
const cvvFieldsInPageDetails = pageDetails.fields.filter(this.isFieldForCardCvv);
if (cvvFieldsInPageDetails.length !== 1) {
return false;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, [...this.creditCardFieldKeywords])
);
}
// If the field has a parent form, check the fields from that form exclusively
const fieldsFromSameForm = pageDetails.fields.filter((f) => f.form === field.form);
// If a credit card number field is not present on the page or there are multiple credit
// card number fields, this field is not part of a credit card form.
const numberFieldsInPageDetails = fieldsFromSameForm.filter(this.isFieldForCardNumber);
if (numberFieldsInPageDetails.length !== 1) {
return false;
}
// If a credit card CVV field is not present on the page or there are multiple credit card
// CVV fields, this field is not part of a credit card form.
const cvvFieldsInPageDetails = fieldsFromSameForm.filter(this.isFieldForCardCvv);
if (cvvFieldsInPageDetails.length !== 1) {
return false;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, [...this.creditCardFieldKeywords])
);
}
/**
* Validates the provided field as a password field for a login form.
*
@@ -71,12 +174,7 @@ export class InlineMenuFieldQualificationService
): boolean {
// If the provided field is set with an autocomplete value of "current-password", we should assume that
// the page developer intends for this field to be interpreted as a password field for a login form.
if (
this.fieldContainsAutocompleteValues(
field.autoCompleteType,
this.currentPasswordAutocompleteValues,
)
) {
if (this.fieldContainsAutocompleteValues(field, this.currentPasswordAutocompleteValue)) {
return true;
}
@@ -110,10 +208,7 @@ export class InlineMenuFieldQualificationService
// provided field is for a login form. This will only be the case if the field does not
// explicitly have its autocomplete attribute set to "off" or "false".
return !this.fieldContainsAutocompleteValues(
field.autoCompleteType,
this.autocompleteDisabledValues,
);
return !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues);
}
// If the field has a form parent and there are multiple visible password fields
@@ -135,10 +230,7 @@ export class InlineMenuFieldQualificationService
// If the field has a form parent and no username field exists and the field has an
// autocomplete attribute set to "off" or "false", this is not a password field
return !this.fieldContainsAutocompleteValues(
field.autoCompleteType,
this.autocompleteDisabledValues,
);
return !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues);
}
/**
@@ -153,9 +245,7 @@ export class InlineMenuFieldQualificationService
): boolean {
// If the provided field is set with an autocomplete of "username", we should assume that
// the page developer intends for this field to be interpreted as a username field.
if (
this.fieldContainsAutocompleteValues(field.autoCompleteType, this.usernameAutocompleteValues)
) {
if (this.fieldContainsAutocompleteValues(field, this.usernameAutocompleteValues)) {
const newPasswordFieldsInPageDetails = pageDetails.fields.filter(this.isNewPasswordField);
return newPasswordFieldsInPageDetails.length === 0;
}
@@ -198,10 +288,7 @@ export class InlineMenuFieldQualificationService
// If the page does not contain any password fields, it might be part of a multistep login form.
// That will only be the case if the field does not explicitly have its autocomplete attribute
// set to "off" or "false".
return !this.fieldContainsAutocompleteValues(
field.autoCompleteType,
this.autocompleteDisabledValues,
);
return !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues);
}
// If the field is structured within a form, but no password fields are present in the form,
@@ -209,12 +296,7 @@ export class InlineMenuFieldQualificationService
if (passwordFieldsInPageDetails.length === 0) {
// If the field's autocomplete is set to a disabled value, we should assume that the field is
// not part of a login form.
if (
this.fieldContainsAutocompleteValues(
field.autoCompleteType,
this.autocompleteDisabledValues,
)
) {
if (this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues)) {
return false;
}
@@ -243,12 +325,111 @@ export class InlineMenuFieldQualificationService
// If no visible password fields are found, this field might be part of a multipart form.
// Check for an invalid autocompleteType to determine if the field is part of a login form.
return !this.fieldContainsAutocompleteValues(
field.autoCompleteType,
this.autocompleteDisabledValues,
);
return !this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues);
}
/**
* Validates the provided field as a field for a credit card name field.
*
* @param field - The field to validate
*/
isFieldForCardholderName = (field: AutofillField): boolean => {
if (this.fieldContainsAutocompleteValues(field, this.creditCardNameAutocompleteValues)) {
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardHolderFieldNames, false)
);
};
/**
* Validates the provided field as a field for a credit card number field.
*
* @param field - The field to validate
*/
isFieldForCardNumber = (field: AutofillField): boolean => {
if (this.fieldContainsAutocompleteValues(field, this.creditCardNumberAutocompleteValue)) {
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardNumberFieldNames, false)
);
};
/**
* Validates the provided field as a field for a credit card expiration date field.
*
* @param field - The field to validate
*/
isFieldForCardExpirationDate = (field: AutofillField): boolean => {
if (
this.fieldContainsAutocompleteValues(field, this.creditCardExpirationDateAutocompleteValue)
) {
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardExpiryFieldNames, false)
);
};
/**
* Validates the provided field as a field for a credit card expiration month field.
*
* @param field - The field to validate
*/
isFieldForCardExpirationMonth = (field: AutofillField): boolean => {
if (
this.fieldContainsAutocompleteValues(field, this.creditCardExpirationMonthAutocompleteValue)
) {
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.ExpiryMonthFieldNames, false)
);
};
/**
* Validates the provided field as a field for a credit card expiration year field.
*
* @param field - The field to validate
*/
isFieldForCardExpirationYear = (field: AutofillField): boolean => {
if (
this.fieldContainsAutocompleteValues(field, this.creditCardExpirationYearAutocompleteValue)
) {
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.ExpiryYearFieldNames, false)
);
};
/**
* Validates the provided field as a field for a credit card CVV field.
*
* @param field - The field to validate
*/
isFieldForCardCvv = (field: AutofillField): boolean => {
if (this.fieldContainsAutocompleteValues(field, this.creditCardCvvAutocompleteValue)) {
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CVVFieldNames, false)
);
};
/**
* Validates the provided field as a username field.
*
@@ -272,10 +453,7 @@ export class InlineMenuFieldQualificationService
*/
private isCurrentPasswordField = (field: AutofillField): boolean => {
if (
this.fieldContainsAutocompleteValues(
field.autoCompleteType,
this.newPasswordAutoCompleteValues,
) ||
this.fieldContainsAutocompleteValues(field, this.newPasswordAutoCompleteValue) ||
this.keywordsFoundInFieldData(field, [...this.accountCreationFieldKeywords])
) {
return false;
@@ -290,12 +468,7 @@ export class InlineMenuFieldQualificationService
* @param field - The field to validate
*/
private isNewPasswordField = (field: AutofillField): boolean => {
if (
this.fieldContainsAutocompleteValues(
field.autoCompleteType,
this.currentPasswordAutocompleteValues,
)
) {
if (this.fieldContainsAutocompleteValues(field, this.currentPasswordAutocompleteValue)) {
return false;
}
@@ -432,60 +605,77 @@ export class InlineMenuFieldQualificationService
*
* @param autofillFieldData - The field data to search for keywords
* @param keywords - The keywords to search for
* @param fuzzyMatchKeywords - Indicates if the keywords should be matched in a fuzzy manner
*/
private keywordsFoundInFieldData(autofillFieldData: AutofillField, keywords: string[]) {
const searchedString = this.getAutofillFieldDataKeywords(autofillFieldData);
return keywords.some((keyword) => searchedString.includes(keyword));
private keywordsFoundInFieldData(
autofillFieldData: AutofillField,
keywords: string[],
fuzzyMatchKeywords = true,
) {
const searchedValues = this.getAutofillFieldDataKeywords(autofillFieldData, fuzzyMatchKeywords);
if (typeof searchedValues === "string") {
return keywords.some((keyword) => searchedValues.indexOf(keyword) > -1);
}
return keywords.some((keyword) => searchedValues.has(keyword));
}
/**
* Retrieves the keywords from the provided autofill field data.
*
* @param autofillFieldData - The field data to search for keywords
* @param returnStringValue - Indicates if the method should return a string value
*/
private getAutofillFieldDataKeywords(autofillFieldData: AutofillField) {
if (this.autofillFieldKeywordsMap.has(autofillFieldData)) {
return this.autofillFieldKeywordsMap.get(autofillFieldData);
private getAutofillFieldDataKeywords(
autofillFieldData: AutofillField,
returnStringValue: boolean,
) {
if (!this.autofillFieldKeywordsMap.has(autofillFieldData)) {
const keywords = [
autofillFieldData.htmlID,
autofillFieldData.htmlName,
autofillFieldData.htmlClass,
autofillFieldData.type,
autofillFieldData.title,
autofillFieldData.placeholder,
autofillFieldData.autoCompleteType,
autofillFieldData["label-data"],
autofillFieldData["label-aria"],
autofillFieldData["label-left"],
autofillFieldData["label-right"],
autofillFieldData["label-tag"],
autofillFieldData["label-top"],
];
const keywordsSet = new Set<string>(keywords);
const stringValue = keywords.join(",").toLowerCase();
this.autofillFieldKeywordsMap.set(autofillFieldData, { keywordsSet, stringValue });
}
const keywordValues = [
autofillFieldData.htmlID,
autofillFieldData.htmlName,
autofillFieldData.htmlClass,
autofillFieldData.type,
autofillFieldData.title,
autofillFieldData.placeholder,
autofillFieldData.autoCompleteType,
autofillFieldData["label-data"],
autofillFieldData["label-aria"],
autofillFieldData["label-left"],
autofillFieldData["label-right"],
autofillFieldData["label-tag"],
autofillFieldData["label-top"],
]
.join(",")
.toLowerCase();
this.autofillFieldKeywordsMap.set(autofillFieldData, keywordValues);
return keywordValues;
const mapValues = this.autofillFieldKeywordsMap.get(autofillFieldData);
return returnStringValue ? mapValues.stringValue : mapValues.keywordsSet;
}
/**
* Separates the provided field data into space-separated values and checks if any
* of the values are present in the provided set of autocomplete values.
*
* @param fieldAutocompleteValue - The field autocomplete value to validate
* @param autofillFieldData - The field autocomplete value to validate
* @param compareValues - The set of autocomplete values to check against
*/
private fieldContainsAutocompleteValues(
fieldAutocompleteValue: string,
compareValues: Set<string>,
autofillFieldData: AutofillField,
compareValues: string | Set<string>,
) {
if (!fieldAutocompleteValue) {
const fieldAutocompleteValue = autofillFieldData.autoCompleteType;
if (!fieldAutocompleteValue || typeof fieldAutocompleteValue !== "string") {
return false;
}
const autocompleteValueParts = fieldAutocompleteValue.split(" ");
if (typeof compareValues === "string") {
return autocompleteValueParts.indexOf(compareValues) > -1;
}
for (let index = 0; index < autocompleteValueParts.length; index++) {
if (compareValues.has(autocompleteValueParts[index])) {
return true;

View File

@@ -208,6 +208,7 @@ export function createInitAutofillInlineMenuListMessageMock(
theme: ThemeType.Light,
authStatus: AuthenticationStatus.Unlocked,
portKey: "portKey",
filledByCipherType: CipherType.Login,
ciphers: [
createAutofillOverlayCipherDataMock(1, {
icon: {
@@ -254,6 +255,7 @@ export function createFocusedFieldDataMock(customFields = {}) {
paddingRight: "6px",
paddingLeft: "6px",
},
filledByCipherType: CipherType.Login,
tabId: 1,
frameId: 2,
...customFields,

View File

@@ -1,5 +1,11 @@
<header
class="tw-p-4 tw-border-0 tw-border-solid tw-border-b tw-border-secondary-300 tw-bg-background"
class="tw-p-4 tw-transition-colors tw-duration-200 tw-border-0 tw-border-b tw-border-solid"
[ngClass]="{
'tw-bg-background-alt tw-border-transparent':
this.background === 'alt' && !pageContentScrolled(),
'tw-bg-background tw-border-secondary-300':
(this.background === 'alt' && pageContentScrolled()) || this.background === 'default'
}"
>
<div class="tw-max-w-screen-sm tw-mx-auto tw-flex tw-justify-between tw-w-full">
<div class="tw-inline-flex tw-items-center tw-gap-2 tw-h-9">
@@ -11,7 +17,10 @@
[ariaLabel]="'back' | i18n"
[bitAction]="backAction"
></button>
<h1 bitTypography="h3" class="!tw-mb-0.5 tw-text-headers">{{ pageTitle }}</h1>
<h1 *ngIf="pageTitle" bitTypography="h3" class="!tw-mb-0.5 tw-text-headers">
{{ pageTitle }}
</h1>
<ng-content></ng-content>
</div>
<div class="tw-inline-flex tw-items-center tw-gap-2 tw-h-9">
<ng-content select="[slot=end]"></ng-content>

View File

@@ -1,6 +1,6 @@
import { BooleanInput, coerceBooleanProperty } from "@angular/cdk/coercion";
import { CommonModule, Location } from "@angular/common";
import { Component, Input } from "@angular/core";
import { Component, Input, Signal, inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -10,6 +10,8 @@ import {
TypographyModule,
} from "@bitwarden/components";
import { PopupPageComponent } from "./popup-page.component";
@Component({
selector: "popup-header",
templateUrl: "popup-header.component.html",
@@ -17,6 +19,12 @@ import {
imports: [TypographyModule, CommonModule, IconButtonModule, JslibModule, AsyncActionsModule],
})
export class PopupHeaderComponent {
protected pageContentScrolled: Signal<boolean> = inject(PopupPageComponent).isScrolled;
/** Background color */
@Input()
background: "default" | "alt" = "default";
/** Display the back button, which uses Location.back() to go back one page in history */
@Input()
get showBackButton() {

View File

@@ -74,6 +74,9 @@ Basic usage example:
- `showBackButton`: optional, defaults to `false`
- Toggles the back button to appear. The back button uses `Location.back()` to navigate back one
page in history.
- `background`: optional
- `"default"` uses a white background
- `"alt"` uses a transparent background
**Slots**
@@ -92,6 +95,12 @@ Usage example:
</popup-header>
```
### Transparent header
<Canvas>
<Story of={stories.TransparentHeader} />
</Canvas>
Common interactive elements to insert into the `end` slot are:
- `app-current-account`: shows current account and switcher

View File

@@ -303,6 +303,7 @@ export default {
MockSettingsPageComponent,
MockVaultPagePoppedComponent,
NoItemsModule,
VaultComponent,
],
providers: [
{
@@ -311,6 +312,7 @@ export default {
return new I18nMockService({
back: "Back",
loading: "Loading",
search: "Search",
});
},
},
@@ -421,3 +423,20 @@ export const Loading: Story = {
`,
}),
};
export const TransparentHeader: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
<extension-container>
<popup-page>
<popup-header slot="header" background="alt"
><span class="tw-italic tw-text-main">🤠 Custom Content</span></popup-header
>
<vault-placeholder></vault-placeholder>
</popup-page>
</extension-container>
`,
}),
};

View File

@@ -1,20 +1,21 @@
<ng-content select="[slot=header]"></ng-content>
<main class="tw-flex-1 tw-overflow-hidden tw-flex tw-flex-col tw-relative tw-bg-background-alt">
<div #nonScrollable [ngClass]="{ 'tw-invisible': loading }">
<div
#nonScrollable
class="tw-transition-colors tw-duration-200 tw-border-0 tw-border-b tw-border-solid"
[ngClass]="{
'tw-invisible': loading || nonScrollable.childElementCount === 0,
'tw-border-secondary-300': scrolled(),
'tw-border-transparent': !scrolled()
}"
>
<ng-content select="[slot=above-scroll-area]"></ng-content>
</div>
<div
class="tw-max-w-screen-sm tw-mx-auto tw-overflow-y-auto tw-flex tw-flex-col tw-w-full tw-h-full"
(scroll)="handleScroll($event)"
[ngClass]="{ 'tw-invisible': loading }"
>
<!-- Only shown when the `slot=above-scroll-area` is populated -->
<!-- The first div will "stick" and show a bottom border when content is scrolled -->
<!-- The second div is displayed on top of the first to hide it until the content is scrolled -->
<ng-container *ngIf="nonScrollable.children.length">
<div class="tw-sticky tw-min-h-[1px] tw-bg-secondary-300 tw-top-0"></div>
<div class="tw-relative tw-z-10 tw-min-h-[2px] tw-bg-background-alt -tw-mt-[2px]"></div>
</ng-container>
<div
class="tw-max-w-screen-sm tw-mx-auto tw-p-3 tw-flex-1 tw-flex tw-flex-col tw-h-full tw-w-full"
>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, Input, inject } from "@angular/core";
import { Component, Input, inject, signal } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -17,6 +17,13 @@ export class PopupPageComponent {
@Input() loading = false;
protected scrolled = signal(false);
isScrolled = this.scrolled.asReadonly();
/** Accessible loading label for the spinner. Defaults to "loading" */
@Input() loadingText?: string = this.i18nService.t("loading");
handleScroll(event: Event) {
this.scrolled.set((event.currentTarget as HTMLElement).scrollTop !== 0);
}
}

View File

@@ -16,6 +16,7 @@ import {
RegistrationStartComponent,
RegistrationStartSecondaryComponent,
RegistrationStartSecondaryComponentData,
SetPasswordJitComponent,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -409,6 +410,15 @@ const routes: Routes = [
},
],
},
{
path: "set-password-jit",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],
component: SetPasswordJitComponent,
data: {
pageTitle: "joinOrganization",
pageSubtitle: "finishJoiningThisOrganizationBySettingAMasterPassword",
} satisfies AnonLayoutWrapperData,
},
],
},
{

View File

@@ -240,7 +240,7 @@
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="special">!@#$%^&*</label>
<label for="special">!&#64;#$%^&*</label>
<input
id="special"
type="checkbox"

View File

@@ -141,6 +141,7 @@ export class AddEditComponent extends BaseAddEditComponent {
}
if (
params.uri &&
this.cipher.login.uris[0] &&
(this.cipher.login.uris[0].uri == null || this.cipher.login.uris[0].uri === "")
) {
this.cipher.login.uris[0].uri = params.uri;

View File

@@ -66,7 +66,7 @@
"form-data": "4.0.0",
"https-proxy-agent": "7.0.2",
"inquirer": "8.2.6",
"jsdom": "23.0.1",
"jsdom": "24.1.1",
"jszip": "3.10.1",
"koa": "2.15.0",
"koa-bodyparser": "4.4.1",

View File

@@ -59,6 +59,156 @@ dependencies = [
"x11rb",
]
[[package]]
name = "async-broadcast"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20cd0e2e25ea8e5f7e9df04578dc6cf5c83577fd09b1a46aaf5c85e1c33f2a7e"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8828ec6e544c02b0d6691d21ed9f9218d0384a82542855073c2a3f58304aaf0"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"slab",
]
[[package]]
name = "async-fs"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d6baa8f0178795da0e71bc42c9e5d13261aac7ee549853162e66a241ba17964"
dependencies = [
"async-lock",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"tracing",
"windows-sys",
]
[[package]]
name = "async-lock"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7eda79bbd84e29c2b308d1dc099d7de8dcc7035e48f4bf5dc4a531a44ff5e2a"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
"tracing",
"windows-sys",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "async-signal"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "794f185324c2f00e771cd9f1ae8b5ac68be2ca7abb129a87afd6e86d228bc54d"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.3.0"
@@ -119,6 +269,25 @@ dependencies = [
"objc2",
]
[[package]]
name = "blocking"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "bytes"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
[[package]]
name = "cbc"
version = "0.1.2"
@@ -156,6 +325,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "cipher"
version = "0.4.4"
@@ -185,6 +360,15 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "convert_case"
version = "0.6.0"
@@ -219,6 +403,12 @@ dependencies = [
"libc",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -314,9 +504,11 @@ dependencies = [
"security-framework-sys",
"sha2",
"thiserror",
"tokio",
"typenum",
"widestring",
"windows",
"zbus",
]
[[package]]
@@ -355,6 +547,33 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "endi"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
[[package]]
name = "enumflags2"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d232db7f5956f3f14313dc2f87985c58bd2c695ce124c8cdd984e08e15ac133d"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "equivalent"
version = "1.0.1"
@@ -377,6 +596,27 @@ version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b"
[[package]]
name = "event-listener"
version = "5.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.1.0"
@@ -427,6 +667,19 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
[[package]]
name = "futures-lite"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.30"
@@ -438,6 +691,12 @@ dependencies = [
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
[[package]]
name = "futures-task"
version = "0.3.30"
@@ -451,8 +710,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
dependencies = [
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
@@ -600,6 +862,18 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "hermit-abi"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "home"
version = "0.5.9"
@@ -729,6 +1003,15 @@ version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -810,10 +1093,23 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases 0.2.1",
"libc",
"memoffset",
]
[[package]]
name = "nom"
version = "7.1.3"
@@ -830,7 +1126,7 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi",
"hermit-abi 0.3.9",
"libc",
]
@@ -948,6 +1244,16 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.0"
@@ -958,6 +1264,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "parking"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae"
[[package]]
name = "parking_lot"
version = "0.12.3"
@@ -1003,12 +1315,38 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae1d5c74c9876f070d3e8fd503d748c7d974c3e48da8f41350fa5222ef9b4391"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
name = "polling"
version = "3.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3ed00ed3fbf728b5816498ecd316d1716eecaced9c0c8d2c5a6740ca214985b"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi 0.4.0",
"pin-project-lite",
"rustix",
"tracing",
"windows-sys",
]
[[package]]
name = "ppv-lite86"
version = "0.2.17"
@@ -1214,6 +1552,17 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_repr"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_spanned"
version = "0.6.6"
@@ -1223,6 +1572,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.8"
@@ -1234,6 +1594,15 @@ dependencies = [
"digest",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
dependencies = [
"libc",
]
[[package]]
name = "slab"
version = "0.4.9"
@@ -1249,6 +1618,12 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "2.0.66"
@@ -1327,8 +1702,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
dependencies = [
"backtrace",
"bytes",
"num_cpus",
"pin-project-lite",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@@ -1376,6 +1764,37 @@ dependencies = [
"winnow 0.6.13",
]
[[package]]
name = "tracing"
version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.1.5"
@@ -1396,6 +1815,17 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
[[package]]
name = "uds_windows"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
dependencies = [
"memoffset",
"tempfile",
"winapi",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
@@ -1511,6 +1941,22 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.8"
@@ -1520,6 +1966,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.57.0"
@@ -1730,7 +2182,7 @@ dependencies = [
"derive-new",
"libc",
"log",
"nix",
"nix 0.28.0",
"os_pipe",
"tempfile",
"thiserror",
@@ -1757,3 +2209,112 @@ name = "x11rb-protocol"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
[[package]]
name = "xdg-home"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca91dcf8f93db085f3a0a29358cd0b9d670915468f4290e8b85d118a34211ab8"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "zbus"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "851238c133804e0aa888edf4a0229481c753544ca12a60fd1c3230c8a500fe40"
dependencies = [
"async-broadcast",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix 0.29.0",
"ordered-stream",
"rand",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys",
"xdg-home",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "4.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d5a3f12c20bd473be3194af6b49d50d7bb804ef3192dc70eddedb26b85d9da7"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant",
]
[[package]]
name = "zvariant"
version = "4.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1724a2b330760dc7d2a8402d841119dc869ef120b139d29862d6980e9c75bfc9"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive",
]
[[package]]
name = "zvariant_derive"
version = "4.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55025a7a518ad14518fb243559c058a2e5b848b015e31f1d90414f36e3317859"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc242db087efc22bd9ade7aa7809e4ba828132edc312871584a6b4391bdf8786"
dependencies = [
"proc-macro2",
"quote",
"syn",
]

View File

@@ -22,6 +22,7 @@ retry = "=2.0.0"
scopeguard = "=1.2.0"
sha2 = "=0.10.8"
thiserror = "=1.0.61"
tokio = { version = "1.38.0", features = ["io-util", "sync", "macros"] }
typenum = "=1.17.0"
[target.'cfg(windows)'.dependencies]
@@ -49,3 +50,4 @@ security-framework-sys = "=2.11.0"
[target.'cfg(target_os = "linux")'.dependencies]
gio = "=0.19.5"
libsecret = "=0.5.0"
zbus = "4.3.1"

View File

@@ -3,3 +3,4 @@ pub mod clipboard;
pub mod crypto;
pub mod error;
pub mod password;
pub mod powermonitor;

View File

@@ -0,0 +1,51 @@
use std::borrow::Cow;
use zbus::{Connection, MatchRule, export::futures_util::TryStreamExt};
struct ScreenLock {
interface: Cow<'static, str>,
path: Cow<'static, str>,
}
const SCREEN_LOCK_MONITORS: [ScreenLock; 2] = [
ScreenLock {
interface: Cow::Borrowed("org.gnome.ScreenSaver"),
path: Cow::Borrowed("/org/gnome/ScreenSaver"),
},
ScreenLock {
interface: Cow::Borrowed("org.freedesktop.ScreenSaver"),
path: Cow::Borrowed("/org/freedesktop/ScreenSaver"),
},
];
pub async fn on_lock(tx: tokio::sync::mpsc::Sender<()>) -> Result<(), Box<dyn std::error::Error>> {
let connection = Connection::session().await?;
let proxy = zbus::fdo::DBusProxy::new(&connection).await?;
for monitor in SCREEN_LOCK_MONITORS.iter() {
let match_rule = MatchRule::builder()
.msg_type(zbus::MessageType::Signal)
.interface(monitor.interface.clone())?
.member("ActiveChanged")?
.build();
proxy.add_match_rule(match_rule).await?;
}
tokio::spawn(async move {
while let Ok(Some(_)) = zbus::MessageStream::from(&connection).try_next().await {
tx.send(()).await.unwrap();
}
});
Ok(())
}
pub async fn is_lock_monitor_available() -> bool {
let connection = Connection::session().await.unwrap();
for monitor in SCREEN_LOCK_MONITORS {
let res = connection.call_method(Some(monitor.interface.clone()), monitor.path.clone(), Some(monitor.interface.clone()), "GetActive", &()).await;
if res.is_ok() {
return true;
}
}
false
}

View File

@@ -0,0 +1,5 @@
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "unimplemented.rs")]
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]
mod powermonitor;
pub use powermonitor::*;

View File

@@ -0,0 +1,7 @@
pub async fn on_lock(_: tokio::sync::mpsc::Sender<()>) -> Result<(), Box<dyn std::error::Error>> {
unimplemented!();
}
pub async fn is_lock_monitor_available() -> bool {
return false;
}

View File

@@ -41,3 +41,7 @@ export namespace clipboards {
export function read(): Promise<string>
export function write(text: string, password: boolean): Promise<void>
}
export namespace powermonitors {
export function onLock(callback: (err: Error | null, ) => any): Promise<void>
export function isLockMonitorAvailable(): Promise<boolean>
}

View File

@@ -206,8 +206,9 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { passwords, biometrics, clipboards } = nativeBinding
const { passwords, biometrics, clipboards, powermonitors } = nativeBinding
module.exports.passwords = passwords
module.exports.biometrics = biometrics
module.exports.clipboards = clipboards
module.exports.powermonitors = powermonitors

View File

@@ -1,6 +1,5 @@
#[macro_use]
extern crate napi_derive;
#[napi]
pub mod passwords {
/// Fetch the stored password from the keychain.
@@ -142,3 +141,26 @@ pub mod clipboards {
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}
#[napi]
pub mod powermonitors {
use napi::{threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio};
#[napi]
pub async fn on_lock(callback: ThreadsafeFunction<(), CalleeHandled>) -> napi::Result<()> {
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32);
desktop_core::powermonitor::on_lock(tx).await.map_err(|e| napi::Error::from_reason(e.to_string()))?;
tokio::spawn(async move {
while let Some(message) = rx.recv().await {
callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking);
}
});
Ok(())
}
#[napi]
pub async fn is_lock_monitor_available() -> napi::Result<bool> {
Ok(desktop_core::powermonitor::is_lock_monitor_available().await)
}
}

View File

@@ -25,7 +25,7 @@
"**/node_modules/argon2/package.json",
"**/node_modules/argon2/build/Release/argon2.node"
],
"electronVersion": "31.2.1",
"electronVersion": "31.3.0",
"generateUpdatesFilesForAllChannels": true,
"publish": {
"provider": "generic",

View File

@@ -46,7 +46,7 @@ export class SettingsComponent implements OnInit {
protected readonly VaultTimeoutAction = VaultTimeoutAction;
showMinToTray = false;
vaultTimeoutOptions: VaultTimeoutOption[];
vaultTimeoutOptions: VaultTimeoutOption[] = [];
localeOptions: any[];
themeOptions: any[];
clearClipboardOptions: any[];
@@ -161,29 +161,6 @@ export class SettingsComponent implements OnInit {
// DuckDuckGo browser is only for macos initially
this.showDuckDuckGoIntegrationOption = isMac;
this.vaultTimeoutOptions = [
{ name: this.i18nService.t("oneMinute"), value: 1 },
{ name: this.i18nService.t("fiveMinutes"), value: 5 },
{ name: this.i18nService.t("fifteenMinutes"), value: 15 },
{ name: this.i18nService.t("thirtyMinutes"), value: 30 },
{ name: this.i18nService.t("oneHour"), value: 60 },
{ name: this.i18nService.t("fourHours"), value: 240 },
{ name: this.i18nService.t("onIdle"), value: VaultTimeoutStringType.OnIdle },
{ name: this.i18nService.t("onSleep"), value: VaultTimeoutStringType.OnSleep },
];
if (this.platformUtilsService.getDevice() !== DeviceType.LinuxDesktop) {
this.vaultTimeoutOptions.push({
name: this.i18nService.t("onLocked"),
value: VaultTimeoutStringType.OnLocked,
});
}
this.vaultTimeoutOptions = this.vaultTimeoutOptions.concat([
{ name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart },
{ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never },
]);
const localeOptions: any[] = [];
this.i18nService.supportedTranslationLocales.forEach((locale) => {
let name = locale;
@@ -215,6 +192,8 @@ export class SettingsComponent implements OnInit {
}
async ngOnInit() {
this.vaultTimeoutOptions = await this.generateVaultTimeoutOptions();
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
this.isWindows = (await this.platformUtilsService.getDevice()) === DeviceType.WindowsDesktop;
@@ -718,6 +697,33 @@ export class SettingsComponent implements OnInit {
);
}
private async generateVaultTimeoutOptions(): Promise<VaultTimeoutOption[]> {
let vaultTimeoutOptions: VaultTimeoutOption[] = [
{ name: this.i18nService.t("oneMinute"), value: 1 },
{ name: this.i18nService.t("fiveMinutes"), value: 5 },
{ name: this.i18nService.t("fifteenMinutes"), value: 15 },
{ name: this.i18nService.t("thirtyMinutes"), value: 30 },
{ name: this.i18nService.t("oneHour"), value: 60 },
{ name: this.i18nService.t("fourHours"), value: 240 },
{ name: this.i18nService.t("onIdle"), value: VaultTimeoutStringType.OnIdle },
{ name: this.i18nService.t("onSleep"), value: VaultTimeoutStringType.OnSleep },
];
if (await ipc.platform.powermonitor.isLockMonitorAvailable()) {
vaultTimeoutOptions.push({
name: this.i18nService.t("onLocked"),
value: VaultTimeoutStringType.OnLocked,
});
}
vaultTimeoutOptions = vaultTimeoutOptions.concat([
{ name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart },
{ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never },
]);
return vaultTimeoutOptions;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();

View File

@@ -16,6 +16,7 @@ import {
RegistrationStartComponent,
RegistrationStartSecondaryComponent,
RegistrationStartSecondaryComponentData,
SetPasswordJitComponent,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -149,6 +150,15 @@ const routes: Routes = [
},
],
},
{
path: "set-password-jit",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],
component: SetPasswordJitComponent,
data: {
pageTitle: "joinOrganization",
pageSubtitle: "finishJoiningThisOrganizationBySettingAMasterPassword",
} satisfies AnonLayoutWrapperData,
},
],
},
];

View File

@@ -0,0 +1,21 @@
import { inject } from "@angular/core";
import {
DefaultSetPasswordJitService,
SetPasswordCredentials,
SetPasswordJitService,
} from "@bitwarden/auth/angular";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
export class DesktopSetPasswordJitService
extends DefaultSetPasswordJitService
implements SetPasswordJitService
{
messagingService = inject(MessagingService);
override async setPassword(credentials: SetPasswordCredentials) {
await super.setPassword(credentials);
this.messagingService.send("redrawMenu");
}
}

View File

@@ -18,16 +18,30 @@ import {
CLIENT_TYPE,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { SetPasswordJitService } from "@bitwarden/auth/angular";
import {
InternalUserDecryptionOptionsServiceAbstraction,
PinServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { KdfConfigService as KdfConfigServiceAbstraction } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import {
KdfConfigService,
KdfConfigService as KdfConfigServiceAbstraction,
} from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import {
CryptoService,
CryptoService as CryptoServiceAbstraction,
} from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -56,7 +70,6 @@ import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vau
import { DialogService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { PinServiceAbstraction } from "../../../../../libs/auth/src/common/abstractions";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
import { ElectronCryptoService } from "../../platform/services/electron-crypto.service";
@@ -77,6 +90,7 @@ import { NativeMessagingService } from "../../services/native-messaging.service"
import { SearchBarService } from "../layout/search/search-bar.service";
import { DesktopFileDownloadService } from "./desktop-file-download.service";
import { DesktopSetPasswordJitService } from "./desktop-set-password-jit.service";
import { InitService } from "./init.service";
import { NativeMessagingManifestService } from "./native-messaging-manifest.service";
import { RendererCryptoFunctionService } from "./renderer-crypto-function.service";
@@ -254,6 +268,20 @@ const safeProviders: SafeProvider[] = [
provide: CLIENT_TYPE,
useValue: ClientType.Desktop,
}),
safeProvider({
provide: SetPasswordJitService,
useClass: DesktopSetPasswordJitService,
deps: [
ApiService,
CryptoService,
I18nServiceAbstraction,
KdfConfigService,
InternalMasterPasswordServiceAbstraction,
OrganizationApiServiceAbstraction,
OrganizationUserService,
InternalUserDecryptionOptionsServiceAbstraction,
],
}),
];
@NgModule({

View File

@@ -265,7 +265,7 @@
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="special">!@#$%^&*</label>
<label for="special">!&#64;#$%^&*</label>
<input
id="special"
type="checkbox"

View File

@@ -551,6 +551,12 @@
"masterPassHintLabel": {
"message": "Master password hint"
},
"joinOrganization": {
"message": "Join organization"
},
"finishJoiningThisOrganizationBySettingAMasterPassword": {
"message": "Finish joining this organization by setting a master password."
},
"settings": {
"message": "Settings"
},
@@ -2093,6 +2099,9 @@
"vaultTimeoutTooLarge": {
"message": "Your vault timeout exceeds the restrictions set by your organization."
},
"inviteAccepted": {
"message": "Invitation accepted"
},
"resetPasswordPolicyAutoEnroll": {
"message": "Automatic enrollment"
},

View File

@@ -184,7 +184,7 @@ export class Main {
});
});
this.powerMonitorMain = new PowerMonitorMain(this.messagingService);
this.powerMonitorMain = new PowerMonitorMain(this.messagingService, this.logService);
this.menuMain = new MenuMain(
this.i18nService,
this.messagingService,

View File

@@ -1,6 +1,8 @@
import { powerMonitor } from "electron";
import { ipcMain, powerMonitor } from "electron";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessageSender } from "@bitwarden/common/platform/messaging";
import { powermonitors } from "@bitwarden/desktop-napi";
import { isSnapStore } from "../utils";
@@ -11,7 +13,10 @@ const IdleCheckInterval = 30 * 1000; // 30 seconds
export class PowerMonitorMain {
private idle = false;
constructor(private messagingService: MessageSender) {}
constructor(
private messagingService: MessageSender,
private logService: LogService,
) {}
init() {
// ref: https://github.com/electron/electron/issues/13767
@@ -27,7 +32,22 @@ export class PowerMonitorMain {
powerMonitor.on("lock-screen", () => {
this.messagingService.send("systemLocked");
});
} else {
powermonitors
.onLock(() => {
this.messagingService.send("systemLocked");
})
.catch((error) => {
this.logService.error("Error setting up lock monitor", { error });
});
}
ipcMain.handle("powermonitor.isLockMonitorAvailable", async (_event: any, _message: any) => {
if (process.platform !== "linux") {
return true;
} else {
return await powermonitors.isLockMonitorAvailable();
}
});
// System idle
global.setInterval(() => {

View File

@@ -59,6 +59,11 @@ const clipboard = {
write: (message: ClipboardWriteMessage) => ipcRenderer.invoke("clipboard.write", message),
};
const powermonitor = {
isLockMonitorAvailable: (): Promise<boolean> =>
ipcRenderer.invoke("powermonitor.isLockMonitorAvailable"),
};
const nativeMessaging = {
sendReply: (message: EncryptedMessageResponse | UnencryptedMessageResponse) => {
ipcRenderer.send("nativeMessagingReply", message);
@@ -148,6 +153,7 @@ export default {
passwords,
biometric,
clipboard,
powermonitor,
nativeMessaging,
crypto,
};

View File

@@ -50,6 +50,6 @@
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="requireSpecial" id="requireSpecial" />
<bit-label>!@#$%^&amp;*</bit-label>
<bit-label>!&#64;#$%^&amp;*</bit-label>
</bit-form-control>
</div>

View File

@@ -44,7 +44,7 @@
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useSpecial" id="useSpecial" />
<bit-label>!@#$%^&amp;*</bit-label>
<bit-label>!&#64;#$%^&amp;*</bit-label>
</bit-form-control>
<h3 bitTypography="h3" class="tw-mt-4">{{ "passphrase" | i18n }}</h3>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">

View File

@@ -1,2 +1,3 @@
export * from "./webauthn-login";
export * from "./set-password-jit";
export * from "./registration";

View File

@@ -145,6 +145,7 @@ describe("DefaultRegistrationFinishService", () => {
passwordInputResult = {
masterKey: masterKey,
masterKeyHash: "masterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint",
};

View File

@@ -0,0 +1 @@
export * from "./web-set-password-jit.service";

View File

@@ -0,0 +1,27 @@
import { inject } from "@angular/core";
import {
DefaultSetPasswordJitService,
SetPasswordCredentials,
SetPasswordJitService,
} from "@bitwarden/auth/angular";
import { RouterService } from "../../../../core/router.service";
import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service";
export class WebSetPasswordJitService
extends DefaultSetPasswordJitService
implements SetPasswordJitService
{
routerService = inject(RouterService);
acceptOrganizationInviteService = inject(AcceptOrganizationInviteService);
override async setPassword(credentials: SetPasswordCredentials) {
await super.setPassword(credentials);
// SSO JIT accepts org invites when setting their MP, meaning
// we can clear the deep linked url for accepting it.
await this.routerService.getAndClearLoginRedirectUrl();
await this.acceptOrganizationInviteService.clearOrganizationInvitation();
}
}

View File

@@ -7,9 +7,12 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { DisableTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/disable-two-factor-authenticator.request";
import { UpdateTwoFactorAuthenticatorRequest } from "@bitwarden/common/auth/models/request/update-two-factor-authenticator.request";
import { TwoFactorAuthenticatorResponse } from "@bitwarden/common/auth/models/response/two-factor-authenticator.response";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -43,6 +46,7 @@ export class TwoFactorAuthenticatorComponent
@Output() onChangeStatus = new EventEmitter<boolean>();
type = TwoFactorProviderType.Authenticator;
key: string;
private userVerificationToken: string;
override componentName = "app-two-factor-authenticator";
qrScriptError = false;
@@ -63,6 +67,7 @@ export class TwoFactorAuthenticatorComponent
logService: LogService,
private accountService: AccountService,
dialogService: DialogService,
private configService: ConfigService,
) {
super(
apiService,
@@ -112,16 +117,46 @@ export class TwoFactorAuthenticatorComponent
const request = await this.buildRequestModel(UpdateTwoFactorAuthenticatorRequest);
request.token = this.formGroup.value.token;
request.key = this.key;
request.userVerificationToken = this.userVerificationToken;
const response = await this.apiService.putTwoFactorAuthenticator(request);
await this.processResponse(response);
this.onUpdated.emit(true);
}
protected override async disableMethod() {
const twoFactorAuthenticatorTokenFeatureFlag = await this.configService.getFeatureFlag(
FeatureFlag.AuthenticatorTwoFactorToken,
);
if (twoFactorAuthenticatorTokenFeatureFlag === false) {
return super.disableMethod();
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "disable" },
content: { key: "twoStepDisableDesc" },
type: "warning",
});
if (!confirmed) {
return;
}
const request = await this.buildRequestModel(DisableTwoFactorAuthenticatorRequest);
request.type = this.type;
request.key = this.key;
request.userVerificationToken = this.userVerificationToken;
await this.apiService.deleteTwoFactorAuthenticator(request);
this.enabled = false;
this.platformUtilsService.showToast("success", null, this.i18nService.t("twoStepDisabled"));
this.onUpdated.emit(false);
}
private async processResponse(response: TwoFactorAuthenticatorResponse) {
this.formGroup.get("token").setValue(null);
this.enabled = response.enabled;
this.key = response.key;
this.userVerificationToken = response.userVerificationToken;
await this.waitForQRiousToLoadOrError().catch((error) => {
this.logService.error(error);

View File

@@ -65,7 +65,7 @@
<ng-template body>
<tr *ngFor="let i of subscription.items">
<td bitCell>
{{ i.name }} {{ i.quantity > 1 ? "&times;" + i.quantity : "" }} @
{{ i.name }} {{ i.quantity > 1 ? "&times;" + i.quantity : "" }} &#64;
{{ i.amount | currency: "$" }}
</td>
<td bitCell>{{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }}</td>

View File

@@ -75,7 +75,7 @@
<tr bitRow *ngFor="let i of subscriptionLineItems">
<td bitCell [ngClass]="{ 'tw-pl-20': i.addonSubscriptionItem }">
<span *ngIf="!i.addonSubscriptionItem">{{ i.productName | i18n }} -</span>
{{ i.name }} {{ i.quantity > 1 ? "&times;" + i.quantity : "" }} @
{{ i.name }} {{ i.quantity > 1 ? "&times;" + i.quantity : "" }} &#64;
{{ i.amount | currency: "$" }}
</td>
<td bitCell class="tw-text-right">

View File

@@ -17,11 +17,20 @@ import {
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service";
import { RegistrationFinishService as RegistrationFinishServiceAbstraction } from "@bitwarden/auth/angular";
import {
SetPasswordJitService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
} from "@bitwarden/auth/angular";
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -48,7 +57,7 @@ import {
import { VaultTimeout, VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type";
import { PolicyListService } from "../admin-console/core/policy-list.service";
import { WebRegistrationFinishService } from "../auth";
import { WebSetPasswordJitService, WebRegistrationFinishService } from "../auth";
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
import { HtmlStorageService } from "../core/html-storage.service";
import { I18nService } from "../core/i18n.service";
@@ -184,6 +193,20 @@ const safeProviders: SafeProvider[] = [
PolicyService,
],
}),
safeProvider({
provide: SetPasswordJitService,
useClass: WebSetPasswordJitService,
deps: [
ApiService,
CryptoServiceAbstraction,
I18nServiceAbstraction,
KdfConfigService,
InternalMasterPasswordServiceAbstraction,
OrganizationApiServiceAbstraction,
OrganizationUserService,
InternalUserDecryptionOptionsServiceAbstraction,
],
}),
];
@NgModule({

View File

@@ -15,21 +15,45 @@
class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0"
>
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
<a
*ngFor="let more of moreProducts"
[href]="more.marketingRoute"
target="_blank"
rel="noreferrer"
class="tw-flex tw-py-2 tw-px-4 tw-font-semibold !tw-text-alt2 !tw-no-underline hover:tw-bg-primary-300/60 [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div *ngIf="more.otherProductOverrides?.supportingText" class="tw-text-xs tw-font-normal">
{{ more.otherProductOverrides.supportingText }}
<ng-container *ngFor="let more of moreProducts">
<!-- <a> for when the marketing route is external -->
<a
*ngIf="more.marketingRoute.external"
[href]="more.marketingRoute.route"
target="_blank"
rel="noreferrer"
class="tw-flex tw-py-2 tw-px-4 tw-font-semibold !tw-text-alt2 !tw-no-underline hover:tw-bg-primary-300/60 [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div
*ngIf="more.otherProductOverrides?.supportingText"
class="tw-text-xs tw-font-normal"
>
{{ more.otherProductOverrides.supportingText }}
</div>
</div>
</div>
</a>
</a>
<!-- <a> for when the marketing route is internal, it needs to use [routerLink] instead of [href] like the external <a> uses. -->
<a
*ngIf="!more.marketingRoute.external"
[routerLink]="more.marketingRoute.route"
rel="noreferrer"
class="tw-flex tw-py-2 tw-px-4 tw-font-semibold !tw-text-alt2 !tw-no-underline hover:tw-bg-primary-300/60 [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div
*ngIf="more.otherProductOverrides?.supportingText"
class="tw-text-xs tw-font-normal"
>
{{ more.otherProductOverrides.supportingText }}
</div>
</div>
</a>
</ng-container>
</section>
</ng-container>
</div>

View File

@@ -80,7 +80,10 @@ describe("NavigationProductSwitcherComponent", () => {
isActive: false,
name: "Other Product",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
marketingRoute: {
route: "https://www.example.com/",
external: true,
},
},
],
});
@@ -100,7 +103,10 @@ describe("NavigationProductSwitcherComponent", () => {
isActive: false,
name: "Other Product",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
marketingRoute: {
route: "https://www.example.com/",
external: true,
},
otherProductOverrides: { name: "Alternate name" },
},
],
@@ -117,7 +123,10 @@ describe("NavigationProductSwitcherComponent", () => {
isActive: false,
name: "Other Product",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
marketingRoute: {
route: "https://www.example.com/",
external: true,
},
otherProductOverrides: { name: "Alternate name", supportingText: "Supporting Text" },
},
],
@@ -134,9 +143,27 @@ describe("NavigationProductSwitcherComponent", () => {
mockProducts$.next({
bento: [],
other: [
{ name: "AA Product", icon: "bwi-lock", marketingRoute: "https://www.example.com/" },
{ name: "Test Product", icon: "bwi-lock", marketingRoute: "https://www.example.com/" },
{ name: "Organizations", icon: "bwi-lock", marketingRoute: "https://www.example.com/" },
{
name: "AA Product",
icon: "bwi-lock",
marketingRoute: {
route: "https://www.example.com/",
external: true,
},
},
{
name: "Test Product",
icon: "bwi-lock",
marketingRoute: {
route: "https://www.example.com/",
external: true,
},
},
{
name: "Organizations",
icon: "bwi-lock",
marketingRoute: { route: "https://www.example.com/", external: true },
},
],
});
@@ -157,7 +184,10 @@ describe("NavigationProductSwitcherComponent", () => {
{
name: "Organizations",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
marketingRoute: {
route: "https://www.example.com/",
external: true,
},
isActive: true,
},
],

View File

@@ -34,17 +34,30 @@
class="tw-mt-4 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-t-text-muted tw-p-2 tw-pb-0"
>
<span class="tw-mb-1 tw-text-xs tw-text-muted">{{ "moreFromBitwarden" | i18n }}</span>
<a
*ngFor="let product of products.other"
bitLink
[href]="product.marketingRoute"
target="_blank"
rel="noreferrer"
>
<span class="tw-flex tw-items-center tw-font-normal">
<i class="bwi bwi-fw {{ product.icon }} tw-m-0 !tw-mr-3"></i>{{ product.name }}
</span>
</a>
<span *ngFor="let product of products.other">
<!-- <a> for when the marketing route is internal, it needs to use [routerLink] instead of [href] like the external <a> uses. -->
<a
*ngIf="!product.marketingRoute.external"
bitLink
[routerLink]="product.marketingRoute.route"
>
<span class="tw-flex tw-items-center tw-font-normal">
<i class="bwi bwi-fw {{ product.icon }} tw-m-0 !tw-mr-3"></i>{{ product.name }}
</span>
</a>
<!-- <a> for when the marketing route is external -->
<a
*ngIf="product.marketingRoute.external"
bitLink
[href]="product.marketingRoute.route"
target="_blank"
rel="noreferrer"
>
<span class="tw-flex tw-items-center tw-font-normal">
<i class="bwi bwi-fw {{ product.icon }} tw-m-0 !tw-mr-3"></i>{{ product.name }}
</span>
</a>
</span>
</section>
</div>
</bit-menu>

View File

@@ -30,7 +30,13 @@ export type ProductSwitcherItem = {
/**
* Route for items in the `otherProducts$` section
*/
marketingRoute?: string | any[];
marketingRoute?: {
route: string | any[];
external: boolean;
};
/**
* Route definition for external/internal routes for items in the `otherProducts$` section
*/
/**
* Used to apply css styles to show when a button is selected
@@ -136,7 +142,10 @@ export class ProductSwitcherService {
name: "Password Manager",
icon: "bwi-lock",
appRoute: "/vault",
marketingRoute: "https://bitwarden.com/products/personal/",
marketingRoute: {
route: "https://bitwarden.com/products/personal/",
external: true,
},
isActive:
!this.router.url.includes("/sm/") &&
!this.router.url.includes("/organizations/") &&
@@ -146,7 +155,10 @@ export class ProductSwitcherService {
name: "Secrets Manager",
icon: "bwi-cli",
appRoute: ["/sm", smOrg?.id],
marketingRoute: "https://bitwarden.com/products/secrets-manager/",
marketingRoute: {
route: "/sm-landing",
external: false,
},
isActive: this.router.url.includes("/sm/"),
otherProductOverrides: {
supportingText: this.i18n.transform("secureYourInfrastructure"),
@@ -156,7 +168,10 @@ export class ProductSwitcherService {
name: "Admin Console",
icon: "bwi-business",
appRoute: ["/organizations", acOrg?.id],
marketingRoute: "https://bitwarden.com/products/business/",
marketingRoute: {
route: "https://bitwarden.com/products/business/",
external: true,
},
isActive: this.router.url.includes("/organizations/"),
},
provider: {
@@ -168,7 +183,10 @@ export class ProductSwitcherService {
orgs: {
name: "Organizations",
icon: "bwi-business",
marketingRoute: "https://bitwarden.com/products/business/",
marketingRoute: {
route: "https://bitwarden.com/products/business/",
external: true,
},
otherProductOverrides: {
name: "Share your passwords",
supportingText: this.i18n.transform("protectYourFamilyOrBusiness"),

View File

@@ -17,6 +17,7 @@ import {
RegistrationStartComponent,
RegistrationStartSecondaryComponent,
RegistrationStartSecondaryComponentData,
SetPasswordJitComponent,
LockIcon,
RegistrationLinkExpiredComponent,
} from "@bitwarden/auth/angular";
@@ -59,6 +60,8 @@ import { EnvironmentSelectorComponent } from "./components/environment-selector/
import { DataProperties } from "./core";
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
import { UserLayoutComponent } from "./layouts/user-layout.component";
import { RequestSMAccessComponent } from "./secrets-manager/secrets-manager-landing/request-sm-access.component";
import { SMLandingComponent } from "./secrets-manager/secrets-manager-landing/sm-landing.component";
import { DomainRulesComponent } from "./settings/domain-rules.component";
import { PreferencesComponent } from "./settings/preferences.component";
import { GeneratorComponent } from "./tools/generator.component";
@@ -206,6 +209,15 @@ const routes: Routes = [
},
],
},
{
path: "set-password-jit",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification)],
component: SetPasswordJitComponent,
data: {
pageTitle: "joinOrganization",
pageSubtitle: "finishJoiningThisOrganizationBySettingAMasterPassword",
} satisfies AnonLayoutWrapperData,
},
{
path: "signup-link-expired",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
@@ -405,6 +417,16 @@ const routes: Routes = [
component: SendComponent,
data: { titleId: "send" } satisfies DataProperties,
},
{
path: "sm-landing",
component: SMLandingComponent,
data: { titleId: "moreProductsFromBitwarden" },
},
{
path: "request-sm-access",
component: RequestSMAccessComponent,
data: { titleId: "requestAccessToSecretsManager" },
},
{
path: "create-organization",
component: CreateOrganizationComponent,

View File

@@ -0,0 +1,6 @@
import { Guid } from "@bitwarden/common/src/types/guid";
export class RequestSMAccessRequest {
OrganizationId: Guid;
EmailContent: string;
}

View File

@@ -0,0 +1,39 @@
<app-header></app-header>
<bit-container>
<form [formGroup]="requestAccessForm" [bitSubmit]="submit">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-9">
<p bitTypography="body1">{{ "youNeedApprovalFromYourAdminToTrySecretsManager" | i18n }}</p>
<bit-form-field>
<bit-label>{{ "addANote" | i18n }}</bit-label>
<textarea
rows="20"
id="request_access_textarea"
bitInput
formControlName="requestAccessEmailContents"
></textarea>
</bit-form-field>
<bit-form-field>
<bit-label>{{ "organization" | i18n }}</bit-label>
<bit-select formControlName="selectedOrganization">
<bit-option
*ngFor="let org of organizations"
[value]="org"
[label]="org.name"
required
></bit-option>
</bit-select>
</bit-form-field>
<div class="tw-flex tw-gap-x-4 tw-mt-4">
<button bitButton bitFormButton type="submit" buttonType="primary">
{{ "sendRequest" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" [routerLink]="'/sm-landing'">
{{ "cancel" | i18n }}
</button>
</div>
</div>
</div>
</form>
</bit-container>

View File

@@ -0,0 +1,75 @@
import { Component, OnInit } from "@angular/core";
import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Guid } from "@bitwarden/common/types/guid";
import { NoItemsModule, SearchModule, ToastService } from "@bitwarden/components";
import { HeaderModule } from "../../layouts/header/header.module";
import { OssModule } from "../../oss.module";
import { SharedModule } from "../../shared/shared.module";
import { RequestSMAccessRequest } from "../models/requests/request-sm-access.request";
import { SmLandingApiService } from "./sm-landing-api.service";
@Component({
selector: "app-request-sm-access",
standalone: true,
templateUrl: "request-sm-access.component.html",
imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule, OssModule],
})
export class RequestSMAccessComponent implements OnInit {
requestAccessForm = new FormGroup({
requestAccessEmailContents: new FormControl(
this.i18nService.t("requestAccessSMDefaultEmailContent"),
[Validators.required],
),
selectedOrganization: new FormControl<Organization>(null, [Validators.required]),
});
organizations: Organization[] = [];
constructor(
private router: Router,
private i18nService: I18nService,
private organizationService: OrganizationService,
private smLandingApiService: SmLandingApiService,
private toastService: ToastService,
) {}
async ngOnInit() {
this.organizations = (await this.organizationService.getAll())
.filter((e) => e.enabled)
.sort((a, b) => a.name.localeCompare(b.name));
if (this.organizations === null || this.organizations.length < 1) {
await this.navigateToCreateOrganizationPage();
}
}
submit = async () => {
this.requestAccessForm.markAllAsTouched();
if (this.requestAccessForm.invalid) {
return;
}
const formValue = this.requestAccessForm.value;
const request = new RequestSMAccessRequest();
request.OrganizationId = formValue.selectedOrganization.id as Guid;
request.EmailContent = formValue.requestAccessEmailContents;
await this.smLandingApiService.requestSMAccessFromAdmins(request);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("smAccessRequestEmailSent"),
});
await this.router.navigate(["/"]);
};
async navigateToCreateOrganizationPage() {
await this.router.navigate(["/create-organization"]);
}
}

View File

@@ -0,0 +1,16 @@
import { Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { RequestSMAccessRequest } from "../models/requests/request-sm-access.request";
@Injectable({
providedIn: "root",
})
export class SmLandingApiService {
constructor(private apiService: ApiService) {}
async requestSMAccessFromAdmins(request: RequestSMAccessRequest): Promise<void> {
await this.apiService.send("POST", "/request-access/request-sm-access", request, true, false);
}
}

View File

@@ -0,0 +1,53 @@
<app-header></app-header>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-6">
<img [src]="imageSrc" class="tw-max-w-full" alt="Bitwarden" aria-hidden="true" />
</div>
<div class="tw-col-span-6 tw-mx-4">
<h1 bitTypography="h1">{{ "bitwardenSecretsManager" | i18n }}</h1>
<bit-container *ngIf="this.showSecretsManagerInformation">
<p bitTypography="body1">
{{ "developmentDevOpsAndITTeamsChooseBWSecret" | i18n }}
</p>
<ul class="tw-list-outside">
<li bitTypography="body1" class="tw-mb-2">
<b>{{ "centralizeSecretsManagement" | i18n }}</b>
{{ "centralizeSecretsManagementDescription" | i18n }}
</li>
<li bitTypography="body1" class="tw-mb-2">
<b>{{ "preventSecretLeaks" | i18n }}</b> {{ "preventSecretLeaksDescription" | i18n }}
</li>
<li bitTypography="body1" class="tw-mb-2">
<b>{{ "enhanceDeveloperProductivity" | i18n }}</b>
{{ "enhanceDeveloperProductivityDescription" | i18n }}
</li>
<li bitTypography="body1" class="tw-mb-2">
<b>{{ "strengthenBusinessSecurity" | i18n }}</b>
{{ "strengthenBusinessSecurityDescription" | i18n }}
</li>
</ul>
</bit-container>
<bit-container *ngIf="this.showGiveMembersAccessInstructions">
<p bitTypography="body1">
{{ "giveMembersAccess" | i18n }}
</p>
<ul class="tw-list-outside">
<li bitTypography="body1" class="tw-mb-2">
{{ "openYourOrganizations" | i18n }} <b>{{ "members" | i18n }}</b>
{{ "viewAndSelectTheMembers" | i18n }}
</li>
<li bitTypography="body1" class="tw-mb-2">
{{ "usingTheMenuSelect" | i18n }} <b>{{ "activateSecretsManager" | i18n }}</b>
{{ "toGrantAccessToSelectedMembers" | i18n }}
</li>
</ul>
</bit-container>
<button type="button" bitButton buttonType="primary" [routerLink]="tryItNowUrl">
{{ "tryItNow" | i18n }}
</button>
<a bitLink linkType="primary" [href]="learnMoreUrl" target="_blank" class="tw-m-5">
{{ "learnMore" | i18n }}
</a>
</div>
</div>

View File

@@ -0,0 +1,76 @@
import { Component } from "@angular/core";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { NoItemsModule, SearchModule } from "@bitwarden/components";
import { HeaderModule } from "../../layouts/header/header.module";
import { SharedModule } from "../../shared/shared.module";
@Component({
selector: "app-sm-landing",
standalone: true,
imports: [SharedModule, SearchModule, NoItemsModule, HeaderModule],
templateUrl: "sm-landing.component.html",
})
export class SMLandingComponent {
tryItNowUrl: string;
learnMoreUrl: string = "https://bitwarden.com/help/secrets-manager-overview/";
imageSrc: string = "../images/sm.webp";
showSecretsManagerInformation: boolean = true;
showGiveMembersAccessInstructions: boolean = false;
constructor(private organizationService: OrganizationService) {}
async ngOnInit() {
const enabledOrganizations = (await this.organizationService.getAll()).filter((e) => e.enabled);
if (enabledOrganizations.length > 0) {
this.handleEnabledOrganizations(enabledOrganizations);
} else {
// Person is not part of any orgs they need to be in an organization in order to use SM
this.tryItNowUrl = "/create-organization";
}
}
private handleEnabledOrganizations(enabledOrganizations: Organization[]) {
// People get to this page because SM (Secrets Manager) isn't enabled for them (or the Organization they are a part of)
// 1 - SM is enabled for the Organization but not that user
//1a - person is Admin+ (Admin or higher) and just needs instructions on how to enable it for themselves
//1b - person is beneath admin status and needs to request SM access from Administrators/Owners
// 2 - SM is not enabled for the organization yet
//2a - person is Owner/Provider - Direct them to the subscription/billing page
//2b - person is Admin - Direct them to request access page where an email is sent to owner/admins
//2c - person is user - Direct them to request access page where an email is sent to owner/admins
// We use useSecretsManager because we want to get the first org the person is a part of where SM is enabled but they don't have access enabled yet
const adminPlusNeedsInstructionsToEnableSM = enabledOrganizations.find(
(o) => o.isAdmin && o.useSecretsManager,
);
const ownerNeedsToEnableSM = enabledOrganizations.find(
(o) => o.isOwner && !o.useSecretsManager,
);
// 1a If Organization has SM Enabled, but this logged in person does not have it enabled, but they are admin+ then give them instructions to enable.
if (adminPlusNeedsInstructionsToEnableSM != undefined) {
this.showHowToEnableSMForMembers(adminPlusNeedsInstructionsToEnableSM.id);
}
// 2a Owners can enable SM in the subscription area of Admin Console.
else if (ownerNeedsToEnableSM != undefined) {
this.tryItNowUrl = `/organizations/${ownerNeedsToEnableSM.id}/billing/subscription`;
}
// 1b and 2b 2c, they must be lower than an Owner, and they need access, or want their org to have access to SM.
else {
this.tryItNowUrl = "/request-sm-access";
}
}
private showHowToEnableSMForMembers(orgId: string) {
this.showGiveMembersAccessInstructions = true;
this.showSecretsManagerInformation = false;
this.learnMoreUrl =
"https://bitwarden.com/help/secrets-manager-quick-start/#give-members-access";
this.imageSrc = "../images/sm-give-access.png";
this.tryItNowUrl = `/organizations/${orgId}/members`;
}
}

View File

@@ -210,7 +210,7 @@
[disabled]="enforcedPasswordPolicyOptions?.useSpecial"
attr.aria-label="{{ 'specialCharacters' | i18n }}"
/>
<label for="special" class="form-check-label">!@#$%^&amp;*</label>
<label for="special" class="form-check-label">!&#64;#$%^&amp;*</label>
</div>
<div class="form-check">
<input

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

BIN
apps/web/src/images/sm.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -3471,6 +3471,9 @@
"joinOrganizationDesc": {
"message": "You've been invited to join the organization listed above. To accept the invitation, you need to log in or create a new Bitwarden account."
},
"finishJoiningThisOrganizationBySettingAMasterPassword": {
"message": "Finish joining this organization by setting a master password."
},
"inviteAccepted": {
"message": "Invitation accepted"
},
@@ -4810,6 +4813,75 @@
"message": "or",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more, see how it works, **or** try it now.'"
},
"developmentDevOpsAndITTeamsChooseBWSecret": {
"message": "Development, DevOps, and IT teams choose Bitwarden Secrets Manager to securely manage and deploy their infrastructure and machine secrets."
},
"centralizeSecretsManagement": {
"message": "Centralize secrets management."
},
"centralizeSecretsManagementDescription": {
"message": "Securely store and manage secrets in one location to prevent secret sprawl across your organization."
},
"preventSecretLeaks": {
"message": "Prevent secret leaks."
},
"preventSecretLeaksDescription": {
"message": "Protect secrets with end-to-end encryption. No more hard coding secrets or sharing through .env files."
},
"enhanceDeveloperProductivity": {
"message": "Enhance developer productivity."
},
"enhanceDeveloperProductivityDescription": {
"message": "Programmatically retrieve and deploy secrets at runtime so developers can focus on what matters most, like improving code quality."
},
"strengthenBusinessSecurity": {
"message": "Strengthen business security."
},
"strengthenBusinessSecurityDescription": {
"message": "Maintain tight control over machine and human access to secrets with SSO integrations, event logs, and access rotation."
},
"tryItNow": {
"message": "Try it now"
},
"sendRequest": {
"message": "Send request"
},
"addANote": {
"message": "Add a note"
},
"bitwardenSecretsManager": {
"message": "Bitwarden Secrets Manager"
},
"moreProductsFromBitwarden": {
"message": "More products from Bitwarden"
},
"requestAccessToSecretsManager": {
"message": "Request access to Secrets Manager"
},
"youNeedApprovalFromYourAdminToTrySecretsManager": {
"message": "You need approval from your administrator to try Secrets Manager."
},
"smAccessRequestEmailSent" : {
"message": "Access request for secrets manager email sent to admins."
},
"requestAccessSMDefaultEmailContent": {
"message": "Hi,\n\nI am requesting a subscription to Bitwarden Secrets Manager for our team. Your support would mean a great deal!\n\nBitwarden Secrets Manager is an end-to-end encrypted secrets management solution for securely storing, sharing, and deploying machine credentials like API keys, database passwords, and authentication certificates.\n\nSecrets Manager will help us to:\n\n- Improve security\n- Streamline operations\n- Prevent costly secret leaks\n\nTo request a free trial for our team, please reach out to Bitwarden.\n\nThank you for your help!"
},
"giveMembersAccess": {
"message": "Give members access:"
},
"viewAndSelectTheMembers" : {
"message" :"view and select the members you want to give access to Secrets Manager."
},
"openYourOrganizations": {
"message": "Open your organization's"
},
"usingTheMenuSelect": {
"message": "Using the menu, select"
},
"toGrantAccessToSelectedMembers": {
"message": "to grant access to selected members."
},
"sendVaultCardTryItNow": {
"message": "try it now",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more, see how it works, or **try it now**.'"

View File

@@ -27,7 +27,7 @@
{{ getFormattedPlanName(i.planName) }} {{ "orgSeats" | i18n }} ({{
i.cadence.toLowerCase()
}}) {{ "&times;" }}{{ getFormattedSeatCount(i.seatMinimum, i.purchasedSeats) }}
@
&#64;
{{
getFormattedCost(
i.cost,

View File

@@ -17,6 +17,7 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -319,6 +320,14 @@ export class SsoComponent {
}
private async handleChangePasswordRequired(orgIdentifier: string) {
const emailVerification = await this.configService.getFeatureFlag(
FeatureFlag.EmailVerification,
);
if (emailVerification) {
this.changePasswordRoute = "set-password-jit";
}
await this.navigateViaCallbackOrRoute(
this.onSuccessfulLoginChangePasswordNavigate,
[this.changePasswordRoute],

View File

@@ -2,6 +2,8 @@ import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Subject } from "rxjs";
import {
SetPasswordJitService,
DefaultSetPasswordJitService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
DefaultRegistrationFinishService,
} from "@bitwarden/auth/angular";
@@ -1265,6 +1267,20 @@ const safeProviders: SafeProvider[] = [
useClass: StripeService,
deps: [LogService],
}),
safeProvider({
provide: SetPasswordJitService,
useClass: DefaultSetPasswordJitService,
deps: [
ApiServiceAbstraction,
CryptoServiceAbstraction,
I18nServiceAbstraction,
KdfConfigServiceAbstraction,
InternalMasterPasswordServiceAbstraction,
OrganizationApiServiceAbstraction,
OrganizationUserService,
InternalUserDecryptionOptionsServiceAbstraction,
],
}),
safeProvider({
provide: RegisterRouteService,
useClass: RegisterRouteService,

View File

@@ -5,13 +5,26 @@
// icons
export * from "./icons";
// anon layout
export * from "./anon-layout/anon-layout.component";
export * from "./anon-layout/anon-layout-wrapper.component";
// fingerprint dialog
export * from "./fingerprint-dialog/fingerprint-dialog.component";
export * from "./input-password/input-password.component";
// password callout
export * from "./password-callout/password-callout.component";
export * from "./vault-timeout-input/vault-timeout-input.component";
// input password
export * from "./input-password/input-password.component";
export * from "./input-password/password-input-result";
// set password (JIT user)
export * from "./set-password-jit/set-password-jit.component";
export * from "./set-password-jit/set-password-jit.service.abstraction";
export * from "./set-password-jit/default-set-password-jit.service";
// user verification
export * from "./user-verification/user-verification-dialog.component";
export * from "./user-verification/user-verification-dialog.types";
@@ -25,6 +38,3 @@ export * from "./registration/registration-start/registration-start-secondary.co
export * from "./registration/registration-env-selector/registration-env-selector.component";
export * from "./registration/registration-finish/registration-finish.service";
export * from "./registration/registration-finish/default-registration-finish.service";
// input password
export * from "./input-password/password-input-result";

View File

@@ -23,7 +23,7 @@
<bit-hint>
<span class="tw-font-bold">{{ "important" | i18n }} </span>
{{ "masterPassImportant" | i18n }}
{{ minPasswordMsg }}.
{{ minPasswordLengthMsg }}.
</bit-hint>
</bit-form-field>

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -12,6 +12,7 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/auth/models/domain/kdf-config";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
AsyncActionsModule,
@@ -48,17 +49,16 @@ import { PasswordInputResult } from "./password-input-result";
JslibModule,
],
})
export class InputPasswordComponent implements OnInit {
export class InputPasswordComponent {
@Output() onPasswordFormSubmit = new EventEmitter<PasswordInputResult>();
@Input({ required: true }) email: string;
@Input() protected buttonText: string;
@Input() buttonText: string;
@Input() masterPasswordPolicyOptions: MasterPasswordPolicyOptions | null = null;
@Input() loading: boolean = false;
private minHintLength = 0;
protected maxHintLength = 50;
protected minPasswordLength = Utils.minimumPasswordLength;
protected minPasswordMsg = "";
protected passwordStrengthScore: PasswordStrengthScore;
@@ -103,17 +103,14 @@ export class InputPasswordComponent implements OnInit {
private toastService: ToastService,
) {}
async ngOnInit() {
get minPasswordLengthMsg() {
if (
this.masterPasswordPolicyOptions != null &&
this.masterPasswordPolicyOptions.minLength > 0
) {
this.minPasswordMsg = this.i18nService.t(
"characterMinimum",
this.masterPasswordPolicyOptions.minLength,
);
return this.i18nService.t("characterMinimum", this.masterPasswordPolicyOptions.minLength);
} else {
this.minPasswordMsg = this.i18nService.t("characterMinimum", this.minPasswordLength);
return this.i18nService.t("characterMinimum", this.minPasswordLength);
}
}
@@ -181,9 +178,16 @@ export class InputPasswordComponent implements OnInit {
const masterKeyHash = await this.cryptoService.hashMasterKey(password, masterKey);
const localMasterKeyHash = await this.cryptoService.hashMasterKey(
password,
masterKey,
HashPurpose.LocalAuthorization,
);
this.onPasswordFormSubmit.emit({
masterKey,
masterKeyHash,
localMasterKeyHash,
kdfConfig,
hint: this.formGroup.controls.hint.value,
});

View File

@@ -4,6 +4,7 @@ import { MasterKey } from "@bitwarden/common/types/key";
export interface PasswordInputResult {
masterKey: MasterKey;
masterKeyHash: string;
localMasterKeyHash: string;
kdfConfig: PBKDF2KdfConfig;
hint: string;
}

View File

@@ -1,7 +1,7 @@
<bit-callout>
{{ message | i18n }}
<ul *ngIf="policy" class="tw-mb-0">
<ul *ngIf="policy" class="tw-mb-0 tw-ml-8 tw-ps-0">
<li *ngIf="policy?.minComplexity > 0">
{{ "policyInEffectMinComplexity" | i18n: getPasswordScoreAlertDisplay() }}
</li>

View File

@@ -1,4 +1,4 @@
<form [formGroup]="formGroup">
<form [formGroup]="formGroup" *ngIf="!hideEnvSelector">
<bit-form-field>
<bit-label>{{ "creatingAccountOn" | i18n }}</bit-label>
<bit-select formControlName="selectedRegion">

View File

@@ -44,6 +44,7 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
private selectedRegionFromEnv: RegionConfig | Region.SelfHosted;
hideEnvSelector = false;
isDesktopOrBrowserExtension = false;
private destroy$ = new Subject<void>();
@@ -59,9 +60,15 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
const clientType = platformUtilsService.getClientType();
this.isDesktopOrBrowserExtension =
clientType === ClientType.Desktop || clientType === ClientType.Browser;
this.hideEnvSelector = clientType === ClientType.Web && this.platformUtilsService.isSelfHost();
}
async ngOnInit() {
if (this.hideEnvSelector) {
return;
}
await this.initSelectedRegionAndListenForEnvChanges();
this.listenForSelectedRegionChanges();
}

View File

@@ -54,6 +54,7 @@ describe("DefaultRegistrationFinishService", () => {
passwordInputResult = {
masterKey: masterKey,
masterKeyHash: "masterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint",
};

View File

@@ -1,4 +1,4 @@
<div class="full-loading-spinner" *ngIf="loading">
<div class="tw-flex tw-items-center tw-justify-center" *ngIf="loading">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>

View File

@@ -1,7 +1,7 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Params, Router, RouterModule } from "@angular/router";
import { Subject, from, switchMap, takeUntil, tap } from "rxjs";
import { EMPTY, Subject, from, switchMap, takeUntil, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
@@ -76,6 +76,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
return from(
this.registerVerificationEmailClicked(this.email, this.emailVerificationToken),
);
} else {
// org invite flow
this.loading = false;
return EMPTY;
}
}),

View File

@@ -0,0 +1,230 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import {
FakeUserDecryptionOptions as UserDecryptionOptions,
InternalUserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { DEFAULT_KDF_CONFIG } from "@bitwarden/common/auth/models/domain/kdf-config";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { PasswordInputResult } from "../input-password/password-input-result";
import { DefaultSetPasswordJitService } from "./default-set-password-jit.service";
import { SetPasswordCredentials } from "./set-password-jit.service.abstraction";
describe("DefaultSetPasswordJitService", () => {
let sut: DefaultSetPasswordJitService;
let apiService: MockProxy<ApiService>;
let cryptoService: MockProxy<CryptoService>;
let i18nService: MockProxy<I18nService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
let organizationUserService: MockProxy<OrganizationUserService>;
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
beforeEach(() => {
apiService = mock<ApiService>();
cryptoService = mock<CryptoService>();
i18nService = mock<I18nService>();
kdfConfigService = mock<KdfConfigService>();
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
organizationApiService = mock<OrganizationApiServiceAbstraction>();
organizationUserService = mock<OrganizationUserService>();
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
sut = new DefaultSetPasswordJitService(
apiService,
cryptoService,
i18nService,
kdfConfigService,
masterPasswordService,
organizationApiService,
organizationUserService,
userDecryptionOptionsService,
);
});
it("should instantiate the DefaultSetPasswordJitService", () => {
expect(sut).not.toBeFalsy();
});
describe("setPassword", () => {
let masterKey: MasterKey;
let userKey: UserKey;
let userKeyEncString: EncString;
let protectedUserKey: [UserKey, EncString];
let keyPair: [string, EncString];
let keysRequest: KeysRequest;
let organizationKeys: OrganizationKeysResponse;
let orgPublicKey: Uint8Array;
let orgSsoIdentifier: string;
let orgId: string;
let resetPasswordAutoEnroll: boolean;
let userId: UserId;
let passwordInputResult: PasswordInputResult;
let credentials: SetPasswordCredentials;
let userDecryptionOptionsSubject: BehaviorSubject<UserDecryptionOptions>;
let setPasswordRequest: SetPasswordRequest;
beforeEach(() => {
masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey;
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
userKeyEncString = new EncString("userKeyEncrypted");
protectedUserKey = [userKey, userKeyEncString];
keyPair = ["publicKey", new EncString("privateKey")];
keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
organizationKeys = {
privateKey: "orgPrivateKey",
publicKey: "orgPublicKey",
} as OrganizationKeysResponse;
orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
orgSsoIdentifier = "orgSsoIdentifier";
orgId = "orgId";
resetPasswordAutoEnroll = false;
userId = "userId" as UserId;
passwordInputResult = {
masterKey: masterKey,
masterKeyHash: "masterKeyHash",
localMasterKeyHash: "localMasterKeyHash",
hint: "hint",
kdfConfig: DEFAULT_KDF_CONFIG,
};
credentials = {
...passwordInputResult,
orgSsoIdentifier,
orgId,
resetPasswordAutoEnroll,
userId,
};
userDecryptionOptionsSubject = new BehaviorSubject(null);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
setPasswordRequest = new SetPasswordRequest(
passwordInputResult.masterKeyHash,
protectedUserKey[1].encryptedString,
passwordInputResult.hint,
orgSsoIdentifier,
keysRequest,
passwordInputResult.kdfConfig.kdfType,
passwordInputResult.kdfConfig.iterations,
);
});
function setupSetPasswordMocks(hasUserKey = true) {
if (!hasUserKey) {
cryptoService.userKey$.mockReturnValue(of(null));
cryptoService.makeUserKey.mockResolvedValue(protectedUserKey);
} else {
cryptoService.userKey$.mockReturnValue(of(userKey));
cryptoService.encryptUserKeyWithMasterKey.mockResolvedValue(protectedUserKey);
}
cryptoService.makeKeyPair.mockResolvedValue(keyPair);
apiService.setPassword.mockResolvedValue(undefined);
masterPasswordService.setForceSetPasswordReason.mockResolvedValue(undefined);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
userDecryptionOptionsService.setUserDecryptionOptions.mockResolvedValue(undefined);
kdfConfigService.setKdfConfig.mockResolvedValue(undefined);
cryptoService.setUserKey.mockResolvedValue(undefined);
cryptoService.setPrivateKey.mockResolvedValue(undefined);
masterPasswordService.setMasterKeyHash.mockResolvedValue(undefined);
}
function setupResetPasswordAutoEnrollMocks(organizationKeysExist = true) {
if (organizationKeysExist) {
organizationApiService.getKeys.mockResolvedValue(organizationKeys);
} else {
organizationApiService.getKeys.mockResolvedValue(null);
return;
}
cryptoService.userKey$.mockReturnValue(of(userKey));
cryptoService.rsaEncrypt.mockResolvedValue(userKeyEncString);
organizationUserService.putOrganizationUserResetPasswordEnrollment.mockResolvedValue(
undefined,
);
}
it("should set password successfully (given a user key)", async () => {
// Arrange
setupSetPasswordMocks();
// Act
await sut.setPassword(credentials);
// Assert
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
it("should set password successfully (given no user key)", async () => {
// Arrange
setupSetPasswordMocks(false);
// Act
await sut.setPassword(credentials);
// Assert
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
it("should handle reset password auto enroll", async () => {
// Arrange
credentials.resetPasswordAutoEnroll = true;
setupSetPasswordMocks();
setupResetPasswordAutoEnrollMocks();
// Act
await sut.setPassword(credentials);
// Assert
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(organizationApiService.getKeys).toHaveBeenCalledWith(orgId);
expect(cryptoService.rsaEncrypt).toHaveBeenCalledWith(userKey.key, orgPublicKey);
expect(organizationUserService.putOrganizationUserResetPasswordEnrollment).toHaveBeenCalled();
});
it("when handling reset password auto enroll, it should throw an error if organization keys are not found", async () => {
// Arrange
credentials.resetPasswordAutoEnroll = true;
setupSetPasswordMocks();
setupResetPasswordAutoEnrollMocks(false);
// Act and Assert
await expect(sut.setPassword(credentials)).rejects.toThrow();
expect(
organizationUserService.putOrganizationUserResetPasswordEnrollment,
).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,170 @@
import { firstValueFrom } from "rxjs";
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { OrganizationUserResetPasswordEnrollmentRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { PBKDF2KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import {
SetPasswordCredentials,
SetPasswordJitService,
} from "./set-password-jit.service.abstraction";
export class DefaultSetPasswordJitService implements SetPasswordJitService {
constructor(
protected apiService: ApiService,
protected cryptoService: CryptoService,
protected i18nService: I18nService,
protected kdfConfigService: KdfConfigService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected organizationApiService: OrganizationApiServiceAbstraction,
protected organizationUserService: OrganizationUserService,
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
) {}
async setPassword(credentials: SetPasswordCredentials): Promise<void> {
const {
masterKey,
masterKeyHash,
localMasterKeyHash,
hint,
kdfConfig,
orgSsoIdentifier,
orgId,
resetPasswordAutoEnroll,
userId,
} = credentials;
for (const [key, value] of Object.entries(credentials)) {
if (value == null) {
throw new Error(`${key} not found. Could not set password.`);
}
}
const protectedUserKey = await this.makeProtectedUserKey(masterKey, userId);
if (protectedUserKey == null) {
throw new Error("protectedUserKey not found. Could not set password.");
}
// Since this is an existing JIT provisioned user in a MP encryption org setting first password,
// they will not already have a user asymmetric key pair so we must create it for them.
const [keyPair, keysRequest] = await this.makeKeyPairAndRequest(protectedUserKey);
const request = new SetPasswordRequest(
masterKeyHash,
protectedUserKey[1].encryptedString,
hint,
orgSsoIdentifier,
keysRequest,
kdfConfig.kdfType, // kdfConfig is always DEFAULT_KDF_CONFIG (see InputPasswordComponent)
kdfConfig.iterations,
);
await this.apiService.setPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
// User now has a password so update account decryption options in state
await this.updateAccountDecryptionProperties(masterKey, kdfConfig, protectedUserKey, userId);
await this.cryptoService.setPrivateKey(keyPair[1].encryptedString, userId);
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId);
if (resetPasswordAutoEnroll) {
await this.handleResetPasswordAutoEnroll(masterKeyHash, orgId, userId);
}
}
private async makeProtectedUserKey(
masterKey: MasterKey,
userId: UserId,
): Promise<[UserKey, EncString]> {
let protectedUserKey: [UserKey, EncString] = null;
const userKey = await firstValueFrom(this.cryptoService.userKey$(userId));
if (userKey == null) {
protectedUserKey = await this.cryptoService.makeUserKey(masterKey);
} else {
protectedUserKey = await this.cryptoService.encryptUserKeyWithMasterKey(masterKey);
}
return protectedUserKey;
}
private async makeKeyPairAndRequest(
protectedUserKey: [UserKey, EncString],
): Promise<[[string, EncString], KeysRequest]> {
const keyPair = await this.cryptoService.makeKeyPair(protectedUserKey[0]);
if (keyPair == null) {
throw new Error("keyPair not found. Could not set password.");
}
const keysRequest = new KeysRequest(keyPair[0], keyPair[1].encryptedString);
return [keyPair, keysRequest];
}
private async updateAccountDecryptionProperties(
masterKey: MasterKey,
kdfConfig: PBKDF2KdfConfig,
protectedUserKey: [UserKey, EncString],
userId: UserId,
) {
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
await this.masterPasswordService.setMasterKey(masterKey, userId);
await this.cryptoService.setUserKey(protectedUserKey[0], userId);
}
private async handleResetPasswordAutoEnroll(
masterKeyHash: string,
orgId: string,
userId: UserId,
) {
const organizationKeys = await this.organizationApiService.getKeys(orgId);
if (organizationKeys == null) {
throw new Error(this.i18nService.t("resetPasswordOrgKeysError"));
}
const publicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
// RSA Encrypt user key with organization public key
const userKey = await firstValueFrom(this.cryptoService.userKey$(userId));
if (userKey == null) {
throw new Error("userKey not found. Could not handle reset password auto enroll.");
}
const encryptedUserKey = await this.cryptoService.rsaEncrypt(userKey.key, publicKey);
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
resetRequest.masterPasswordHash = masterKeyHash;
resetRequest.resetPasswordKey = encryptedUserKey.encryptedString;
await this.organizationUserService.putOrganizationUserResetPasswordEnrollment(
orgId,
userId,
resetRequest,
);
}
}

View File

@@ -0,0 +1,22 @@
<ng-container *ngIf="syncLoading">
<i class="bwi bwi-spinner bwi-spin tw-mr-2" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
{{ "loading" | i18n }}
</ng-container>
<ng-container *ngIf="!syncLoading">
<app-callout
type="warning"
title="{{ 'resetPasswordPolicyAutoEnroll' | i18n }}"
*ngIf="resetPasswordAutoEnroll"
>
{{ "resetPasswordAutoEnrollInviteWarning" | i18n }}
</app-callout>
<auth-input-password
[buttonText]="'createAccount' | i18n"
[email]="email"
[loading]="submitting"
[masterPasswordPolicyOptions]="masterPasswordPolicyOptions"
(onPasswordFormSubmit)="handlePasswordFormSubmit($event)"
></auth-input-password>
</ng-container>

View File

@@ -0,0 +1,126 @@
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { UserId } from "@bitwarden/common/types/guid";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "../../../../components/src/toast";
import { InputPasswordComponent } from "../input-password/input-password.component";
import { PasswordInputResult } from "../input-password/password-input-result";
import {
SetPasswordCredentials,
SetPasswordJitService,
} from "./set-password-jit.service.abstraction";
@Component({
standalone: true,
selector: "auth-set-password-jit",
templateUrl: "set-password-jit.component.html",
imports: [CommonModule, InputPasswordComponent, JslibModule],
})
export class SetPasswordJitComponent implements OnInit {
protected email: string;
protected masterPasswordPolicyOptions: MasterPasswordPolicyOptions;
protected orgId: string;
protected orgSsoIdentifier: string;
protected resetPasswordAutoEnroll: boolean;
protected submitting = false;
protected syncLoading = true;
protected userId: UserId;
constructor(
private accountService: AccountService,
private activatedRoute: ActivatedRoute,
private i18nService: I18nService,
private organizationApiService: OrganizationApiServiceAbstraction,
private policyApiService: PolicyApiServiceAbstraction,
private router: Router,
private setPasswordJitService: SetPasswordJitService,
private syncService: SyncService,
private toastService: ToastService,
private validationService: ValidationService,
) {}
async ngOnInit() {
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
await this.syncService.fullSync(true);
this.syncLoading = false;
await this.handleQueryParams();
}
private async handleQueryParams() {
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
if (qParams.identifier != null) {
try {
this.orgSsoIdentifier = qParams.identifier;
const autoEnrollStatus = await this.organizationApiService.getAutoEnrollStatus(
this.orgSsoIdentifier,
);
this.orgId = autoEnrollStatus.id;
this.resetPasswordAutoEnroll = autoEnrollStatus.resetPasswordEnabled;
this.masterPasswordPolicyOptions =
await this.policyApiService.getMasterPasswordPolicyOptsForOrgUser(autoEnrollStatus.id);
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
}
}
protected async handlePasswordFormSubmit(passwordInputResult: PasswordInputResult) {
this.submitting = true;
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
const credentials: SetPasswordCredentials = {
...passwordInputResult,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
userId,
};
try {
await this.setPasswordJitService.setPassword(credentials);
} catch (e) {
this.validationService.showError(e);
this.submitting = false;
return;
}
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("accountSuccessfullyCreated"),
});
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("inviteAccepted"),
});
this.submitting = false;
await this.router.navigate(["vault"]);
}
}

View File

@@ -0,0 +1,33 @@
import { PBKDF2KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey } from "@bitwarden/common/types/key";
export interface SetPasswordCredentials {
masterKey: MasterKey;
masterKeyHash: string;
localMasterKeyHash: string;
kdfConfig: PBKDF2KdfConfig;
hint: string;
orgSsoIdentifier: string;
orgId: string;
resetPasswordAutoEnroll: boolean;
userId: UserId;
}
/**
* This service handles setting a password for a "just-in-time" provisioned user.
*
* A "just-in-time" (JIT) provisioned user is a user who does not have a registered account at the
* time they first click "Login with SSO". Once they click "Login with SSO" we register the account on
* the fly ("just-in-time").
*/
export abstract class SetPasswordJitService {
/**
* Sets the password for a JIT provisioned user.
*
* @param credentials An object of the credentials needed to set the password for a JIT provisioned user
* @throws If any property on the `credentials` object is null or undefined, or if a protectedUserKey
* or newKeyPair could not be created.
*/
setPassword: (credentials: SetPasswordCredentials) => Promise<void>;
}

View File

@@ -30,6 +30,7 @@ import {
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
import { CreateAuthRequest } from "../auth/models/request/create-auth.request";
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
import { EmailRequest } from "../auth/models/request/email.request";
import { PasswordTokenRequest } from "../auth/models/request/identity-token/password-token.request";
@@ -323,6 +324,9 @@ export abstract class ApiService {
putTwoFactorAuthenticator: (
request: UpdateTwoFactorAuthenticatorRequest,
) => Promise<TwoFactorAuthenticatorResponse>;
deleteTwoFactorAuthenticator: (
request: DisableTwoFactorAuthenticatorRequest,
) => Promise<TwoFactorProviderResponse>;
putTwoFactorEmail: (request: UpdateTwoFactorEmailRequest) => Promise<TwoFactorEmailResponse>;
putTwoFactorDuo: (request: UpdateTwoFactorDuoRequest) => Promise<TwoFactorDuoResponse>;
putTwoFactorOrganizationDuo: (

View File

@@ -0,0 +1,6 @@
import { TwoFactorProviderRequest } from "./two-factor-provider.request";
export class DisableTwoFactorAuthenticatorRequest extends TwoFactorProviderRequest {
key: string;
userVerificationToken: string;
}

View File

@@ -3,4 +3,5 @@ import { SecretVerificationRequest } from "./secret-verification.request";
export class UpdateTwoFactorAuthenticatorRequest extends SecretVerificationRequest {
token: string;
key: string;
userVerificationToken: string;
}

View File

@@ -3,10 +3,12 @@ import { BaseResponse } from "../../../models/response/base.response";
export class TwoFactorAuthenticatorResponse extends BaseResponse {
enabled: boolean;
key: string;
userVerificationToken: string;
constructor(response: any) {
super(response);
this.enabled = this.getResponseProperty("Enabled");
this.key = this.getResponseProperty("Key");
this.userVerificationToken = this.getResponseProperty("UserVerificationToken");
}
}

View File

@@ -27,6 +27,7 @@ export enum FeatureFlag {
VaultBulkManagementAction = "vault-bulk-management-action",
AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page",
DeviceTrustLogging = "pm-8285-device-trust-logging",
AuthenticatorTwoFactorToken = "authenticator-2fa-token",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -64,6 +65,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE,
[FeatureFlag.DeviceTrustLogging]: FALSE,
[FeatureFlag.AuthenticatorTwoFactorToken]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -37,6 +37,7 @@ import { SelectionReadOnlyResponse } from "../admin-console/models/response/sele
import { TokenService } from "../auth/abstractions/token.service";
import { CreateAuthRequest } from "../auth/models/request/create-auth.request";
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
import { EmailRequest } from "../auth/models/request/email.request";
import { DeviceRequest } from "../auth/models/request/identity-token/device.request";
@@ -998,6 +999,13 @@ export class ApiService implements ApiServiceAbstraction {
return new TwoFactorAuthenticatorResponse(r);
}
async deleteTwoFactorAuthenticator(
request: DisableTwoFactorAuthenticatorRequest,
): Promise<TwoFactorProviderResponse> {
const r = await this.send("DELETE", "/two-factor/authenticator", request, true, true);
return new TwoFactorProviderResponse(r);
}
async putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise<TwoFactorEmailResponse> {
const r = await this.send("PUT", "/two-factor/email", request, true, true);
return new TwoFactorEmailResponse(r);

View File

@@ -1,53 +0,0 @@
import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
import { GeneratedCredential, GeneratorCategory } from "../history";
/** Tracks the history of password generations.
* Each user gets their own store.
*/
export abstract class GeneratorHistoryService {
/** Tracks a new credential. When an item with the same `credential` value
* is found, this method does nothing. When the total number of items exceeds
* {@link HistoryServiceOptions.maxTotal}, then the oldest items exceeding the total
* are deleted.
* @param userId identifies the user storing the credential.
* @param credential stored by the history service.
* @param date when the credential was generated. If this is omitted, then the generator
* uses the date the credential was added to the store instead.
* @returns a promise that completes with the added credential. If the credential
* wasn't added, then the promise completes with `null`.
* @remarks this service is not suitable for use with vault items/ciphers. It models only
* a history of an individually generated credential, while a vault item's history
* may contain several credentials that are better modelled as atomic versions of the
* vault item itself.
*/
track: (
userId: UserId,
credential: string,
category: GeneratorCategory,
date?: Date,
) => Promise<GeneratedCredential | null>;
/** Removes a matching credential from the history service.
* @param userId identifies the user taking the credential.
* @param credential to match in the history service.
* @returns A promise that completes with the credential read. If the credential wasn't found,
* the promise completes with null.
* @remarks this can be used to extract an entry when a credential is stored in the vault.
*/
take: (userId: UserId, credential: string) => Promise<GeneratedCredential | null>;
/** Deletes a user's credential history.
* @param userId identifies the user taking the credential.
* @returns A promise that completes when the history is cleared.
*/
clear: (userId: UserId) => Promise<GeneratedCredential[]>;
/** Lists all credentials for a user.
* @param userId identifies the user listing the credential.
* @remarks This field is eventually consistent with `track` and `take` operations.
* It is not guaranteed to immediately reflect those changes.
*/
credentials$: (userId: UserId) => Observable<GeneratedCredential[]>;
}

View File

@@ -1,42 +0,0 @@
import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
import { GeneratorNavigation } from "../navigation/generator-navigation";
import { GeneratorNavigationPolicy } from "../navigation/generator-navigation-policy";
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
/** Loads and stores generator navigational data
*/
export abstract class GeneratorNavigationService {
/** An observable monitoring the options saved to disk.
* The observable updates when the options are saved.
* @param userId: Identifies the user making the request
*/
options$: (userId: UserId) => Observable<GeneratorNavigation>;
/** Gets the default options. */
defaults$: (userId: UserId) => Observable<GeneratorNavigation>;
/** An observable monitoring the options used to enforce policy.
* The observable updates when the policy changes.
* @param userId: Identifies the user making the request
*/
evaluator$: (
userId: UserId,
) => Observable<PolicyEvaluator<GeneratorNavigationPolicy, GeneratorNavigation>>;
/** Enforces the policy on the given options
* @param userId: Identifies the user making the request
* @param options the options to enforce the policy on
* @returns a new instance of the options with the policy enforced
*/
enforcePolicy: (userId: UserId, options: GeneratorNavigation) => Promise<GeneratorNavigation>;
/** Saves the navigation options to disk.
* @param userId: Identifies the user making the request
* @param options the options to save
* @returns a promise that resolves when the options are saved
*/
saveOptions: (userId: UserId, options: GeneratorNavigation) => Promise<void>;
}

Some files were not shown because too many files have changed in this diff Show More