diff --git a/apps/browser/src/autofill/services/autofill-constants.ts b/apps/browser/src/autofill/services/autofill-constants.ts index 1bab721067..9cf2b6848c 100644 --- a/apps/browser/src/autofill/services/autofill-constants.ts +++ b/apps/browser/src/autofill/services/autofill-constants.ts @@ -90,6 +90,7 @@ export class CreditCardAutoFillConstants { "data-stripe", "htmlName", "htmlID", + "title", "label-tag", "placeholder", "label-left", @@ -299,6 +300,54 @@ export class CreditCardAutoFillConstants { "cb-type", ]; + static readonly CardExpiryDateDelimiters: string[] = ["/", "-", ".", " "]; + + // Note, these are expressions of user-guidance for the expected expiry date format to be used + static readonly CardExpiryDateFormats: CardExpiryDateFormat[] = [ + // English + { + Month: "mm", + MonthShort: "m", + Year: "yyyy", + YearShort: "yy", + }, + // Danish + { + Month: "mm", + MonthShort: "m", + Year: "åååå", + YearShort: "åå", + }, + // German/Dutch + { + Month: "mm", + MonthShort: "m", + Year: "jjjj", + YearShort: "jj", + }, + // French/Spanish/Italian + { + Month: "mm", + MonthShort: "m", + Year: "aa", + YearShort: "aa", + }, + // Russian + { + Month: "мм", + MonthShort: "м", + Year: "гггг", + YearShort: "гг", + }, + // Portuguese + { + Month: "mm", + MonthShort: "m", + Year: "rrrr", + YearShort: "rr", + }, + ]; + // Each index represents a language. These three arrays should all be the same length. // 0: English, 1: Danish, 2: German/Dutch, 3: French/Spanish/Italian, 4: Russian, 5: Portuguese static readonly MonthAbbr = ["mm", "mm", "mm", "mm", "мм", "mm"]; @@ -306,6 +355,13 @@ export class CreditCardAutoFillConstants { static readonly YearAbbrLong = ["yyyy", "åååå", "jjjj", "aa", "гггг", "rrrr"]; } +export type CardExpiryDateFormat = { + Month: string; + MonthShort: string; + Year: string; + YearShort: string; +}; + export class IdentityAutoFillConstants { static readonly IdentityAttributes: string[] = [ "autoCompleteType", diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index b1c372655c..455c171e59 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -2475,10 +2475,10 @@ describe("AutofillService", () => { options.cipher.card = mock(); }); - it("returns null if the passed options contains a cipher with no card view", () => { + it("returns null if the passed options contains a cipher with no card view", async () => { options.cipher.card = undefined; - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2499,7 +2499,7 @@ describe("AutofillService", () => { untrustedIframe: false, }; - it("returns an unmodified fill script when the field is a `span` field", () => { + it("returns an unmodified fill script when the field is a `span` field", async () => { const spanField = createAutofillFieldMock({ opid: "span-field", form: "validFormId", @@ -2510,7 +2510,7 @@ describe("AutofillService", () => { pageDetails.fields = [spanField]; jest.spyOn(AutofillService, "isExcludedFieldType"); - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2522,7 +2522,7 @@ describe("AutofillService", () => { }); AutoFillConstants.ExcludedAutofillTypes.forEach((excludedType) => { - it(`returns an unmodified fill script when the field has a '${excludedType}' type`, () => { + it(`returns an unmodified fill script when the field has a '${excludedType}' type`, async () => { const invalidField = createAutofillFieldMock({ opid: `${excludedType}-field`, form: "validFormId", @@ -2533,7 +2533,7 @@ describe("AutofillService", () => { pageDetails.fields = [invalidField]; jest.spyOn(AutofillService, "isExcludedFieldType"); - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2548,7 +2548,7 @@ describe("AutofillService", () => { }); }); - it("returns an unmodified fill script when the field is not viewable", () => { + it("returns an unmodified fill script when the field is not viewable", async () => { const notViewableField = createAutofillFieldMock({ opid: "invalid-field", form: "validFormId", @@ -2561,7 +2561,7 @@ describe("AutofillService", () => { jest.spyOn(AutofillService, "forCustomFieldsOnly"); jest.spyOn(AutofillService, "isExcludedFieldType"); - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2663,8 +2663,8 @@ describe("AutofillService", () => { jest.spyOn(autofillService as any, "makeScriptActionWithValue"); }); - it("returns a fill script containing all of the passed card fields", () => { - const value = autofillService["generateCardFillScript"]( + it("returns a fill script containing all of the passed card fields", async () => { + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2745,11 +2745,11 @@ describe("AutofillService", () => { options.cipher.card.expMonth = "05"; }); - it("returns an expiration month parsed from found select options within the field", () => { + it("returns an expiration month parsed from found select options within the field", async () => { const testValue = "sometestvalue"; expMonthField.selectInfo.options[4] = ["May", testValue]; - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2759,12 +2759,12 @@ describe("AutofillService", () => { expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]); }); - it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the end of the list of options", () => { + it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the end of the list of options", async () => { const testValue = "sometestvalue"; expMonthField.selectInfo.options[4] = ["May", testValue]; expMonthField.selectInfo.options.push(["", ""]); - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2774,12 +2774,12 @@ describe("AutofillService", () => { expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]); }); - it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the start of the list of options", () => { + it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the start of the list of options", async () => { const testValue = "sometestvalue"; expMonthField.selectInfo.options[4] = ["May", testValue]; expMonthField.selectInfo.options.unshift(["", ""]); - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2789,13 +2789,13 @@ describe("AutofillService", () => { expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]); }); - it("returns an expiration month with a zero attached if the field requires two characters, and the vault item has only one character", () => { + it("returns an expiration month with a zero attached if the field requires two characters, and the vault item has only one character", async () => { options.cipher.card.expMonth = "5"; expMonthField.selectInfo = null; expMonthField.placeholder = "mm"; expMonthField.maxLength = 2; - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2830,12 +2830,12 @@ describe("AutofillService", () => { options.cipher.card.expYear = "2024"; }); - it("returns an expiration year parsed from the select options if an exact match is found for either the select option text or value", () => { + it("returns an expiration year parsed from the select options if an exact match is found for either the select option text or value", async () => { const someTestValue = "sometestvalue"; expYearField.selectInfo.options[1] = ["2024", someTestValue]; options.cipher.card.expYear = someTestValue; - let value = autofillService["generateCardFillScript"]( + let value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2846,7 +2846,7 @@ describe("AutofillService", () => { expYearField.selectInfo.options[1] = [someTestValue, "2024"]; - value = autofillService["generateCardFillScript"]( + value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2856,12 +2856,12 @@ describe("AutofillService", () => { expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, someTestValue]); }); - it("returns an expiration year parsed from the select options if the value of an option contains only two characters and the vault item value contains four characters", () => { + it("returns an expiration year parsed from the select options if the value of an option contains only two characters and the vault item value contains four characters", async () => { const yearValue = "26"; expYearField.selectInfo.options.push(["The year 2026", yearValue]); options.cipher.card.expYear = "2026"; - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2871,13 +2871,13 @@ describe("AutofillService", () => { expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, yearValue]); }); - it("returns an expiration year parsed from the select options if the vault of an option is separated by a colon", () => { + it("returns an expiration year parsed from the select options if the vault of an option is separated by a colon", async () => { const yearValue = "26"; const colonSeparatedYearValue = `2:0${yearValue}`; expYearField.selectInfo.options.push(["The year 2026", colonSeparatedYearValue]); options.cipher.card.expYear = yearValue; - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2891,14 +2891,14 @@ describe("AutofillService", () => { ]); }); - it("returns an expiration year with `20` prepended to the vault item value if the field to be filled expects a `yyyy` format but the vault item only has two characters", () => { + it("returns an expiration year with `20` prepended to the vault item value if the field to be filled expects a `yyyy` format but the vault item only has two characters", async () => { const yearValue = "26"; expYearField.selectInfo = null; expYearField.placeholder = "yyyy"; expYearField.maxLength = 4; options.cipher.card.expYear = yearValue; - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2912,14 +2912,14 @@ describe("AutofillService", () => { ]); }); - it("returns an expiration year with only the last two values if the field to be filled expects a `yy` format but the vault item contains four characters", () => { + it("returns an expiration year with only the last two values if the field to be filled expects a `yy` format but the vault item contains four characters", async () => { const yearValue = "26"; expYearField.selectInfo = null; expYearField.placeholder = "yy"; expYearField.maxLength = 2; options.cipher.card.expYear = `20${yearValue}`; - const value = autofillService["generateCardFillScript"]( + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2930,11 +2930,26 @@ describe("AutofillService", () => { }); }); + const expectedDateFormats = [ + ["mm/yyyy", "05/2024"], + ["mm/yy", "05/24"], + ["yyyy/mm", "2024/05"], + ["yy/mm", "24/05"], + ["mm-yyyy", "05-2024"], + ["mm-yy", "05-24"], + ["yyyy-mm", "2024-05"], + ["yy-mm", "24-05"], + ["yyyymm", "202405"], + ["yymm", "2405"], + ["mmyyyy", "052024"], + ["mmyy", "0524"], + ]; describe("given a generic expiration date field", () => { let expirationDateField: AutofillField; let expirationDateFieldView: FieldView; beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(false); expirationDateField = createAutofillFieldMock({ opid: "expirationDate", form: "validFormId", @@ -2949,23 +2964,11 @@ describe("AutofillService", () => { options.cipher.card.expYear = "2024"; }); - const expectedDateFormats = [ - ["mm/yyyy", "05/2024"], - ["mm/yy", "05/24"], - ["yyyy/mm", "2024/05"], - ["yy/mm", "24/05"], - ["mm-yyyy", "05-2024"], - ["mm-yy", "05-24"], - ["yyyy-mm", "2024-05"], - ["yy-mm", "24-05"], - ["yyyymm", "202405"], - ["yymm", "2405"], - ["mmyyyy", "052024"], - ["mmyy", "0524"], - ]; expectedDateFormats.forEach((dateFormat, index) => { - it(`returns an expiration date format matching '${dateFormat[0]}'`, () => { + it(`returns an expiration date format matching '${dateFormat[0]}'`, async () => { expirationDateField.placeholder = dateFormat[0]; + + // test alternate stored cipher value formats if (index === 0) { options.cipher.card.expYear = "24"; } @@ -2973,7 +2976,13 @@ describe("AutofillService", () => { options.cipher.card.expMonth = "5"; } - const value = autofillService["generateCardFillScript"]( + const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag( + FeatureFlag.EnableNewCardCombinedExpiryAutofill, + ); + + expect(enableNewCardCombinedExpiryAutofill).toEqual(false); + + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, @@ -2984,17 +2993,128 @@ describe("AutofillService", () => { }); }); - it("returns an expiration date format matching `yyyy-mm` if no valid format can be identified", () => { - const value = autofillService["generateCardFillScript"]( + it("returns an expiration date format matching `yyyy-mm` if no valid format can be identified", async () => { + const value = await autofillService["generateCardFillScript"]( fillScript, pageDetails, filledFields, options, ); + const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag( + FeatureFlag.EnableNewCardCombinedExpiryAutofill, + ); + + expect(enableNewCardCombinedExpiryAutofill).toEqual(false); + expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", "2024-05"]); }); }); + + const extraExpectedDateFormats = [ + ...expectedDateFormats, + ["m yy", "5 24"], + ["m yyyy", "5 2024"], + ["m-yy", "5-24"], + ["m-yyyy", "5-2024"], + ["m.yy", "5.24"], + ["m.yyyy", "5.2024"], + ["m/yy", "5/24"], + ["m/yyyy", "5/2024"], + ["mm åååå", "05 2024"], + ["mm yy", "05 24"], + ["mm yyyy", "05 2024"], + ["mm.yy", "05.24"], + ["mm.yyyy", "05.2024"], + ["myy", "524"], + ["myyyy", "52024"], + ["yy m", "24 5"], + ["yy mm", "24 05"], + ["yy mm", "24 05"], + ["yy-m", "24-5"], + ["yy.m", "24.5"], + ["yy.mm", "24.05"], + ["yy/m", "24/5"], + ["yym", "245"], + ["yyyy m", "2024 5"], + ["yyyy mm", "2024 05"], + ["yyyy-m", "2024-5"], + ["yyyy.m", "2024.5"], + ["yyyy.mm", "2024.05"], + ["yyyy/m", "2024/5"], + ["yyyym", "20245"], + ["мм гг", "05 24"], + ]; + describe("given a generic expiration date field with the `enable-new-card-combined-expiry-autofill` feature-flag enabled", () => { + let expirationDateField: AutofillField; + let expirationDateFieldView: FieldView; + + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue(true); + expirationDateField = createAutofillFieldMock({ + opid: "expirationDate", + form: "validFormId", + elementNumber: 3, + htmlName: "expiration-date", + }); + filledFields["exp-field"] = expirationDateField; + expirationDateFieldView = mock({ name: "exp" }); + pageDetails.fields = [expirationDateField]; + options.cipher.fields = [expirationDateFieldView]; + options.cipher.card.expMonth = "05"; + options.cipher.card.expYear = "2024"; + }); + + afterEach(() => { + configService.getFeatureFlag.mockResolvedValue(false); + }); + + extraExpectedDateFormats.forEach((dateFormat, index) => { + it(`feature-flagged logic returns an expiration date format matching '${dateFormat[0]}'`, async () => { + expirationDateField.placeholder = dateFormat[0]; + + // test alternate stored cipher value formats + if (index === 0) { + options.cipher.card.expYear = "24"; + } + if (index === 1) { + options.cipher.card.expMonth = "05"; + } + + const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag( + FeatureFlag.EnableNewCardCombinedExpiryAutofill, + ); + + expect(enableNewCardCombinedExpiryAutofill).toEqual(true); + + const value = await autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", dateFormat[1]]); + }); + }); + + it("feature-flagged logic returns an expiration date format matching `mm/yy` if no valid format can be identified", async () => { + const value = await autofillService["generateCardFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag( + FeatureFlag.EnableNewCardCombinedExpiryAutofill, + ); + + expect(enableNewCardCombinedExpiryAutofill).toEqual(true); + + expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", "05/24"]); + }); + }); }); describe("inUntrustedIframe", () => { diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 1002ca9922..49d00624f3 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -26,6 +26,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { FieldType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; @@ -50,6 +51,7 @@ import { } from "./abstractions/autofill.service"; import { AutoFillConstants, + CardExpiryDateFormat, CreditCardAutoFillConstants, IdentityAutoFillConstants, } from "./autofill-constants"; @@ -721,7 +723,12 @@ export default class AutofillService implements AutofillServiceInterface { ); break; case CipherType.Card: - fillScript = this.generateCardFillScript(fillScript, pageDetails, filledFields, options); + fillScript = await this.generateCardFillScript( + fillScript, + pageDetails, + filledFields, + options, + ); break; case CipherType.Identity: fillScript = await this.generateIdentityFillScript( @@ -937,12 +944,12 @@ export default class AutofillService implements AutofillServiceInterface { * @returns {AutofillScript|null} * @private */ - private generateCardFillScript( + private async generateCardFillScript( fillScript: AutofillScript, pageDetails: AutofillPageDetails, filledFields: { [id: string]: AutofillField }, options: GenerateFillScriptOptions, - ): AutofillScript | null { + ): Promise { if (!options.cipher.card) { return null; } @@ -1027,6 +1034,7 @@ export default class AutofillService implements AutofillServiceInterface { this.makeScriptAction(fillScript, card, fillFields, filledFields, "code"); this.makeScriptAction(fillScript, card, fillFields, filledFields, "brand"); + // There is an expiration month field and the cipher has an expiration month value if (fillFields.expMonth && AutofillService.hasValue(card.expMonth)) { let expMonth: string = card.expMonth; @@ -1065,6 +1073,7 @@ export default class AutofillService implements AutofillServiceInterface { AutofillService.fillByOpid(fillScript, fillFields.expMonth, expMonth); } + // There is an expiration year field and the cipher has an expiration year value if (fillFields.expYear && AutofillService.hasValue(card.expYear)) { let expYear: string = card.expYear; if (fillFields.expYear.selectInfo && fillFields.expYear.selectInfo.options) { @@ -1111,142 +1120,174 @@ export default class AutofillService implements AutofillServiceInterface { AutofillService.fillByOpid(fillScript, fillFields.expYear, expYear); } + // There is a single expiry date field (combined values) and the cipher has both expiration month and year if ( fillFields.exp && AutofillService.hasValue(card.expMonth) && AutofillService.hasValue(card.expYear) ) { - const fullMonth = ("0" + card.expMonth).slice(-2); + let combinedExpiryFillValue = null; - let fullYear: string = card.expYear; - let partYear: string = null; - if (fullYear.length === 2) { - partYear = fullYear; - fullYear = normalizeExpiryYearFormat(fullYear); - } else if (fullYear.length === 4) { - partYear = fullYear.substr(2, 2); - } + const enableNewCardCombinedExpiryAutofill = await this.configService.getFeatureFlag( + FeatureFlag.EnableNewCardCombinedExpiryAutofill, + ); - let exp: string = null; - for (let i = 0; i < CreditCardAutoFillConstants.MonthAbbr.length; i++) { - if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.MonthAbbr[i] + - "/" + - CreditCardAutoFillConstants.YearAbbrLong[i], - ) - ) { - exp = fullMonth + "/" + fullYear; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.MonthAbbr[i] + - "/" + - CreditCardAutoFillConstants.YearAbbrShort[i], - ) && - partYear != null - ) { - exp = fullMonth + "/" + partYear; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.YearAbbrLong[i] + - "/" + - CreditCardAutoFillConstants.MonthAbbr[i], - ) - ) { - exp = fullYear + "/" + fullMonth; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.YearAbbrShort[i] + - "/" + - CreditCardAutoFillConstants.MonthAbbr[i], - ) && - partYear != null - ) { - exp = partYear + "/" + fullMonth; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.MonthAbbr[i] + - "-" + - CreditCardAutoFillConstants.YearAbbrLong[i], - ) - ) { - exp = fullMonth + "-" + fullYear; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.MonthAbbr[i] + - "-" + - CreditCardAutoFillConstants.YearAbbrShort[i], - ) && - partYear != null - ) { - exp = fullMonth + "-" + partYear; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.YearAbbrLong[i] + - "-" + - CreditCardAutoFillConstants.MonthAbbr[i], - ) - ) { - exp = fullYear + "-" + fullMonth; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.YearAbbrShort[i] + - "-" + - CreditCardAutoFillConstants.MonthAbbr[i], - ) && - partYear != null - ) { - exp = partYear + "-" + fullMonth; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.YearAbbrLong[i] + CreditCardAutoFillConstants.MonthAbbr[i], - ) - ) { - exp = fullYear + fullMonth; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.YearAbbrShort[i] + CreditCardAutoFillConstants.MonthAbbr[i], - ) && - partYear != null - ) { - exp = partYear + fullMonth; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.YearAbbrLong[i], - ) - ) { - exp = fullMonth + fullYear; - } else if ( - this.fieldAttrsContain( - fillFields.exp, - CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.YearAbbrShort[i], - ) && - partYear != null - ) { - exp = fullMonth + partYear; + if (enableNewCardCombinedExpiryAutofill) { + combinedExpiryFillValue = this.generateCombinedExpiryValue(card, fillFields.exp); + } else { + const fullMonth = ("0" + card.expMonth).slice(-2); + + let fullYear: string = card.expYear; + let partYear: string = null; + if (fullYear.length === 2) { + partYear = fullYear; + fullYear = normalizeExpiryYearFormat(fullYear); + } else if (fullYear.length === 4) { + partYear = fullYear.substr(2, 2); } - if (exp != null) { - break; + for (let i = 0; i < CreditCardAutoFillConstants.MonthAbbr.length; i++) { + if ( + // mm/yyyy + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.MonthAbbr[i] + + "/" + + CreditCardAutoFillConstants.YearAbbrLong[i], + ) + ) { + combinedExpiryFillValue = fullMonth + "/" + fullYear; + } else if ( + // mm/yy + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.MonthAbbr[i] + + "/" + + CreditCardAutoFillConstants.YearAbbrShort[i], + ) && + partYear != null + ) { + combinedExpiryFillValue = fullMonth + "/" + partYear; + } else if ( + // yyyy/mm + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.YearAbbrLong[i] + + "/" + + CreditCardAutoFillConstants.MonthAbbr[i], + ) + ) { + combinedExpiryFillValue = fullYear + "/" + fullMonth; + } else if ( + // yy/mm + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.YearAbbrShort[i] + + "/" + + CreditCardAutoFillConstants.MonthAbbr[i], + ) && + partYear != null + ) { + combinedExpiryFillValue = partYear + "/" + fullMonth; + } else if ( + // mm-yyyy + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.MonthAbbr[i] + + "-" + + CreditCardAutoFillConstants.YearAbbrLong[i], + ) + ) { + combinedExpiryFillValue = fullMonth + "-" + fullYear; + } else if ( + // mm-yy + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.MonthAbbr[i] + + "-" + + CreditCardAutoFillConstants.YearAbbrShort[i], + ) && + partYear != null + ) { + combinedExpiryFillValue = fullMonth + "-" + partYear; + } else if ( + // yyyy-mm + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.YearAbbrLong[i] + + "-" + + CreditCardAutoFillConstants.MonthAbbr[i], + ) + ) { + combinedExpiryFillValue = fullYear + "-" + fullMonth; + } else if ( + // yy-mm + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.YearAbbrShort[i] + + "-" + + CreditCardAutoFillConstants.MonthAbbr[i], + ) && + partYear != null + ) { + combinedExpiryFillValue = partYear + "-" + fullMonth; + } else if ( + // yyyymm + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.YearAbbrLong[i] + + CreditCardAutoFillConstants.MonthAbbr[i], + ) + ) { + combinedExpiryFillValue = fullYear + fullMonth; + } else if ( + // yymm + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.YearAbbrShort[i] + + CreditCardAutoFillConstants.MonthAbbr[i], + ) && + partYear != null + ) { + combinedExpiryFillValue = partYear + fullMonth; + } else if ( + // mmyyyy + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.MonthAbbr[i] + + CreditCardAutoFillConstants.YearAbbrLong[i], + ) + ) { + combinedExpiryFillValue = fullMonth + fullYear; + } else if ( + // mmyy + this.fieldAttrsContain( + fillFields.exp, + CreditCardAutoFillConstants.MonthAbbr[i] + + CreditCardAutoFillConstants.YearAbbrShort[i], + ) && + partYear != null + ) { + combinedExpiryFillValue = fullMonth + partYear; + } + + if (combinedExpiryFillValue != null) { + break; + } + } + + // If none of the previous cases applied, set as default + if (combinedExpiryFillValue == null) { + combinedExpiryFillValue = fullYear + "-" + fullMonth; } } - if (exp == null) { - exp = fullYear + "-" + fullMonth; - } - - this.makeScriptActionWithValue(fillScript, exp, fillFields.exp, filledFields); + this.makeScriptActionWithValue( + fillScript, + combinedExpiryFillValue, + fillFields.exp, + filledFields, + ); } return fillScript; @@ -1287,28 +1328,169 @@ export default class AutofillService implements AutofillServiceInterface { * Used when handling autofill on credit card fields. Determines whether * the field has an attribute that matches the given value. * @param {AutofillField} field - * @param {string} containsVal + * @param {string} containsValue * @returns {boolean} * @private */ - private fieldAttrsContain(field: AutofillField, containsVal: string): boolean { + private fieldAttrsContain(field: AutofillField, containsValue: string): boolean { if (!field) { return false; } - let doesContain = false; - CreditCardAutoFillConstants.CardAttributesExtended.forEach((attr) => { - // eslint-disable-next-line - if (doesContain || !field.hasOwnProperty(attr) || !field[attr]) { + let doesContainValue = false; + CreditCardAutoFillConstants.CardAttributesExtended.forEach((attributeName) => { + // eslint-disable-next-line no-prototype-builtins + if (doesContainValue || !field[attributeName]) { return; } - let val = field[attr]; - val = val.replace(/ /g, "").toLowerCase(); - doesContain = val.indexOf(containsVal) > -1; + let fieldValue = field[attributeName]; + fieldValue = fieldValue.replace(/ /g, "").toLowerCase(); + doesContainValue = fieldValue.indexOf(containsValue) > -1; }); - return doesContain; + return doesContainValue; + } + + /** + * Returns a string value representation of the combined card expiration month and year values + * in a format matching discovered guidance within the field attributes (typically provided for users). + * + * @param {CardView} cardCipher + * @param {AutofillField} field + */ + private generateCombinedExpiryValue(cardCipher: CardView, field: AutofillField): string { + /* + Some expectations of the passed stored card cipher view: + + - At the time of writing, the stored card expiry year value (`expYear`) + can be any arbitrary string (no format validation). We may attempt some format + normalization here, but expect the user to have entered a string of integers + with a length of 2 or 4 + + - the `expiration` property cannot be used for autofill as it is an opinionated + format + + - `expMonth` a stringified integer stored with no zero-padding and is not + zero-indexed (e.g. January is "1", not "01" or 0) + */ + + // Expiry format options + let useMonthPadding = true; + let useYearFull = false; + let delimiter = "/"; + let orderByYear = false; + + // Because users are allowed to store truncated years, we need to make assumptions + // about the full year format when called for + const currentCentury = `${new Date().getFullYear()}`.slice(0, 2); + + // Note, we construct the output rather than doing string replacement against the + // format guidance pattern to avoid edge cases that would output invalid values + const [ + // The guidance parsed from the field properties regarding expiry format + expectedExpiryDateFormat, + // The (localized) date pattern set that was used to parse the expiry format guidance + expiryDateFormatPatterns, + ] = this.getExpectedExpiryDateFormat(field); + + if (expectedExpiryDateFormat) { + const { Month, MonthShort, Year } = expiryDateFormatPatterns; + + const expiryDateDelimitersPattern = + "\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\"); + + // assign the delimiter from the expected format string + delimiter = + expectedExpiryDateFormat.match(new RegExp(`[${expiryDateDelimitersPattern}]`, "g"))?.[0] || + ""; + + // check if the expected format starts with a month form + // order matters here; check long form first, since short form will match against long + if (expectedExpiryDateFormat.indexOf(Month + delimiter) === 0) { + useMonthPadding = true; + orderByYear = false; + } else if (expectedExpiryDateFormat.indexOf(MonthShort + delimiter) === 0) { + useMonthPadding = false; + orderByYear = false; + } else { + orderByYear = true; + + // short form can match against long form, but long won't match against short + const containsLongMonthPattern = new RegExp(`${Month}`, "i"); + useMonthPadding = containsLongMonthPattern.test(expectedExpiryDateFormat); + } + + const containsLongYearPattern = new RegExp(`${Year}`, "i"); + + useYearFull = containsLongYearPattern.test(expectedExpiryDateFormat); + } + + const month = useMonthPadding + ? // Ensure zero-padding + ("0" + cardCipher.expMonth).slice(-2) + : // Handle zero-padded stored month values, even though they are not _expected_ to be as such + cardCipher.expMonth.replaceAll("0", ""); + // Note: assumes the user entered an `expYear` value with a length of either 2 or 4 + const year = (currentCentury + cardCipher.expYear).slice(useYearFull ? -4 : -2); + + const combinedExpiryFillValue = (orderByYear ? [year, month] : [month, year]).join(delimiter); + + return combinedExpiryFillValue; + } + + /** + * Returns a string value representation of discovered guidance for a combined month and year expiration value from the field attributes + * + * @param {AutofillField} field + */ + private getExpectedExpiryDateFormat( + field: AutofillField, + ): [string | null, CardExpiryDateFormat | null] { + let expectedDateFormat = null; + let dateFormatPatterns = null; + + const expiryDateDelimitersPattern = + "\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\"); + + CreditCardAutoFillConstants.CardExpiryDateFormats.find((dateFormat) => { + dateFormatPatterns = dateFormat; + + const { Month, MonthShort, YearShort, Year } = dateFormat; + + // Non-exhaustive coverage of field guidances. Some uncovered edge cases: ". " delimiter, space-delimited delimiters ("mm / yyyy"). + // We should consider if added whitespace is for improved readability of user-guidance or actually desired in the filled value. + // e.g. "/((mm|m)[\/\-\.\ ]{0,1}(yyyy|yy))|((yyyy|yy)[\/\-\.\ ]{0,1}(mm|m))/gi" + const dateFormatPattern = new RegExp( + `((${Month}|${MonthShort})[${expiryDateDelimitersPattern}]{0,1}(${Year}|${YearShort}))|((${Year}|${YearShort})[${expiryDateDelimitersPattern}]{0,1}(${Month}|${MonthShort}))`, + "gi", + ); + + return CreditCardAutoFillConstants.CardAttributesExtended.find((attributeName) => { + const fieldAttributeValue = field[attributeName]; + + const fieldAttributeMatch = fieldAttributeValue?.match(dateFormatPattern); + // break find as soon as a match is found + + if (fieldAttributeMatch?.length) { + expectedDateFormat = fieldAttributeMatch[0]; + + // remove any irrelevant characters + const irrelevantExpiryCharactersPattern = new RegExp( + // "or digits" to ensure numbers are removed from guidance pattern, which aren't covered by ^\w + `[^\\w${expiryDateDelimitersPattern}]|[\\d]`, + "gi", + ); + expectedDateFormat.replaceAll(irrelevantExpiryCharactersPattern, ""); + + return true; + } + + return false; + }); + }); + + return [expectedDateFormat, dateFormatPatterns]; } /** diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index a2423dc1a9..53baea14c5 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -30,6 +30,7 @@ export enum FeatureFlag { UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh", EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub", GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor", + EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill", DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2", AccountDeprovisioning = "pm-10308-account-deprovisioning", NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements", @@ -76,6 +77,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE, [FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE, [FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE, + [FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE, [FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE, [FeatureFlag.StorageReseedRefactor]: FALSE, [FeatureFlag.AccountDeprovisioning]: FALSE,