diff --git a/dist/autofill-debug.js b/dist/autofill-debug.js index 2511af76a..042e81694 100644 --- a/dist/autofill-debug.js +++ b/dist/autofill-debug.js @@ -9476,13 +9476,17 @@ class InterfacePrototype { postSubmit(values, form) { if (!form.form) return; if (!form.hasValues(values)) return; - const checks = [form.shouldPromptToStoreData && !form.submitHandlerExecuted, this.passwordGenerator.generated]; + const isUsernameOnly = Object.keys(values?.credentials || {}).length === 1 && values?.credentials?.username; + const checks = [form.shouldPromptToStoreData && !form.submitHandlerExecuted, this.passwordGenerator.generated, isUsernameOnly]; if (checks.some(Boolean)) { const formData = (0, _Credentials.appendGeneratedKey)(values, { password: this.passwordGenerator.password, username: this.emailProtection.lastGenerated }); - this.storeFormData(formData, 'formSubmission'); + + // If credentials has only username field, and no password field, then trigger is a partialSave + const trigger = isUsernameOnly ? 'partialSave' : 'formSubmission'; + this.storeFormData(formData, trigger); } } @@ -11921,6 +11925,7 @@ Object.defineProperty(exports, "__esModule", { exports.prepareFormValuesForStorage = exports.inferCountryCodeFromElement = exports.getUnifiedExpiryDate = exports.getMMAndYYYYFromString = exports.getCountryName = exports.getCountryDisplayName = exports.formatPhoneNumber = exports.formatFullName = exports.formatCCYear = void 0; var _matching = require("./matching.js"); var _countryNames = require("./countryNames.js"); +var _autofillUtils = require("../autofill-utils.js"); // Matches strings like mm/yy, mm-yyyy, mm-aa, 12 / 2024 const DATE_SEPARATOR_REGEX = /\b((.)\2{1,3}|\d+)(?\s?[/\s.\-_—–]\s?)((.)\5{1,3}|\d+)\b/i; // Matches 4 non-digit repeated characters (YYYY or AAAA) or 4 digits (2022) @@ -12093,21 +12098,10 @@ const getMMAndYYYYFromString = expiration => { * @return {boolean} */ exports.getMMAndYYYYFromString = getMMAndYYYYFromString; -const shouldStoreCredentials = _ref3 => { - let { - credentials - } = _ref3; - return Boolean(credentials.password); -}; - -/** - * @param {InternalDataStorageObject} credentials - * @return {boolean} - */ -const shouldStoreIdentities = _ref4 => { +const shouldStoreIdentities = _ref3 => { let { identities - } = _ref4; + } = _ref3; return Boolean((identities.firstName || identities.fullName) && identities.addressStreet && identities.addressCity); }; @@ -12115,10 +12109,10 @@ const shouldStoreIdentities = _ref4 => { * @param {InternalDataStorageObject} credentials * @return {boolean} */ -const shouldStoreCreditCards = _ref5 => { +const shouldStoreCreditCards = _ref4 => { let { creditCards - } = _ref5; + } = _ref4; if (!creditCards.cardNumber) return false; if (creditCards.cardSecurityCode) return true; // Some forms (Amazon) don't have the cvv, so we still save if there's the expiration @@ -12154,14 +12148,14 @@ const prepareFormValuesForStorage = formValues => { creditCards.cardName = identities?.fullName || formatFullName(identities); } - /** Fixes for credentials **/ - // Don't store if there isn't enough data - if (shouldStoreCredentials(formValues)) { - // If we don't have a username to match a password, let's see if the email is available - if (credentials.password && !credentials.username && identities.emailAddress) { - credentials.username = identities.emailAddress; - } - } else { + /** Fixes for credentials */ + if (!credentials.username && (0, _autofillUtils.hasUsernameLikeIdentity)(identities)) { + // @ts-ignore - We know that username is not a useful value here + credentials.username = identities.emailAddress || identities.phone; + } + + // If we still don't have any credentials, we discard the object + if (Object.keys(credentials ?? {}).length === 0) { credentials = undefined; } @@ -12217,7 +12211,7 @@ const prepareFormValuesForStorage = formValues => { }; exports.prepareFormValuesForStorage = prepareFormValuesForStorage; -},{"./countryNames.js":36,"./matching.js":44}],38:[function(require,module,exports){ +},{"../autofill-utils.js":64,"./countryNames.js":36,"./matching.js":44}],38:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -17154,7 +17148,9 @@ exports.formatDuckAddress = void 0; exports.getActiveElement = getActiveElement; exports.getDaxBoundingBox = void 0; exports.getFormControlElements = getFormControlElements; -exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = void 0; +exports.getTextShallow = void 0; +exports.hasUsernameLikeIdentity = hasUsernameLikeIdentity; +exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = void 0; exports.isFormLikelyToBeUsedAsPageWrapper = isFormLikelyToBeUsedAsPageWrapper; exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedConfig = void 0; exports.isLocalNetwork = isLocalNetwork; @@ -17822,6 +17818,15 @@ function queryElementsWithShadow(element, selector) { return [...elements]; } +/** + * Checks if there is a single username-like identity, i.e. email or phone + * @param {InternalIdentityObject} identities + * @returns {boolean} + */ +function hasUsernameLikeIdentity(identities) { + return Object.keys(identities ?? {}).length === 1 && Boolean(identities?.emailAddress || identities.phone); +} + },{"./Form/matching.js":44,"./constants.js":67,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],65:[function(require,module,exports){ "use strict"; @@ -18495,7 +18500,7 @@ const getAutofillDataResponseSchema = exports.getAutofillDataResponseSchema = _z }); const storeFormDataSchema = exports.storeFormDataSchema = _zod.z.object({ credentials: outgoingCredentialsSchema.optional(), - trigger: _zod.z.union([_zod.z.literal("formSubmission"), _zod.z.literal("passwordGeneration"), _zod.z.literal("emailProtection")]).optional() + trigger: _zod.z.union([_zod.z.literal("partialSave"), _zod.z.literal("formSubmission"), _zod.z.literal("passwordGeneration"), _zod.z.literal("emailProtection")]).optional() }); const getAvailableInputTypesResultSchema = exports.getAvailableInputTypesResultSchema = _zod.z.object({ type: _zod.z.literal("getAvailableInputTypesResponse").optional(), diff --git a/dist/autofill.js b/dist/autofill.js index fac51c928..fd485bd79 100644 --- a/dist/autofill.js +++ b/dist/autofill.js @@ -5113,13 +5113,17 @@ class InterfacePrototype { postSubmit(values, form) { if (!form.form) return; if (!form.hasValues(values)) return; - const checks = [form.shouldPromptToStoreData && !form.submitHandlerExecuted, this.passwordGenerator.generated]; + const isUsernameOnly = Object.keys(values?.credentials || {}).length === 1 && values?.credentials?.username; + const checks = [form.shouldPromptToStoreData && !form.submitHandlerExecuted, this.passwordGenerator.generated, isUsernameOnly]; if (checks.some(Boolean)) { const formData = (0, _Credentials.appendGeneratedKey)(values, { password: this.passwordGenerator.password, username: this.emailProtection.lastGenerated }); - this.storeFormData(formData, 'formSubmission'); + + // If credentials has only username field, and no password field, then trigger is a partialSave + const trigger = isUsernameOnly ? 'partialSave' : 'formSubmission'; + this.storeFormData(formData, trigger); } } @@ -7558,6 +7562,7 @@ Object.defineProperty(exports, "__esModule", { exports.prepareFormValuesForStorage = exports.inferCountryCodeFromElement = exports.getUnifiedExpiryDate = exports.getMMAndYYYYFromString = exports.getCountryName = exports.getCountryDisplayName = exports.formatPhoneNumber = exports.formatFullName = exports.formatCCYear = void 0; var _matching = require("./matching.js"); var _countryNames = require("./countryNames.js"); +var _autofillUtils = require("../autofill-utils.js"); // Matches strings like mm/yy, mm-yyyy, mm-aa, 12 / 2024 const DATE_SEPARATOR_REGEX = /\b((.)\2{1,3}|\d+)(?\s?[/\s.\-_—–]\s?)((.)\5{1,3}|\d+)\b/i; // Matches 4 non-digit repeated characters (YYYY or AAAA) or 4 digits (2022) @@ -7730,21 +7735,10 @@ const getMMAndYYYYFromString = expiration => { * @return {boolean} */ exports.getMMAndYYYYFromString = getMMAndYYYYFromString; -const shouldStoreCredentials = _ref3 => { - let { - credentials - } = _ref3; - return Boolean(credentials.password); -}; - -/** - * @param {InternalDataStorageObject} credentials - * @return {boolean} - */ -const shouldStoreIdentities = _ref4 => { +const shouldStoreIdentities = _ref3 => { let { identities - } = _ref4; + } = _ref3; return Boolean((identities.firstName || identities.fullName) && identities.addressStreet && identities.addressCity); }; @@ -7752,10 +7746,10 @@ const shouldStoreIdentities = _ref4 => { * @param {InternalDataStorageObject} credentials * @return {boolean} */ -const shouldStoreCreditCards = _ref5 => { +const shouldStoreCreditCards = _ref4 => { let { creditCards - } = _ref5; + } = _ref4; if (!creditCards.cardNumber) return false; if (creditCards.cardSecurityCode) return true; // Some forms (Amazon) don't have the cvv, so we still save if there's the expiration @@ -7791,14 +7785,14 @@ const prepareFormValuesForStorage = formValues => { creditCards.cardName = identities?.fullName || formatFullName(identities); } - /** Fixes for credentials **/ - // Don't store if there isn't enough data - if (shouldStoreCredentials(formValues)) { - // If we don't have a username to match a password, let's see if the email is available - if (credentials.password && !credentials.username && identities.emailAddress) { - credentials.username = identities.emailAddress; - } - } else { + /** Fixes for credentials */ + if (!credentials.username && (0, _autofillUtils.hasUsernameLikeIdentity)(identities)) { + // @ts-ignore - We know that username is not a useful value here + credentials.username = identities.emailAddress || identities.phone; + } + + // If we still don't have any credentials, we discard the object + if (Object.keys(credentials ?? {}).length === 0) { credentials = undefined; } @@ -7854,7 +7848,7 @@ const prepareFormValuesForStorage = formValues => { }; exports.prepareFormValuesForStorage = prepareFormValuesForStorage; -},{"./countryNames.js":26,"./matching.js":34}],28:[function(require,module,exports){ +},{"../autofill-utils.js":54,"./countryNames.js":26,"./matching.js":34}],28:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12791,7 +12785,9 @@ exports.formatDuckAddress = void 0; exports.getActiveElement = getActiveElement; exports.getDaxBoundingBox = void 0; exports.getFormControlElements = getFormControlElements; -exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = void 0; +exports.getTextShallow = void 0; +exports.hasUsernameLikeIdentity = hasUsernameLikeIdentity; +exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = void 0; exports.isFormLikelyToBeUsedAsPageWrapper = isFormLikelyToBeUsedAsPageWrapper; exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedConfig = void 0; exports.isLocalNetwork = isLocalNetwork; @@ -13459,6 +13455,15 @@ function queryElementsWithShadow(element, selector) { return [...elements]; } +/** + * Checks if there is a single username-like identity, i.e. email or phone + * @param {InternalIdentityObject} identities + * @returns {boolean} + */ +function hasUsernameLikeIdentity(identities) { + return Object.keys(identities ?? {}).length === 1 && Boolean(identities?.emailAddress || identities.phone); +} + },{"./Form/matching.js":34,"./constants.js":57,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],55:[function(require,module,exports){ "use strict"; diff --git a/integration-test/helpers/pages/loginPage.js b/integration-test/helpers/pages/loginPage.js index f7e7c95fa..a7121f8d9 100644 --- a/integration-test/helpers/pages/loginPage.js +++ b/integration-test/helpers/pages/loginPage.js @@ -215,6 +215,12 @@ export function loginPage(page, opts = {}) { expect(mockCalls.length).toBe(0); } + async shouldPromptToSave() { + let mockCalls = []; + mockCalls = await mockedCalls(page, { names: ['storeFormData'] }); + expect(mockCalls.length).toBeGreaterThan(0); + } + /** * This is used mostly to avoid false negatives when we check for something _not_ happening. * Basically, you check that a specific call hasn't happened but the rest of the script ran just fine. diff --git a/integration-test/tests/save-prompts.android.spec.js b/integration-test/tests/save-prompts.android.spec.js index bf0e55e24..38095b556 100644 --- a/integration-test/tests/save-prompts.android.spec.js +++ b/integration-test/tests/save-prompts.android.spec.js @@ -81,11 +81,11 @@ test.describe('Android Save prompts', () => { await login.submitPasswordOnlyForm(credentials); await login.assertWasPromptedToSave(credentials); }); - test('with username only (should NOT prompt)', async ({ page }) => { + test('with username only (should prompt)', async ({ page }) => { const { login } = await setup(page); const credentials = { username: '123456' }; await login.submitUsernameOnlyForm(credentials.username); - await login.promptWasNotShown(); + await login.shouldPromptToSave(); }); }); }); diff --git a/integration-test/tests/save-prompts.ios.spec.js b/integration-test/tests/save-prompts.ios.spec.js index d93f95f92..ff0801b35 100644 --- a/integration-test/tests/save-prompts.ios.spec.js +++ b/integration-test/tests/save-prompts.ios.spec.js @@ -105,12 +105,12 @@ test.describe('iOS Save prompts', () => { await login.assertWasPromptedToSave(credentials); }); - test('username only (should NOT prompt)', async ({ page }) => { + test('username only (should prompt)', async ({ page }) => { const login = await setup(page); const credentials = { username: '123456' }; await login.submitUsernameOnlyForm(credentials.username); - await login.shouldNotPromptToSave(); + await login.shouldPromptToSave(); }); }); diff --git a/integration-test/tests/save-prompts.macos.spec.js b/integration-test/tests/save-prompts.macos.spec.js index b78cf3ddb..a4d5e8321 100644 --- a/integration-test/tests/save-prompts.macos.spec.js +++ b/integration-test/tests/save-prompts.macos.spec.js @@ -64,7 +64,7 @@ test.describe('macos', () => { await login.submitPasswordOnlyForm(credentials); await login.assertWasPromptedToSave(credentials); }); - test('username only (should NOT prompt)', async ({ page }) => { + test('username only (should prompt)', async ({ page }) => { // enable in-terminal exceptions await forwardConsoleMessages(page); @@ -76,7 +76,7 @@ test.describe('macos', () => { const login = loginPage(page); await login.navigate(); await login.submitUsernameOnlyForm(credentials.username); - await login.shouldNotPromptToSave(); + await login.shouldPromptToSave(); }); }); }); diff --git a/src/DeviceInterface/InterfacePrototype.js b/src/DeviceInterface/InterfacePrototype.js index afeb0531d..51fb4398f 100644 --- a/src/DeviceInterface/InterfacePrototype.js +++ b/src/DeviceInterface/InterfacePrototype.js @@ -799,14 +799,18 @@ class InterfacePrototype { postSubmit(values, form) { if (!form.form) return; if (!form.hasValues(values)) return; - const checks = [form.shouldPromptToStoreData && !form.submitHandlerExecuted, this.passwordGenerator.generated]; + const isUsernameOnly = Object.keys(values?.credentials || {}).length === 1 && values?.credentials?.username; + const checks = [form.shouldPromptToStoreData && !form.submitHandlerExecuted, this.passwordGenerator.generated, isUsernameOnly]; if (checks.some(Boolean)) { const formData = appendGeneratedKey(values, { password: this.passwordGenerator.password, username: this.emailProtection.lastGenerated, }); - this.storeFormData(formData, 'formSubmission'); + + // If credentials has only username field, and no password field, then trigger is a partialSave + const trigger = isUsernameOnly ? 'partialSave' : 'formSubmission'; + this.storeFormData(formData, trigger); } } diff --git a/src/Form/Form.test.js b/src/Form/Form.test.js index 86e916019..bf69ff3e9 100644 --- a/src/Form/Form.test.js +++ b/src/Form/Form.test.js @@ -84,8 +84,8 @@ describe('Test the form class reading values correctly', () => { `, - expHasValues: false, - expValues: { credentials: undefined }, + expHasValues: true, + expValues: { credentials: { username: 'testUsername' } }, }, { testCase: 'form where the password is <=3 characters long', @@ -95,8 +95,8 @@ describe('Test the form class reading values correctly', () => { `, - expHasValues: false, - expValues: { credentials: undefined }, + expHasValues: true, + expValues: { credentials: { username: 'testUsername' } }, }, { testCase: 'form with hidden email field', diff --git a/src/Form/formatters.js b/src/Form/formatters.js index a9ad6eb02..976145926 100644 --- a/src/Form/formatters.js +++ b/src/Form/formatters.js @@ -1,5 +1,6 @@ import { matchInPlaceholderAndLabels, checkPlaceholderAndLabels } from './matching.js'; import { COUNTRY_CODES_TO_NAMES, COUNTRY_NAMES_TO_CODES } from './countryNames.js'; +import { hasUsernameLikeIdentity } from '../autofill-utils.js'; // Matches strings like mm/yy, mm-yyyy, mm-aa, 12 / 2024 const DATE_SEPARATOR_REGEX = /\b((.)\2{1,3}|\d+)(?\s?[/\s.\-_—–]\s?)((.)\5{1,3}|\d+)\b/i; @@ -159,12 +160,6 @@ const getMMAndYYYYFromString = (expiration) => { ); }; -/** - * @param {InternalDataStorageObject} credentials - * @return {boolean} - */ -const shouldStoreCredentials = ({ credentials }) => Boolean(credentials.password); - /** * @param {InternalDataStorageObject} credentials * @return {boolean} @@ -207,14 +202,14 @@ const prepareFormValuesForStorage = (formValues) => { creditCards.cardName = identities?.fullName || formatFullName(identities); } - /** Fixes for credentials **/ - // Don't store if there isn't enough data - if (shouldStoreCredentials(formValues)) { - // If we don't have a username to match a password, let's see if the email is available - if (credentials.password && !credentials.username && identities.emailAddress) { - credentials.username = identities.emailAddress; - } - } else { + /** Fixes for credentials */ + if (!credentials.username && hasUsernameLikeIdentity(identities)) { + // @ts-ignore - We know that username is not a useful value here + credentials.username = identities.emailAddress || identities.phone; + } + + // If we still don't have any credentials, we discard the object + if (Object.keys(credentials ?? {}).length === 0) { credentials = undefined; } diff --git a/src/Form/formatters.test.js b/src/Form/formatters.test.js index df3b1ed4d..0e12b5333 100644 --- a/src/Form/formatters.test.js +++ b/src/Form/formatters.test.js @@ -61,7 +61,7 @@ describe('Can strip phone formatting characters', () => { describe('prepareFormValuesForStorage()', () => { describe('handling credentials', () => { - it('rejects for username only', () => { + it('accepts for username only', () => { const values = prepareFormValuesForStorage({ credentials: { username: 'dax@example.com' }, // @ts-ignore @@ -69,7 +69,7 @@ describe('prepareFormValuesForStorage()', () => { // @ts-ignore identities: {}, }); - expect(values.credentials).toBeUndefined(); + expect(values.credentials?.username).toBe('dax@example.com'); }); it('accepts password only', () => { const values = prepareFormValuesForStorage({ diff --git a/src/autofill-utils.js b/src/autofill-utils.js index 96eba5b48..99a11fdc4 100644 --- a/src/autofill-utils.js +++ b/src/autofill-utils.js @@ -624,6 +624,15 @@ function queryElementsWithShadow(element, selector, forceScanShadowTree = false) return [...elements]; } +/** + * Checks if there is a single username-like identity, i.e. email or phone + * @param {InternalIdentityObject} identities + * @returns {boolean} + */ +function hasUsernameLikeIdentity(identities) { + return Object.keys(identities ?? {}).length === 1 && Boolean(identities?.emailAddress || identities.phone); +} + export { notifyWebApp, sendAndWaitForAnswer, @@ -659,4 +668,5 @@ export { findElementsInShadowTree, queryElementsWithShadow, getFormControlElements, + hasUsernameLikeIdentity, }; diff --git a/src/device-interface.d.ts b/src/device-interface.d.ts index 6d9f5d7d7..1d03ef557 100644 --- a/src/device-interface.d.ts +++ b/src/device-interface.d.ts @@ -71,7 +71,7 @@ interface DataStorageObject { credentials?: CredentialsObject; creditCards?: CreditCardObject; identities?: IdentityObject; - trigger?: 'formSubmission' | 'passwordGeneration' | 'emailProtection'; + trigger?: 'partialSave' | 'formSubmission' | 'passwordGeneration' | 'emailProtection'; } interface InternalDataStorageObject { diff --git a/src/deviceApiCalls/__generated__/validators-ts.ts b/src/deviceApiCalls/__generated__/validators-ts.ts index a7e1d8c4d..23787cf7c 100644 --- a/src/deviceApiCalls/__generated__/validators-ts.ts +++ b/src/deviceApiCalls/__generated__/validators-ts.ts @@ -389,7 +389,7 @@ export interface UserPreferences { */ export interface StoreFormData { credentials?: OutgoingCredentials; - trigger?: "formSubmission" | "passwordGeneration" | "emailProtection"; + trigger?: "partialSave" | "formSubmission" | "passwordGeneration" | "emailProtection"; } export interface OutgoingCredentials { /** diff --git a/src/deviceApiCalls/__generated__/validators.zod.js b/src/deviceApiCalls/__generated__/validators.zod.js index 0b2614d15..87ae05418 100644 --- a/src/deviceApiCalls/__generated__/validators.zod.js +++ b/src/deviceApiCalls/__generated__/validators.zod.js @@ -300,7 +300,7 @@ export const getAutofillDataResponseSchema = z.object({ export const storeFormDataSchema = z.object({ credentials: outgoingCredentialsSchema.optional(), - trigger: z.union([z.literal("formSubmission"), z.literal("passwordGeneration"), z.literal("emailProtection")]).optional() + trigger: z.union([z.literal("partialSave"), z.literal("formSubmission"), z.literal("passwordGeneration"), z.literal("emailProtection")]).optional() }); export const getAvailableInputTypesResultSchema = z.object({ diff --git a/src/deviceApiCalls/api.json b/src/deviceApiCalls/api.json index e6151e868..268380bb1 100644 --- a/src/deviceApiCalls/api.json +++ b/src/deviceApiCalls/api.json @@ -9,7 +9,9 @@ "type": "object", "description": "Register a new debug flag that will be included in breakage reports", "properties": { - "paramsValidator": { "$ref": "./schemas/addDebugFlag.params.json" } + "paramsValidator": { + "$ref": "./schemas/addDebugFlag.params.json" + } } }, "getAutofillData": { @@ -19,59 +21,91 @@ "type": "string", "const": "getAutofillDataResponse" }, - "paramsValidator": { "$ref": "./schemas/getAutofillData.params.json" }, - "resultValidator": { "$ref": "./schemas/getAutofillData.result.json" } + "paramsValidator": { + "$ref": "./schemas/getAutofillData.params.json" + }, + "resultValidator": { + "$ref": "./schemas/getAutofillData.result.json" + } } }, "getRuntimeConfiguration": { "type": "object", "properties": { - "id": { "type": "string", "const": "getRuntimeConfigurationResponse"}, - "resultValidator": { "$ref": "./schemas/getRuntimeConfiguration.result.json" } + "id": { + "type": "string", + "const": "getRuntimeConfigurationResponse" + }, + "resultValidator": { + "$ref": "./schemas/getRuntimeConfiguration.result.json" + } } }, "storeFormData": { "type": "object", "properties": { - "paramsValidator": { "$ref": "./schemas/storeFormData.params.json" } + "paramsValidator": { + "$ref": "./schemas/storeFormData.params.json" + } } }, "getAvailableInputTypes": { "type": "object", "properties": { - "id": { "type": "string", "const": "getAvailableInputTypesResponse"}, - "resultValidator": { "$ref": "./schemas/getAvailableInputTypes.result.json" } + "id": { + "type": "string", + "const": "getAvailableInputTypesResponse" + }, + "resultValidator": { + "$ref": "./schemas/getAvailableInputTypes.result.json" + } } }, "getAutofillInitData": { "type": "object", "description": "This is called inside an overlay (eg: on Windows or soon also on macOS) to retrieve available data", "properties": { - "id": { "type": "string", "const": "getAutofillInitDataResponse"}, - "resultValidator": { "$ref": "./schemas/getAutofillInitData.result.json" } + "id": { + "type": "string", + "const": "getAutofillInitDataResponse" + }, + "resultValidator": { + "$ref": "./schemas/getAutofillInitData.result.json" + } } }, "getAutofillCredentials": { "type": "object", "description": "Used to retrieve a specific set of credentials", "properties": { - "id": { "type": "string", "const": "getAutofillCredentialsResponse"}, - "paramsValidator": { "$ref": "./schemas/getAutofillCredentials.params.json" }, - "resultValidator": { "$ref": "./schemas/getAutofillCredentials.result.json" } + "id": { + "type": "string", + "const": "getAutofillCredentialsResponse" + }, + "paramsValidator": { + "$ref": "./schemas/getAutofillCredentials.params.json" + }, + "resultValidator": { + "$ref": "./schemas/getAutofillCredentials.result.json" + } } }, "setSize": { "type": "object", "description": "Used by Windows to communicate the desired size of the overlay to the native side", "properties": { - "paramsValidator": { "$ref": "./schemas/setSize.params.json" } + "paramsValidator": { + "$ref": "./schemas/setSize.params.json" + } } }, "selectedDetail": { "type": "object", "description": "Used by Windows to communicate a selected autofill item to the native side", "properties": { - "paramsValidator": { "$ref": "./schemas/selectedDetail.params.json" } + "paramsValidator": { + "$ref": "./schemas/selectedDetail.params.json" + } } }, "closeAutofillParent": { @@ -81,34 +115,53 @@ "askToUnlockProvider": { "type": "object", "properties": { - "id": { "type": "string", "const": "askToUnlockProviderResponse"}, - "resultValidator": { "$ref": "./schemas/askToUnlockProvider.result.json" } + "id": { + "type": "string", + "const": "askToUnlockProviderResponse" + }, + "resultValidator": { + "$ref": "./schemas/askToUnlockProvider.result.json" + } } }, "checkCredentialsProviderStatus": { "type": "object", "properties": { - "id": { "type": "string", "const": "checkCredentialsProviderStatusResponse"}, - "resultValidator": { "$ref": "./schemas/checkCredentialsProviderStatus.result.json" } + "id": { + "type": "string", + "const": "checkCredentialsProviderStatusResponse" + }, + "resultValidator": { + "$ref": "./schemas/checkCredentialsProviderStatus.result.json" + } } }, "sendJSPixel": { "type": "object", "properties": { - "paramsValidator": { "$ref": "./schemas/sendJSPixel.params.json" } + "paramsValidator": { + "$ref": "./schemas/sendJSPixel.params.json" + } } }, "setIncontextSignupPermanentlyDismissedAt": { "type": "object", "properties": { - "paramsValidator": { "$ref": "./schemas/setIncontextSignupPermanentlyDismissedAt.params.json" } + "paramsValidator": { + "$ref": "./schemas/setIncontextSignupPermanentlyDismissedAt.params.json" + } } }, "getIncontextSignupDismissedAt": { "type": "object", "properties": { - "id": { "type": "string", "const": "getIncontextSignupDismissedAt"}, - "resultValidator": { "$ref": "./schemas/getIncontextSignupDismissedAt.result.json" } + "id": { + "type": "string", + "const": "getIncontextSignupDismissedAt" + }, + "resultValidator": { + "$ref": "./schemas/getIncontextSignupDismissedAt.result.json" + } } }, "autofillSettings": { @@ -118,7 +171,9 @@ "type": "boolean", "const": true }, - "resultValidator": { "$ref": "./schemas/autofill-settings.json" } + "resultValidator": { + "$ref": "./schemas/autofill-settings.json" + } } }, "getAlias": { @@ -128,8 +183,12 @@ "type": "boolean", "const": true }, - "paramValidator": { "$ref": "./schemas/getAlias.params.json" }, - "resultValidator": { "$ref": "./schemas/getAlias.result.json" } + "paramValidator": { + "$ref": "./schemas/getAlias.params.json" + }, + "resultValidator": { + "$ref": "./schemas/getAlias.result.json" + } } }, "openManagePasswords": { @@ -156,8 +215,13 @@ "type": "object", "description": "Used to store Email Protection auth credentials (logging in)", "properties": { - "id": { "type": "string", "const": "emailProtectionStoreUserDataResponse"}, - "paramsValidator": { "$ref": "./schemas/emailProtectionStoreUserData.params.json" } + "id": { + "type": "string", + "const": "emailProtectionStoreUserDataResponse" + }, + "paramsValidator": { + "$ref": "./schemas/emailProtectionStoreUserData.params.json" + } } }, "emailProtectionRemoveUserData": { @@ -168,40 +232,65 @@ "type": "object", "description": "Used to get check if a user is logged in to Email Protection", "properties": { - "id": { "type": "string", "const": "emailProtectionGetIsLoggedInResponse"}, - "resultValidator": { "$ref": "./schemas/emailProtectionGetIsLoggedIn.result.json" } + "id": { + "type": "string", + "const": "emailProtectionGetIsLoggedInResponse" + }, + "resultValidator": { + "$ref": "./schemas/emailProtectionGetIsLoggedIn.result.json" + } } }, "emailProtectionGetUserData": { "type": "object", "description": "Used to get Email Protection auth credentials", "properties": { - "id": { "type": "string", "const": "emailProtectionGetUserDataResponse"}, - "resultValidator": { "$ref": "./schemas/emailProtectionGetUserData.result.json" } + "id": { + "type": "string", + "const": "emailProtectionGetUserDataResponse" + }, + "resultValidator": { + "$ref": "./schemas/emailProtectionGetUserData.result.json" + } } }, "emailProtectionGetCapabilities": { "type": "object", "description": "Used by the Email Protection web app to determine which API functionality is available", "properties": { - "id": { "type": "string", "const": "emailProtectionGetCapabilitiesResponse"}, - "resultValidator": { "$ref": "./schemas/emailProtectionGetCapabilities.result.json" } + "id": { + "type": "string", + "const": "emailProtectionGetCapabilitiesResponse" + }, + "resultValidator": { + "$ref": "./schemas/emailProtectionGetCapabilities.result.json" + } } }, "emailProtectionGetAddresses": { "type": "object", "description": "Used to get both Email Protection addresses (personal and private)", "properties": { - "id": { "type": "string", "const": "emailProtectionGetAddressesResponse"}, - "resultValidator": { "$ref": "./schemas/emailProtectionGetAddresses.result.json" } + "id": { + "type": "string", + "const": "emailProtectionGetAddressesResponse" + }, + "resultValidator": { + "$ref": "./schemas/emailProtectionGetAddresses.result.json" + } } }, "emailProtectionRefreshPrivateAddress": { "type": "object", "description": "Used to refresh Email Protection private address and get both Email Protection addresses (personal and private)", "properties": { - "id": { "type": "string", "const": "emailProtectionRefreshPrivateAddressResponse"}, - "resultValidator": { "$ref": "./schemas/emailProtectionRefreshPrivateAddress.result.json" } + "id": { + "type": "string", + "const": "emailProtectionRefreshPrivateAddressResponse" + }, + "resultValidator": { + "$ref": "./schemas/emailProtectionRefreshPrivateAddress.result.json" + } } }, "startEmailProtectionSignup": { @@ -216,9 +305,14 @@ "type": "object", "description": "Used by Android to open the in-context signup prompt and report back when completed", "properties": { - "id": { "type": "string", "const": "ShowInContextEmailProtectionSignupPromptResponse"}, - "resultValidator": { "$ref": "./schemas/showInContextEmailProtectionSignupPrompt.result.json" } + "id": { + "type": "string", + "const": "ShowInContextEmailProtectionSignupPromptResponse" + }, + "resultValidator": { + "$ref": "./schemas/showInContextEmailProtectionSignupPrompt.result.json" + } } } } -} +} \ No newline at end of file diff --git a/src/deviceApiCalls/schemas/storeFormData.params.json b/src/deviceApiCalls/schemas/storeFormData.params.json index 1c68e9ce1..9be93231c 100644 --- a/src/deviceApiCalls/schemas/storeFormData.params.json +++ b/src/deviceApiCalls/schemas/storeFormData.params.json @@ -23,6 +23,7 @@ "trigger": { "type": "string", "enum": [ + "partialSave", "formSubmission", "passwordGeneration", "emailProtection" diff --git a/swift-package/Resources/assets/autofill-debug.js b/swift-package/Resources/assets/autofill-debug.js index 2511af76a..042e81694 100644 --- a/swift-package/Resources/assets/autofill-debug.js +++ b/swift-package/Resources/assets/autofill-debug.js @@ -9476,13 +9476,17 @@ class InterfacePrototype { postSubmit(values, form) { if (!form.form) return; if (!form.hasValues(values)) return; - const checks = [form.shouldPromptToStoreData && !form.submitHandlerExecuted, this.passwordGenerator.generated]; + const isUsernameOnly = Object.keys(values?.credentials || {}).length === 1 && values?.credentials?.username; + const checks = [form.shouldPromptToStoreData && !form.submitHandlerExecuted, this.passwordGenerator.generated, isUsernameOnly]; if (checks.some(Boolean)) { const formData = (0, _Credentials.appendGeneratedKey)(values, { password: this.passwordGenerator.password, username: this.emailProtection.lastGenerated }); - this.storeFormData(formData, 'formSubmission'); + + // If credentials has only username field, and no password field, then trigger is a partialSave + const trigger = isUsernameOnly ? 'partialSave' : 'formSubmission'; + this.storeFormData(formData, trigger); } } @@ -11921,6 +11925,7 @@ Object.defineProperty(exports, "__esModule", { exports.prepareFormValuesForStorage = exports.inferCountryCodeFromElement = exports.getUnifiedExpiryDate = exports.getMMAndYYYYFromString = exports.getCountryName = exports.getCountryDisplayName = exports.formatPhoneNumber = exports.formatFullName = exports.formatCCYear = void 0; var _matching = require("./matching.js"); var _countryNames = require("./countryNames.js"); +var _autofillUtils = require("../autofill-utils.js"); // Matches strings like mm/yy, mm-yyyy, mm-aa, 12 / 2024 const DATE_SEPARATOR_REGEX = /\b((.)\2{1,3}|\d+)(?\s?[/\s.\-_—–]\s?)((.)\5{1,3}|\d+)\b/i; // Matches 4 non-digit repeated characters (YYYY or AAAA) or 4 digits (2022) @@ -12093,21 +12098,10 @@ const getMMAndYYYYFromString = expiration => { * @return {boolean} */ exports.getMMAndYYYYFromString = getMMAndYYYYFromString; -const shouldStoreCredentials = _ref3 => { - let { - credentials - } = _ref3; - return Boolean(credentials.password); -}; - -/** - * @param {InternalDataStorageObject} credentials - * @return {boolean} - */ -const shouldStoreIdentities = _ref4 => { +const shouldStoreIdentities = _ref3 => { let { identities - } = _ref4; + } = _ref3; return Boolean((identities.firstName || identities.fullName) && identities.addressStreet && identities.addressCity); }; @@ -12115,10 +12109,10 @@ const shouldStoreIdentities = _ref4 => { * @param {InternalDataStorageObject} credentials * @return {boolean} */ -const shouldStoreCreditCards = _ref5 => { +const shouldStoreCreditCards = _ref4 => { let { creditCards - } = _ref5; + } = _ref4; if (!creditCards.cardNumber) return false; if (creditCards.cardSecurityCode) return true; // Some forms (Amazon) don't have the cvv, so we still save if there's the expiration @@ -12154,14 +12148,14 @@ const prepareFormValuesForStorage = formValues => { creditCards.cardName = identities?.fullName || formatFullName(identities); } - /** Fixes for credentials **/ - // Don't store if there isn't enough data - if (shouldStoreCredentials(formValues)) { - // If we don't have a username to match a password, let's see if the email is available - if (credentials.password && !credentials.username && identities.emailAddress) { - credentials.username = identities.emailAddress; - } - } else { + /** Fixes for credentials */ + if (!credentials.username && (0, _autofillUtils.hasUsernameLikeIdentity)(identities)) { + // @ts-ignore - We know that username is not a useful value here + credentials.username = identities.emailAddress || identities.phone; + } + + // If we still don't have any credentials, we discard the object + if (Object.keys(credentials ?? {}).length === 0) { credentials = undefined; } @@ -12217,7 +12211,7 @@ const prepareFormValuesForStorage = formValues => { }; exports.prepareFormValuesForStorage = prepareFormValuesForStorage; -},{"./countryNames.js":36,"./matching.js":44}],38:[function(require,module,exports){ +},{"../autofill-utils.js":64,"./countryNames.js":36,"./matching.js":44}],38:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -17154,7 +17148,9 @@ exports.formatDuckAddress = void 0; exports.getActiveElement = getActiveElement; exports.getDaxBoundingBox = void 0; exports.getFormControlElements = getFormControlElements; -exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = void 0; +exports.getTextShallow = void 0; +exports.hasUsernameLikeIdentity = hasUsernameLikeIdentity; +exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = void 0; exports.isFormLikelyToBeUsedAsPageWrapper = isFormLikelyToBeUsedAsPageWrapper; exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedConfig = void 0; exports.isLocalNetwork = isLocalNetwork; @@ -17822,6 +17818,15 @@ function queryElementsWithShadow(element, selector) { return [...elements]; } +/** + * Checks if there is a single username-like identity, i.e. email or phone + * @param {InternalIdentityObject} identities + * @returns {boolean} + */ +function hasUsernameLikeIdentity(identities) { + return Object.keys(identities ?? {}).length === 1 && Boolean(identities?.emailAddress || identities.phone); +} + },{"./Form/matching.js":44,"./constants.js":67,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],65:[function(require,module,exports){ "use strict"; @@ -18495,7 +18500,7 @@ const getAutofillDataResponseSchema = exports.getAutofillDataResponseSchema = _z }); const storeFormDataSchema = exports.storeFormDataSchema = _zod.z.object({ credentials: outgoingCredentialsSchema.optional(), - trigger: _zod.z.union([_zod.z.literal("formSubmission"), _zod.z.literal("passwordGeneration"), _zod.z.literal("emailProtection")]).optional() + trigger: _zod.z.union([_zod.z.literal("partialSave"), _zod.z.literal("formSubmission"), _zod.z.literal("passwordGeneration"), _zod.z.literal("emailProtection")]).optional() }); const getAvailableInputTypesResultSchema = exports.getAvailableInputTypesResultSchema = _zod.z.object({ type: _zod.z.literal("getAvailableInputTypesResponse").optional(), diff --git a/swift-package/Resources/assets/autofill.js b/swift-package/Resources/assets/autofill.js index fac51c928..fd485bd79 100644 --- a/swift-package/Resources/assets/autofill.js +++ b/swift-package/Resources/assets/autofill.js @@ -5113,13 +5113,17 @@ class InterfacePrototype { postSubmit(values, form) { if (!form.form) return; if (!form.hasValues(values)) return; - const checks = [form.shouldPromptToStoreData && !form.submitHandlerExecuted, this.passwordGenerator.generated]; + const isUsernameOnly = Object.keys(values?.credentials || {}).length === 1 && values?.credentials?.username; + const checks = [form.shouldPromptToStoreData && !form.submitHandlerExecuted, this.passwordGenerator.generated, isUsernameOnly]; if (checks.some(Boolean)) { const formData = (0, _Credentials.appendGeneratedKey)(values, { password: this.passwordGenerator.password, username: this.emailProtection.lastGenerated }); - this.storeFormData(formData, 'formSubmission'); + + // If credentials has only username field, and no password field, then trigger is a partialSave + const trigger = isUsernameOnly ? 'partialSave' : 'formSubmission'; + this.storeFormData(formData, trigger); } } @@ -7558,6 +7562,7 @@ Object.defineProperty(exports, "__esModule", { exports.prepareFormValuesForStorage = exports.inferCountryCodeFromElement = exports.getUnifiedExpiryDate = exports.getMMAndYYYYFromString = exports.getCountryName = exports.getCountryDisplayName = exports.formatPhoneNumber = exports.formatFullName = exports.formatCCYear = void 0; var _matching = require("./matching.js"); var _countryNames = require("./countryNames.js"); +var _autofillUtils = require("../autofill-utils.js"); // Matches strings like mm/yy, mm-yyyy, mm-aa, 12 / 2024 const DATE_SEPARATOR_REGEX = /\b((.)\2{1,3}|\d+)(?\s?[/\s.\-_—–]\s?)((.)\5{1,3}|\d+)\b/i; // Matches 4 non-digit repeated characters (YYYY or AAAA) or 4 digits (2022) @@ -7730,21 +7735,10 @@ const getMMAndYYYYFromString = expiration => { * @return {boolean} */ exports.getMMAndYYYYFromString = getMMAndYYYYFromString; -const shouldStoreCredentials = _ref3 => { - let { - credentials - } = _ref3; - return Boolean(credentials.password); -}; - -/** - * @param {InternalDataStorageObject} credentials - * @return {boolean} - */ -const shouldStoreIdentities = _ref4 => { +const shouldStoreIdentities = _ref3 => { let { identities - } = _ref4; + } = _ref3; return Boolean((identities.firstName || identities.fullName) && identities.addressStreet && identities.addressCity); }; @@ -7752,10 +7746,10 @@ const shouldStoreIdentities = _ref4 => { * @param {InternalDataStorageObject} credentials * @return {boolean} */ -const shouldStoreCreditCards = _ref5 => { +const shouldStoreCreditCards = _ref4 => { let { creditCards - } = _ref5; + } = _ref4; if (!creditCards.cardNumber) return false; if (creditCards.cardSecurityCode) return true; // Some forms (Amazon) don't have the cvv, so we still save if there's the expiration @@ -7791,14 +7785,14 @@ const prepareFormValuesForStorage = formValues => { creditCards.cardName = identities?.fullName || formatFullName(identities); } - /** Fixes for credentials **/ - // Don't store if there isn't enough data - if (shouldStoreCredentials(formValues)) { - // If we don't have a username to match a password, let's see if the email is available - if (credentials.password && !credentials.username && identities.emailAddress) { - credentials.username = identities.emailAddress; - } - } else { + /** Fixes for credentials */ + if (!credentials.username && (0, _autofillUtils.hasUsernameLikeIdentity)(identities)) { + // @ts-ignore - We know that username is not a useful value here + credentials.username = identities.emailAddress || identities.phone; + } + + // If we still don't have any credentials, we discard the object + if (Object.keys(credentials ?? {}).length === 0) { credentials = undefined; } @@ -7854,7 +7848,7 @@ const prepareFormValuesForStorage = formValues => { }; exports.prepareFormValuesForStorage = prepareFormValuesForStorage; -},{"./countryNames.js":26,"./matching.js":34}],28:[function(require,module,exports){ +},{"../autofill-utils.js":54,"./countryNames.js":26,"./matching.js":34}],28:[function(require,module,exports){ "use strict"; Object.defineProperty(exports, "__esModule", { @@ -12791,7 +12785,9 @@ exports.formatDuckAddress = void 0; exports.getActiveElement = getActiveElement; exports.getDaxBoundingBox = void 0; exports.getFormControlElements = getFormControlElements; -exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = exports.getTextShallow = void 0; +exports.getTextShallow = void 0; +exports.hasUsernameLikeIdentity = hasUsernameLikeIdentity; +exports.isEventWithinDax = exports.isAutofillEnabledFromProcessedConfig = void 0; exports.isFormLikelyToBeUsedAsPageWrapper = isFormLikelyToBeUsedAsPageWrapper; exports.isLikelyASubmitButton = exports.isIncontextSignupEnabledFromProcessedConfig = void 0; exports.isLocalNetwork = isLocalNetwork; @@ -13459,6 +13455,15 @@ function queryElementsWithShadow(element, selector) { return [...elements]; } +/** + * Checks if there is a single username-like identity, i.e. email or phone + * @param {InternalIdentityObject} identities + * @returns {boolean} + */ +function hasUsernameLikeIdentity(identities) { + return Object.keys(identities ?? {}).length === 1 && Boolean(identities?.emailAddress || identities.phone); +} + },{"./Form/matching.js":34,"./constants.js":57,"@duckduckgo/content-scope-scripts/src/apple-utils":1}],55:[function(require,module,exports){ "use strict";