From 1cd8b61176c4cfe617aeb465d0ef4d781a6e8ca6 Mon Sep 17 00:00:00 2001 From: plpmd Date: Tue, 14 Jul 2026 16:11:56 -0300 Subject: [PATCH 01/10] feat: add fieldClearLabel option --- packages/scripts/dist/interfaces/options.d.ts | 1 + packages/scripts/dist/remember-me.d.ts | 2 ++ packages/scripts/dist/remember-me.js | 6 ++++-- packages/scripts/src/interfaces/options.ts | 1 + packages/scripts/src/remember-me.ts | 9 +++++++-- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/scripts/dist/interfaces/options.d.ts b/packages/scripts/dist/interfaces/options.d.ts index 1646b529..712e730c 100644 --- a/packages/scripts/dist/interfaces/options.d.ts +++ b/packages/scripts/dist/interfaces/options.d.ts @@ -50,6 +50,7 @@ export interface Options { fieldOptInSelectorTargetLocation?: string; fieldClearSelectorTarget?: string; fieldClearSelectorTargetLocation?: string; + fieldClearLabel?: string; checked?: boolean; }; StickyNSG?: boolean; diff --git a/packages/scripts/dist/remember-me.d.ts b/packages/scripts/dist/remember-me.d.ts index 3dd43413..b456f030 100644 --- a/packages/scripts/dist/remember-me.d.ts +++ b/packages/scripts/dist/remember-me.d.ts @@ -17,6 +17,7 @@ export declare class RememberMe { private fieldOptInSelectorTargetLocation; private fieldClearSelectorTarget; private fieldClearSelectorTargetLocation; + private fieldClearLabel; constructor(options: { remoteUrl?: string; cookieName?: string; @@ -30,6 +31,7 @@ export declare class RememberMe { fieldOptInSelectorTargetLocation?: string; fieldClearSelectorTarget?: string; fieldClearSelectorTargetLocation?: string; + fieldClearLabel?: string; checked?: boolean; }); private updateFieldData; diff --git a/packages/scripts/dist/remember-me.js b/packages/scripts/dist/remember-me.js index ba8e0535..e16ca7d0 100644 --- a/packages/scripts/dist/remember-me.js +++ b/packages/scripts/dist/remember-me.js @@ -43,6 +43,9 @@ export class RememberMe { options.fieldClearSelectorTargetLocation ? options.fieldClearSelectorTargetLocation : "before"; + this.fieldClearLabel = options.fieldClearLabel + ? options.fieldClearLabel + : "(clear autofill)"; this.fieldData = {}; if (this.useRemote()) { this.createIframe(() => { @@ -109,12 +112,11 @@ export class RememberMe { insertClearRememberMeLink() { let clearRememberMeField = document.getElementById("clear-autofill-data"); if (!clearRememberMeField) { - const clearAutofillLabel = "clear autofill"; clearRememberMeField = document.createElement("a"); clearRememberMeField.setAttribute("id", "clear-autofill-data"); clearRememberMeField.classList.add("label-tooltip"); clearRememberMeField.setAttribute("style", "cursor: pointer;"); - clearRememberMeField.innerHTML = `(${clearAutofillLabel})`; + clearRememberMeField.innerHTML = this.fieldClearLabel; const targetField = this.getElementByFirstSelector(this.fieldClearSelectorTarget); if (targetField) { if (this.fieldClearSelectorTargetLocation === "after") { diff --git a/packages/scripts/src/interfaces/options.ts b/packages/scripts/src/interfaces/options.ts index c3d53a7a..a4b41694 100644 --- a/packages/scripts/src/interfaces/options.ts +++ b/packages/scripts/src/interfaces/options.ts @@ -54,6 +54,7 @@ export interface Options { fieldOptInSelectorTargetLocation?: string; fieldClearSelectorTarget?: string; fieldClearSelectorTargetLocation?: string; + fieldClearLabel?: string; checked?: boolean; }; StickyNSG?: boolean; diff --git a/packages/scripts/src/remember-me.ts b/packages/scripts/src/remember-me.ts index 7259cb6e..b2c1ae43 100644 --- a/packages/scripts/src/remember-me.ts +++ b/packages/scripts/src/remember-me.ts @@ -27,6 +27,7 @@ export class RememberMe { private fieldOptInSelectorTargetLocation: string; private fieldClearSelectorTarget: string; private fieldClearSelectorTargetLocation: string; + private fieldClearLabel: string; constructor(options: { remoteUrl?: string; @@ -41,6 +42,7 @@ export class RememberMe { fieldOptInSelectorTargetLocation?: string; fieldClearSelectorTarget?: string; fieldClearSelectorTargetLocation?: string; + fieldClearLabel?: string; checked?: boolean; }) { this.iframe = null; @@ -86,6 +88,10 @@ export class RememberMe { ? options.fieldClearSelectorTargetLocation : "before"; + this.fieldClearLabel = options.fieldClearLabel + ? options.fieldClearLabel + : "(clear autofill)"; + this.fieldData = {}; if (this.useRemote()) { this.createIframe( @@ -161,12 +167,11 @@ export class RememberMe { private insertClearRememberMeLink() { let clearRememberMeField = document.getElementById("clear-autofill-data"); if (!clearRememberMeField) { - const clearAutofillLabel = "clear autofill"; clearRememberMeField = document.createElement("a"); clearRememberMeField.setAttribute("id", "clear-autofill-data"); clearRememberMeField.classList.add("label-tooltip"); clearRememberMeField.setAttribute("style", "cursor: pointer;"); - clearRememberMeField.innerHTML = `(${clearAutofillLabel})`; + clearRememberMeField.innerHTML = this.fieldClearLabel; const targetField = this.getElementByFirstSelector( this.fieldClearSelectorTarget From eafcd1aa4e638d37eeba5ef8153b5d8f437275cb Mon Sep 17 00:00:00 2001 From: plpmd Date: Wed, 15 Jul 2026 14:37:42 -0300 Subject: [PATCH 02/10] feat: add encryption support --- packages/scripts/dist/interfaces/options.d.ts | 1 + packages/scripts/dist/remember-me.d.ts | 38 ++++ packages/scripts/dist/remember-me.js | 182 ++++++++++++++++- packages/scripts/src/interfaces/options.ts | 1 + packages/scripts/src/remember-me.ts | 185 +++++++++++++++++- reference-materials/docs/rememberme.md | 2 + 6 files changed, 407 insertions(+), 2 deletions(-) diff --git a/packages/scripts/dist/interfaces/options.d.ts b/packages/scripts/dist/interfaces/options.d.ts index 712e730c..aace87ef 100644 --- a/packages/scripts/dist/interfaces/options.d.ts +++ b/packages/scripts/dist/interfaces/options.d.ts @@ -52,6 +52,7 @@ export interface Options { fieldClearSelectorTargetLocation?: string; fieldClearLabel?: string; checked?: boolean; + encryptData?: boolean; }; StickyNSG?: boolean; StickyPrepopulation?: false | { diff --git a/packages/scripts/dist/remember-me.d.ts b/packages/scripts/dist/remember-me.d.ts index b456f030..415fe425 100644 --- a/packages/scripts/dist/remember-me.d.ts +++ b/packages/scripts/dist/remember-me.d.ts @@ -9,6 +9,7 @@ export declare class RememberMe { private cookieExpirationDays; private iframe; private rememberMeOptIn; + private encryptData; private fieldDonationAmountRadioName; private fieldDonationAmountOtherName; private fieldDonationRecurrPayRadioName; @@ -33,6 +34,7 @@ export declare class RememberMe { fieldClearSelectorTargetLocation?: string; fieldClearLabel?: string; checked?: boolean; + encryptData?: boolean; }); private updateFieldData; private insertClearRememberMeLink; @@ -45,6 +47,42 @@ export declare class RememberMe { private saveCookieToRemote; private readCookie; private saveCookie; + /** + * Reads and decrypts the local (non-remote) Remember Me cookie using + * browser-native AES-GCM (Web Crypto), with the key held in localStorage + * on this device. If the key is absent (different device or cleared + * storage) or decryption otherwise fails, the field data is left empty + * and the component falls back to the normal, no-autofill experience. + */ + private readCookieEncrypted; + /** + * Encrypts the current fieldData with AES-GCM (Web Crypto) and stores the + * base64-encoded result in the local cookie. If encryption isn't possible + * (e.g. Web Crypto unavailable), nothing is written. + */ + private saveCookieEncrypted; + private clearCookieEncrypted; + /** + * Retrieves the per-device AES-GCM encryption key. A random secret + * generated once per device and held in localStorage — never written + * to the cookie, so it never travels with the transported value. + */ + private getEncryptionKey; + /** + * Encrypts a plaintext string with AES-GCM and returns the base64-encoded + * IV + ciphertext, ready for storage. Returns null if a key isn't + * available (e.g. Web Crypto unsupported). + */ + private encryptPayload; + /** + * Decrypts a base64-encoded IV + ciphertext payload previously produced by + * encryptPayload. Returns null (rather than throwing) if the key is + * missing or decryption otherwise fails, so callers can gracefully fall + * back to the standard, no-autofill experience. + */ + private decryptPayload; + private arrayBufferToBase64; + private base64ToArrayBuffer; private readFields; private setFieldValue; private clearFields; diff --git a/packages/scripts/dist/remember-me.js b/packages/scripts/dist/remember-me.js index e16ca7d0..0e52665e 100644 --- a/packages/scripts/dist/remember-me.js +++ b/packages/scripts/dist/remember-me.js @@ -1,11 +1,24 @@ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; import * as cookie from "./cookie"; import { EnForm, RememberMeEvents } from "./events"; const tippy = require("tippy.js").default; +// localStorage key used to cache the per-device AES-GCM encryption key. +// A random secret generated once per device and held in localStorage. +const RM_ENCRYPTION_KEY_STORAGE_NAME = "engrid-remember-me-key"; export class RememberMe { constructor(options) { this._form = EnForm.getInstance(); this._events = RememberMeEvents.getInstance(); this.iframe = null; + this.encryptData = options.encryptData ? options.encryptData : false; this.remoteUrl = options.remoteUrl ? options.remoteUrl : null; this.cookieName = options.cookieName ? options.cookieName @@ -50,7 +63,11 @@ export class RememberMe { if (this.useRemote()) { this.createIframe(() => { if (this.iframe && this.iframe.contentWindow) { - this.iframe.contentWindow.postMessage(JSON.stringify({ key: this.cookieName, operation: "read" }), "*"); + this.iframe.contentWindow.postMessage(JSON.stringify({ + key: this.cookieName, + operation: "read", + encryptData: this.encryptData, + }), "*"); this._form.onSubmit.subscribe(() => { if (this.rememberMeOptIn) { this.readFields(); @@ -81,6 +98,29 @@ export class RememberMe { } }); } + else if (this.encryptData) { + // Same flow as the unencrypted branch below, but the cookie payload is + // AES-GCM encrypted/decrypted (browser-native Web Crypto), so reading + // the cookie is asynchronous. A failed decrypt (foreign device or + // cleared localStorage) leaves fieldData empty and silently falls back + // to the standard, no-autofill experience. + this.readCookieEncrypted().then(() => { + let hasFieldData = Object.keys(this.fieldData).length > 0; + if (!hasFieldData) { + this.insertRememberMeOptin(); + } + else { + this.insertClearRememberMeLink(); + } + this.writeFields(); + this._form.onSubmit.subscribe(() => { + if (this.rememberMeOptIn) { + this.readFields(); + this.saveCookieEncrypted(); + } + }); + }); + } else { this.readCookie(); let hasFieldData = Object.keys(this.fieldData).length > 0; @@ -133,6 +173,9 @@ export class RememberMe { if (this.useRemote()) { this.clearCookieOnRemote(); } + else if (this.encryptData) { + this.clearCookieEncrypted(); + } else { this.clearCookie(); } @@ -253,6 +296,7 @@ export class RememberMe { value: this.fieldData, operation: "write", expires: this.cookieExpirationDays, + encryptData: this.encryptData, }), "*"); } } @@ -264,6 +308,142 @@ export class RememberMe { expires: this.cookieExpirationDays, }); } + /** + * Reads and decrypts the local (non-remote) Remember Me cookie using + * browser-native AES-GCM (Web Crypto), with the key held in localStorage + * on this device. If the key is absent (different device or cleared + * storage) or decryption otherwise fails, the field data is left empty + * and the component falls back to the normal, no-autofill experience. + */ + readCookieEncrypted() { + return __awaiter(this, void 0, void 0, function* () { + const raw = cookie.get(this.cookieName); + if (!raw) { + return; + } + const decrypted = yield this.decryptPayload(raw); + if (decrypted) { + this.updateFieldData(decrypted); + } + }); + } + /** + * Encrypts the current fieldData with AES-GCM (Web Crypto) and stores the + * base64-encoded result in the local cookie. If encryption isn't possible + * (e.g. Web Crypto unavailable), nothing is written. + */ + saveCookieEncrypted() { + return __awaiter(this, void 0, void 0, function* () { + const encrypted = yield this.encryptPayload(JSON.stringify(this.fieldData)); + if (encrypted) { + cookie.set(this.cookieName, encrypted, { + expires: this.cookieExpirationDays, + }); + } + }); + } + clearCookieEncrypted() { + this.fieldData = {}; + this.saveCookieEncrypted(); + } + /** + * Retrieves the per-device AES-GCM encryption key. A random secret + * generated once per device and held in localStorage — never written + * to the cookie, so it never travels with the transported value. + */ + getEncryptionKey() { + return __awaiter(this, void 0, void 0, function* () { + if (!window.crypto || !window.crypto.subtle) { + return null; + } + const storedKey = window.localStorage.getItem(RM_ENCRYPTION_KEY_STORAGE_NAME); + if (storedKey) { + try { + return yield window.crypto.subtle.importKey("raw", this.base64ToArrayBuffer(storedKey), { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + } + catch (e) { + return null; + } + } + try { + const key = yield window.crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); + const exported = yield window.crypto.subtle.exportKey("raw", key); + window.localStorage.setItem(RM_ENCRYPTION_KEY_STORAGE_NAME, this.arrayBufferToBase64(exported)); + return key; + } + catch (e) { + return null; + } + }); + } + /** + * Encrypts a plaintext string with AES-GCM and returns the base64-encoded + * IV + ciphertext, ready for storage. Returns null if a key isn't + * available (e.g. Web Crypto unsupported). + */ + encryptPayload(plaintext) { + return __awaiter(this, void 0, void 0, function* () { + const key = yield this.getEncryptionKey(); + if (!key) { + return null; + } + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = yield window.crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(plaintext)); + const combined = new Uint8Array(iv.length + ciphertext.byteLength); + combined.set(iv); + combined.set(new Uint8Array(ciphertext), iv.length); + return this.arrayBufferToBase64(combined); + }); + } + /** + * Decrypts a base64-encoded IV + ciphertext payload previously produced by + * encryptPayload. Returns null (rather than throwing) if the key is + * missing or decryption otherwise fails, so callers can gracefully fall + * back to the standard, no-autofill experience. + */ + decryptPayload(encryptedBase64) { + return __awaiter(this, void 0, void 0, function* () { + const key = yield this.getEncryptionKey(); + if (!key) { + return null; + } + let combined; + try { + combined = new Uint8Array(this.base64ToArrayBuffer(encryptedBase64)); + } + catch (e) { + return null; + } + if (combined.length < 13) { + return null; + } + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + try { + const decrypted = yield window.crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + return new TextDecoder().decode(decrypted); + } + catch (e) { + return null; + } + }); + } + arrayBufferToBase64(buffer) { + let binary = ""; + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + base64ToArrayBuffer(base64) { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } readFields() { for (let i = 0; i < this.fieldNames.length; i++) { let fieldSelector = "[name='" + this.fieldNames[i] + "']"; diff --git a/packages/scripts/src/interfaces/options.ts b/packages/scripts/src/interfaces/options.ts index a4b41694..f947fbf2 100644 --- a/packages/scripts/src/interfaces/options.ts +++ b/packages/scripts/src/interfaces/options.ts @@ -56,6 +56,7 @@ export interface Options { fieldClearSelectorTargetLocation?: string; fieldClearLabel?: string; checked?: boolean; + encryptData?: boolean; }; StickyNSG?: boolean; StickyPrepopulation?: false | { fields: string[] }; diff --git a/packages/scripts/src/remember-me.ts b/packages/scripts/src/remember-me.ts index b2c1ae43..968e7d2b 100644 --- a/packages/scripts/src/remember-me.ts +++ b/packages/scripts/src/remember-me.ts @@ -6,6 +6,10 @@ interface DataObj { [key: string]: string; } +// localStorage key used to cache the per-device AES-GCM encryption key. +// A random secret generated once per device and held in localStorage. +const RM_ENCRYPTION_KEY_STORAGE_NAME = "engrid-remember-me-key"; + export class RememberMe { public _form: EnForm = EnForm.getInstance(); public _events: RememberMeEvents = RememberMeEvents.getInstance(); @@ -17,6 +21,7 @@ export class RememberMe { private cookieExpirationDays: number; private iframe: HTMLIFrameElement | null; private rememberMeOptIn: boolean; + private encryptData: boolean; private fieldDonationAmountRadioName: string; private fieldDonationAmountOtherName: string; @@ -44,8 +49,10 @@ export class RememberMe { fieldClearSelectorTargetLocation?: string; fieldClearLabel?: string; checked?: boolean; + encryptData?: boolean; }) { this.iframe = null; + this.encryptData = options.encryptData ? options.encryptData : false; this.remoteUrl = options.remoteUrl ? options.remoteUrl : null; this.cookieName = options.cookieName @@ -98,7 +105,11 @@ export class RememberMe { () => { if (this.iframe && this.iframe.contentWindow) { this.iframe.contentWindow.postMessage( - JSON.stringify({ key: this.cookieName, operation: "read" }), + JSON.stringify({ + key: this.cookieName, + operation: "read", + encryptData: this.encryptData, + }), "*" ); this._form.onSubmit.subscribe(() => { @@ -135,6 +146,27 @@ export class RememberMe { } } ); + } else if (this.encryptData) { + // Same flow as the unencrypted branch below, but the cookie payload is + // AES-GCM encrypted/decrypted (browser-native Web Crypto), so reading + // the cookie is asynchronous. A failed decrypt (foreign device or + // cleared localStorage) leaves fieldData empty and silently falls back + // to the standard, no-autofill experience. + this.readCookieEncrypted().then(() => { + let hasFieldData = Object.keys(this.fieldData).length > 0; + if (!hasFieldData) { + this.insertRememberMeOptin(); + } else { + this.insertClearRememberMeLink(); + } + this.writeFields(); + this._form.onSubmit.subscribe(() => { + if (this.rememberMeOptIn) { + this.readFields(); + this.saveCookieEncrypted(); + } + }); + }); } else { this.readCookie(); let hasFieldData = Object.keys(this.fieldData).length > 0; @@ -189,6 +221,8 @@ export class RememberMe { this.clearFields(["supporter.country" /*, 'supporter.emailAddress'*/]); if (this.useRemote()) { this.clearCookieOnRemote(); + } else if (this.encryptData) { + this.clearCookieEncrypted(); } else { this.clearCookie(); } @@ -342,6 +376,7 @@ export class RememberMe { value: this.fieldData, operation: "write", expires: this.cookieExpirationDays, + encryptData: this.encryptData, }), "*" ); @@ -355,6 +390,154 @@ export class RememberMe { expires: this.cookieExpirationDays, }); } + /** + * Reads and decrypts the local (non-remote) Remember Me cookie using + * browser-native AES-GCM (Web Crypto), with the key held in localStorage + * on this device. If the key is absent (different device or cleared + * storage) or decryption otherwise fails, the field data is left empty + * and the component falls back to the normal, no-autofill experience. + */ + private async readCookieEncrypted(): Promise { + const raw = cookie.get(this.cookieName); + if (!raw) { + return; + } + const decrypted = await this.decryptPayload(raw); + if (decrypted) { + this.updateFieldData(decrypted); + } + } + /** + * Encrypts the current fieldData with AES-GCM (Web Crypto) and stores the + * base64-encoded result in the local cookie. If encryption isn't possible + * (e.g. Web Crypto unavailable), nothing is written. + */ + private async saveCookieEncrypted(): Promise { + const encrypted = await this.encryptPayload(JSON.stringify(this.fieldData)); + if (encrypted) { + cookie.set(this.cookieName, encrypted, { + expires: this.cookieExpirationDays, + }); + } + } + private clearCookieEncrypted() { + this.fieldData = {}; + this.saveCookieEncrypted(); + } + /** + * Retrieves the per-device AES-GCM encryption key. A random secret + * generated once per device and held in localStorage — never written + * to the cookie, so it never travels with the transported value. + */ + private async getEncryptionKey(): Promise { + if (!window.crypto || !window.crypto.subtle) { + return null; + } + const storedKey = window.localStorage.getItem( + RM_ENCRYPTION_KEY_STORAGE_NAME + ); + if (storedKey) { + try { + return await window.crypto.subtle.importKey( + "raw", + this.base64ToArrayBuffer(storedKey), + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); + } catch (e) { + return null; + } + } + try { + const key = await window.crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + const exported = await window.crypto.subtle.exportKey("raw", key); + window.localStorage.setItem( + RM_ENCRYPTION_KEY_STORAGE_NAME, + this.arrayBufferToBase64(exported) + ); + return key; + } catch (e) { + return null; + } + } + /** + * Encrypts a plaintext string with AES-GCM and returns the base64-encoded + * IV + ciphertext, ready for storage. Returns null if a key isn't + * available (e.g. Web Crypto unsupported). + */ + private async encryptPayload(plaintext: string): Promise { + const key = await this.getEncryptionKey(); + if (!key) { + return null; + } + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await window.crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + new TextEncoder().encode(plaintext) + ); + const combined = new Uint8Array(iv.length + ciphertext.byteLength); + combined.set(iv); + combined.set(new Uint8Array(ciphertext), iv.length); + return this.arrayBufferToBase64(combined); + } + /** + * Decrypts a base64-encoded IV + ciphertext payload previously produced by + * encryptPayload. Returns null (rather than throwing) if the key is + * missing or decryption otherwise fails, so callers can gracefully fall + * back to the standard, no-autofill experience. + */ + private async decryptPayload( + encryptedBase64: string + ): Promise { + const key = await this.getEncryptionKey(); + if (!key) { + return null; + } + let combined: Uint8Array; + try { + combined = new Uint8Array(this.base64ToArrayBuffer(encryptedBase64)); + } catch (e) { + return null; + } + if (combined.length < 13) { + return null; + } + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + try { + const decrypted = await window.crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + key, + ciphertext + ); + return new TextDecoder().decode(decrypted); + } catch (e) { + return null; + } + } + private arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string { + let binary = ""; + const bytes = + buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + private base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } private readFields() { for (let i = 0; i < this.fieldNames.length; i++) { let fieldSelector = "[name='" + this.fieldNames[i] + "']"; diff --git a/reference-materials/docs/rememberme.md b/reference-materials/docs/rememberme.md index 41d5ea97..bcd12892 100644 --- a/reference-materials/docs/rememberme.md +++ b/reference-materials/docs/rememberme.md @@ -32,6 +32,8 @@ To enable, add a 'RememberMe' property to the 'options' object in your engrid th **fieldDonationAmountOtherCheckboxID**: This is deprecated and will be removed. +**encryptData**: Boolean. If set to true, the saved form details are encrypted with browser-native AES-GCM (Web Crypto) before being stored, and the resulting bytes are base64-encoded. This applies to both the local cookie and the remote-iframe cookie. The encryption key is randomly generated once per device and kept in `localStorage` (never written to the cookie itself, so it never travels with the transported value). In remote mode, the key and store live in the iframe's origin, so multiple sites sharing that remote origin (e.g. FWW and FWA) share them automatically — however, note that this relies on localStorage access within the cross-origin iframe, which is subject to Chrome's third-party storage partitioning (see known limitations). If the key is missing or decryption otherwise fails (a different device, or cleared storage), the component silently discards the data and falls back to the normal, no-autofill experience. Defaults to false. When enabled with `remoteUrl`, the remote page must implement the matching encrypt/decrypt protocol (see `data-remember.html` for a reference implementation). + --- **Sample Remote URL Page Markup to be used as a cookie repository** From b538a4cb56cab7cc601536c3c187321c6c129d06 Mon Sep 17 00:00:00 2001 From: plpmd Date: Thu, 16 Jul 2026 08:56:23 -0300 Subject: [PATCH 03/10] feat: add hide option --- packages/scripts/dist/interfaces/options.d.ts | 1 + packages/scripts/dist/remember-me.d.ts | 2 ++ packages/scripts/dist/remember-me.js | 4 ++++ packages/scripts/src/interfaces/options.ts | 1 + packages/scripts/src/remember-me.ts | 7 +++++++ 5 files changed, 15 insertions(+) diff --git a/packages/scripts/dist/interfaces/options.d.ts b/packages/scripts/dist/interfaces/options.d.ts index aace87ef..b20b0b5e 100644 --- a/packages/scripts/dist/interfaces/options.d.ts +++ b/packages/scripts/dist/interfaces/options.d.ts @@ -53,6 +53,7 @@ export interface Options { fieldClearLabel?: string; checked?: boolean; encryptData?: boolean; + hide?: boolean; }; StickyNSG?: boolean; StickyPrepopulation?: false | { diff --git a/packages/scripts/dist/remember-me.d.ts b/packages/scripts/dist/remember-me.d.ts index 415fe425..c3bf0e16 100644 --- a/packages/scripts/dist/remember-me.d.ts +++ b/packages/scripts/dist/remember-me.d.ts @@ -10,6 +10,7 @@ export declare class RememberMe { private iframe; private rememberMeOptIn; private encryptData; + private hide; private fieldDonationAmountRadioName; private fieldDonationAmountOtherName; private fieldDonationRecurrPayRadioName; @@ -35,6 +36,7 @@ export declare class RememberMe { fieldClearLabel?: string; checked?: boolean; encryptData?: boolean; + hide?: boolean; }); private updateFieldData; private insertClearRememberMeLink; diff --git a/packages/scripts/dist/remember-me.js b/packages/scripts/dist/remember-me.js index 0e52665e..a8a18dc8 100644 --- a/packages/scripts/dist/remember-me.js +++ b/packages/scripts/dist/remember-me.js @@ -19,6 +19,7 @@ export class RememberMe { this._events = RememberMeEvents.getInstance(); this.iframe = null; this.encryptData = options.encryptData ? options.encryptData : false; + this.hide = options.hide ? options.hide : false; this.remoteUrl = options.remoteUrl ? options.remoteUrl : null; this.cookieName = options.cookieName ? options.cookieName @@ -247,6 +248,9 @@ export class RememberMe { } }); } + if (this.hide) { + rememberMeOptInField.classList.add("hide"); + } tippy("#rememberme-learn-more-toggle", { content: rememberMeInfo }); } } diff --git a/packages/scripts/src/interfaces/options.ts b/packages/scripts/src/interfaces/options.ts index f947fbf2..0999fbbd 100644 --- a/packages/scripts/src/interfaces/options.ts +++ b/packages/scripts/src/interfaces/options.ts @@ -57,6 +57,7 @@ export interface Options { fieldClearLabel?: string; checked?: boolean; encryptData?: boolean; + hide?: boolean; }; StickyNSG?: boolean; StickyPrepopulation?: false | { fields: string[] }; diff --git a/packages/scripts/src/remember-me.ts b/packages/scripts/src/remember-me.ts index 968e7d2b..1019cb9a 100644 --- a/packages/scripts/src/remember-me.ts +++ b/packages/scripts/src/remember-me.ts @@ -22,6 +22,7 @@ export class RememberMe { private iframe: HTMLIFrameElement | null; private rememberMeOptIn: boolean; private encryptData: boolean; + private hide: boolean; private fieldDonationAmountRadioName: string; private fieldDonationAmountOtherName: string; @@ -50,9 +51,11 @@ export class RememberMe { fieldClearLabel?: string; checked?: boolean; encryptData?: boolean; + hide?: boolean; }) { this.iframe = null; this.encryptData = options.encryptData ? options.encryptData : false; + this.hide = options.hide ? options.hide : false; this.remoteUrl = options.remoteUrl ? options.remoteUrl : null; this.cookieName = options.cookieName @@ -315,6 +318,10 @@ export class RememberMe { }); } + if (this.hide) { + rememberMeOptInField.classList.add("hide"); + } + tippy("#rememberme-learn-more-toggle", { content: rememberMeInfo }); } } else if (this.rememberMeOptIn) { From 06a78df07ba1b306f69f7b558c9b8384029b5437 Mon Sep 17 00:00:00 2001 From: plpmd Date: Thu, 16 Jul 2026 10:54:42 -0300 Subject: [PATCH 04/10] fix(remember-me): correctly restore transaction.recurrfreq radio selection transaction.recurrfreq is a radio group (MONTHLY, ONETIME, ANNUAL, etc). The previous implementation fell through to the generic setFieldValue path, which only checked the first radio in the DOM. If that radio's value didn't match the saved value, nothing was selected. Add explicit handling for recurrfreq (configurable via fieldDonationRecurrFreqRadioName option, defaulting to 'transaction.recurrfreq') that queries the exact radio by value and clicks it, matching the same pattern already used for transaction.donationAmt. --- packages/scripts/dist/remember-me.d.ts | 2 ++ packages/scripts/dist/remember-me.js | 14 ++++++++++++++ packages/scripts/src/remember-me.ts | 17 +++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/packages/scripts/dist/remember-me.d.ts b/packages/scripts/dist/remember-me.d.ts index c3bf0e16..66e8d74f 100644 --- a/packages/scripts/dist/remember-me.d.ts +++ b/packages/scripts/dist/remember-me.d.ts @@ -14,6 +14,7 @@ export declare class RememberMe { private fieldDonationAmountRadioName; private fieldDonationAmountOtherName; private fieldDonationRecurrPayRadioName; + private fieldDonationRecurrFreqRadioName; private fieldDonationAmountOtherCheckboxID; private fieldOptInSelectorTarget; private fieldOptInSelectorTargetLocation; @@ -28,6 +29,7 @@ export declare class RememberMe { fieldDonationAmountRadioName?: string; fieldDonationAmountOtherName?: string; fieldDonationRecurrPayRadioName?: string; + fieldDonationRecurrFreqRadioName?: string; fieldDonationAmountOtherCheckboxID?: string; fieldOptInSelectorTarget?: string; fieldOptInSelectorTargetLocation?: string; diff --git a/packages/scripts/dist/remember-me.js b/packages/scripts/dist/remember-me.js index a8a18dc8..a227ac03 100644 --- a/packages/scripts/dist/remember-me.js +++ b/packages/scripts/dist/remember-me.js @@ -39,6 +39,10 @@ export class RememberMe { options.fieldDonationRecurrPayRadioName ? options.fieldDonationRecurrPayRadioName : "transaction.recurrpay"; + this.fieldDonationRecurrFreqRadioName = + options.fieldDonationRecurrFreqRadioName + ? options.fieldDonationRecurrFreqRadioName + : "transaction.recurrfreq"; this.fieldDonationAmountOtherCheckboxID = options.fieldDonationAmountOtherCheckboxID ? options.fieldDonationAmountOtherCheckboxID @@ -541,6 +545,16 @@ export class RememberMe { field.click(); } } + else if (this.fieldNames[i] === this.fieldDonationRecurrFreqRadioName) { + // recurrfreq is a radio group — find the specific radio with the saved value and click it + const savedValue = this.fieldData[this.fieldNames[i]]; + if (savedValue) { + const freqRadio = document.querySelector(fieldSelector + "[value='" + savedValue + "']"); + if (freqRadio) { + freqRadio.click(); + } + } + } else if (this.fieldDonationAmountRadioName === this.fieldNames[i]) { field = document.querySelector(fieldSelector + "[value='" + diff --git a/packages/scripts/src/remember-me.ts b/packages/scripts/src/remember-me.ts index 1019cb9a..cee2d7dd 100644 --- a/packages/scripts/src/remember-me.ts +++ b/packages/scripts/src/remember-me.ts @@ -27,6 +27,7 @@ export class RememberMe { private fieldDonationAmountRadioName: string; private fieldDonationAmountOtherName: string; private fieldDonationRecurrPayRadioName: string; + private fieldDonationRecurrFreqRadioName: string; private fieldDonationAmountOtherCheckboxID: string; private fieldOptInSelectorTarget: string; @@ -43,6 +44,7 @@ export class RememberMe { fieldDonationAmountRadioName?: string; fieldDonationAmountOtherName?: string; fieldDonationRecurrPayRadioName?: string; + fieldDonationRecurrFreqRadioName?: string; fieldDonationAmountOtherCheckboxID?: string; fieldOptInSelectorTarget?: string; fieldOptInSelectorTargetLocation?: string; @@ -77,6 +79,10 @@ export class RememberMe { options.fieldDonationRecurrPayRadioName ? options.fieldDonationRecurrPayRadioName : "transaction.recurrpay"; + this.fieldDonationRecurrFreqRadioName = + options.fieldDonationRecurrFreqRadioName + ? options.fieldDonationRecurrFreqRadioName + : "transaction.recurrfreq"; this.fieldDonationAmountOtherCheckboxID = options.fieldDonationAmountOtherCheckboxID ? options.fieldDonationAmountOtherCheckboxID @@ -642,6 +648,17 @@ export class RememberMe { if (this.fieldData[this.fieldNames[i]] === "Y") { field.click(); } + } else if (this.fieldNames[i] === this.fieldDonationRecurrFreqRadioName) { + // recurrfreq is a radio group — find the specific radio with the saved value and click it + const savedValue = this.fieldData[this.fieldNames[i]]; + if (savedValue) { + const freqRadio = document.querySelector( + fieldSelector + "[value='" + savedValue + "']" + ) as HTMLInputElement; + if (freqRadio) { + freqRadio.click(); + } + } } else if (this.fieldDonationAmountRadioName === this.fieldNames[i]) { field = document.querySelector( fieldSelector + From 8e40d1cf4f3e9732915875fca900371b39553429 Mon Sep 17 00:00:00 2001 From: plpmd Date: Thu, 16 Jul 2026 10:55:20 -0300 Subject: [PATCH 05/10] fix(remember-me): save and restore custom donation amount correctly When the donor selected the 'Other' radio and typed a custom amount, readFields() was storing the radio's own value ('Other' / 'other') instead of the numeric value the donor entered in the text input. On the restore side, writeFields() jumped straight to filling the .other text input without first clicking the 'Other' radio, leaving the input hidden/disabled in some form configurations. Fix readFields() to detect when the checked donationAmt radio has value 'other' and persist the numeric value from transaction.donationAmt.other instead. Fix writeFields() to first click the 'Other' radio before populating the text input when no predefined radio matches the saved value. --- packages/scripts/dist/remember-me.js | 31 ++++++++++++++++---- packages/scripts/src/remember-me.ts | 43 +++++++++++++++++++++------- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/packages/scripts/dist/remember-me.js b/packages/scripts/dist/remember-me.js index a227ac03..593c1afd 100644 --- a/packages/scripts/dist/remember-me.js +++ b/packages/scripts/dist/remember-me.js @@ -462,6 +462,17 @@ export class RememberMe { if (type === "radio" || type === "checkbox") { field = document.querySelector(fieldSelector + ":checked"); } + // When the donation amount radio is set to "Other", save the actual + // custom value from the .other text input instead of "Other". + if (this.fieldNames[i] === this.fieldDonationAmountRadioName && + field && + field.value.toLowerCase() === "other") { + const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']"); + if (otherField && otherField.value) { + this.fieldData[this.fieldNames[i]] = encodeURIComponent(otherField.value); + continue; + } + } this.fieldData[this.fieldNames[i]] = encodeURIComponent(field.value); } else if (field.tagName === "SELECT") { @@ -556,16 +567,24 @@ export class RememberMe { } } else if (this.fieldDonationAmountRadioName === this.fieldNames[i]) { - field = document.querySelector(fieldSelector + - "[value='" + - this.fieldData[this.fieldNames[i]] + - "']"); + const savedAmt = this.fieldData[this.fieldNames[i]]; + field = document.querySelector(fieldSelector + "[value='" + savedAmt + "']"); if (field) { + // Saved value matches a predefined radio option — just click it field.click(); } else { - field = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']"); - this.setFieldValue(field, this.fieldData[this.fieldNames[i]], true); + // No matching radio: the value is a custom amount. + // Click the "Other" radio first so the text input becomes active, + // then fill in the numeric value. + const otherRadio = document.querySelector(fieldSelector + "[value='Other'], " + + fieldSelector + "[value='other'], " + + fieldSelector + "[value='OTHER']"); + if (otherRadio) { + otherRadio.click(); + } + const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']"); + this.setFieldValue(otherField, savedAmt, true); } } else { diff --git a/packages/scripts/src/remember-me.ts b/packages/scripts/src/remember-me.ts index cee2d7dd..e248eac1 100644 --- a/packages/scripts/src/remember-me.ts +++ b/packages/scripts/src/remember-me.ts @@ -563,6 +563,23 @@ export class RememberMe { fieldSelector + ":checked" ) as HTMLInputElement; } + // When the donation amount radio is set to "Other", save the actual + // custom value from the .other text input instead of "Other". + if ( + this.fieldNames[i] === this.fieldDonationAmountRadioName && + field && + field.value.toLowerCase() === "other" + ) { + const otherField = document.querySelector( + "input[name='" + this.fieldDonationAmountOtherName + "']" + ) as HTMLInputElement; + if (otherField && otherField.value) { + this.fieldData[this.fieldNames[i]] = encodeURIComponent( + otherField.value + ); + continue; + } + } this.fieldData[this.fieldNames[i]] = encodeURIComponent(field.value); } else if (field.tagName === "SELECT") { this.fieldData[this.fieldNames[i]] = encodeURIComponent(field.value); @@ -660,23 +677,29 @@ export class RememberMe { } } } else if (this.fieldDonationAmountRadioName === this.fieldNames[i]) { + const savedAmt = this.fieldData[this.fieldNames[i]]; field = document.querySelector( - fieldSelector + - "[value='" + - this.fieldData[this.fieldNames[i]] + - "']" + fieldSelector + "[value='" + savedAmt + "']" ) as HTMLInputElement; if (field) { + // Saved value matches a predefined radio option — just click it field.click(); } else { - field = document.querySelector( + // No matching radio: the value is a custom amount. + // Click the "Other" radio first so the text input becomes active, + // then fill in the numeric value. + const otherRadio = document.querySelector( + fieldSelector + "[value='Other'], " + + fieldSelector + "[value='other'], " + + fieldSelector + "[value='OTHER']" + ) as HTMLInputElement; + if (otherRadio) { + otherRadio.click(); + } + const otherField = document.querySelector( "input[name='" + this.fieldDonationAmountOtherName + "']" ) as HTMLInputElement; - this.setFieldValue( - field, - this.fieldData[this.fieldNames[i]], - true - ); + this.setFieldValue(otherField, savedAmt, true); } } else { this.setFieldValue( From 757b254a6b2e62783d926153b315066eb6bc7d8a Mon Sep 17 00:00:00 2001 From: plpmd Date: Thu, 16 Jul 2026 10:56:22 -0300 Subject: [PATCH 06/10] fix(remember-me): reapply donation amount after SwapAmounts replaces the DOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwapAmounts listens to onFrequencyChange and calls enjs.swapList() to replace the donationAmt radio nodes in the DOM. This runs ~1 second after page load via a setTimeout in App.run(), which is after RememberMe has already called writeFields() and selected a radio — wiping that selection. Add reapplyDonationAmtAfterSwap() which subscribes once (via .one()) to onFrequencyChange and, after a 200 ms settle delay, re-clicks the correct donationAmt radio (or fills the Other text input for custom amounts). The handler fires only on the initial frequency load and never again, so it does not interfere with subsequent donor interactions. --- packages/scripts/dist/remember-me.d.ts | 12 ++++++ packages/scripts/dist/remember-me.js | 47 ++++++++++++++++++++++- packages/scripts/src/remember-me.ts | 53 +++++++++++++++++++++++++- 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/packages/scripts/dist/remember-me.d.ts b/packages/scripts/dist/remember-me.d.ts index 66e8d74f..faf378c9 100644 --- a/packages/scripts/dist/remember-me.d.ts +++ b/packages/scripts/dist/remember-me.d.ts @@ -2,6 +2,7 @@ import { EnForm, RememberMeEvents } from "./events"; export declare class RememberMe { _form: EnForm; _events: RememberMeEvents; + private _frequency; private remoteUrl; private cookieName; private fieldNames; @@ -101,5 +102,16 @@ export declare class RememberMe { * @param overwrite - A boolean indicating whether to overwrite the existing value of the fields. Defaults to false. */ private writeFields; + /** + * SwapAmounts replaces the donationAmt radio DOM nodes ~1 second after page + * load (triggered by DonationFrequency.load() setTimeout). When that happens + * the selection the RememberMe just wrote gets wiped out. + * + * This method subscribes to the first onFrequencyChange event and, after a + * short delay to let SwapAmounts finish its DOM update, re-applies only the + * donation amount. It unsubscribes immediately so it only fires once and + * never interferes with manual donor interactions. + */ + private reapplyDonationAmtAfterSwap; private isJson; } diff --git a/packages/scripts/dist/remember-me.js b/packages/scripts/dist/remember-me.js index 593c1afd..7a3ca36b 100644 --- a/packages/scripts/dist/remember-me.js +++ b/packages/scripts/dist/remember-me.js @@ -8,7 +8,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }); }; import * as cookie from "./cookie"; -import { EnForm, RememberMeEvents } from "./events"; +import { EnForm, RememberMeEvents, DonationFrequency } from "./events"; const tippy = require("tippy.js").default; // localStorage key used to cache the per-device AES-GCM encryption key. // A random secret generated once per device and held in localStorage. @@ -17,6 +17,7 @@ export class RememberMe { constructor(options) { this._form = EnForm.getInstance(); this._events = RememberMeEvents.getInstance(); + this._frequency = DonationFrequency.getInstance(); this.iframe = null; this.encryptData = options.encryptData ? options.encryptData : false; this.hide = options.hide ? options.hide : false; @@ -99,6 +100,7 @@ export class RememberMe { } else { this.insertClearRememberMeLink(); + this.reapplyDonationAmtAfterSwap(); } } }); @@ -118,6 +120,9 @@ export class RememberMe { this.insertClearRememberMeLink(); } this.writeFields(); + if (hasFieldData) { + this.reapplyDonationAmtAfterSwap(); + } this._form.onSubmit.subscribe(() => { if (this.rememberMeOptIn) { this.readFields(); @@ -136,6 +141,9 @@ export class RememberMe { this.insertClearRememberMeLink(); } this.writeFields(); + if (hasFieldData) { + this.reapplyDonationAmtAfterSwap(); + } this._form.onSubmit.subscribe(() => { if (this.rememberMeOptIn) { this.readFields(); @@ -597,6 +605,43 @@ export class RememberMe { } } } + /** + * SwapAmounts replaces the donationAmt radio DOM nodes ~1 second after page + * load (triggered by DonationFrequency.load() setTimeout). When that happens + * the selection the RememberMe just wrote gets wiped out. + * + * This method subscribes to the first onFrequencyChange event and, after a + * short delay to let SwapAmounts finish its DOM update, re-applies only the + * donation amount. It unsubscribes immediately so it only fires once and + * never interferes with manual donor interactions. + */ + reapplyDonationAmtAfterSwap() { + const savedAmt = this.fieldData[this.fieldDonationAmountRadioName]; + if (!savedAmt) + return; + const handler = () => { + // SwapAmounts calls _amount.load() after swapList — give it a tick to settle + window.setTimeout(() => { + const fieldSelector = "[name='" + this.fieldDonationAmountRadioName + "']"; + let radio = document.querySelector(fieldSelector + "[value='" + savedAmt + "']"); + if (radio) { + radio.click(); + } + else { + // Custom amount: click "Other" radio then fill the text input + const otherRadio = document.querySelector(fieldSelector + "[value='Other'], " + + fieldSelector + "[value='other'], " + + fieldSelector + "[value='OTHER']"); + if (otherRadio) + otherRadio.click(); + const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']"); + this.setFieldValue(otherField, savedAmt, true); + } + }, 200); + }; + // Subscribe once: fires on the first frequency change then auto-unsubscribes + this._frequency.onFrequencyChange.one(handler); + } isJson(str) { try { JSON.parse(str); diff --git a/packages/scripts/src/remember-me.ts b/packages/scripts/src/remember-me.ts index e248eac1..35e9621d 100644 --- a/packages/scripts/src/remember-me.ts +++ b/packages/scripts/src/remember-me.ts @@ -1,5 +1,5 @@ import * as cookie from "./cookie"; -import { EnForm, RememberMeEvents } from "./events"; +import { EnForm, RememberMeEvents, DonationFrequency } from "./events"; const tippy = require("tippy.js").default; interface DataObj { @@ -13,6 +13,7 @@ const RM_ENCRYPTION_KEY_STORAGE_NAME = "engrid-remember-me-key"; export class RememberMe { public _form: EnForm = EnForm.getInstance(); public _events: RememberMeEvents = RememberMeEvents.getInstance(); + private _frequency: DonationFrequency = DonationFrequency.getInstance(); private remoteUrl: string | null; private cookieName: string; @@ -151,6 +152,7 @@ export class RememberMe { this.insertRememberMeOptin(); } else { this.insertClearRememberMeLink(); + this.reapplyDonationAmtAfterSwap(); } } } @@ -169,6 +171,9 @@ export class RememberMe { this.insertClearRememberMeLink(); } this.writeFields(); + if (hasFieldData) { + this.reapplyDonationAmtAfterSwap(); + } this._form.onSubmit.subscribe(() => { if (this.rememberMeOptIn) { this.readFields(); @@ -185,6 +190,9 @@ export class RememberMe { this.insertClearRememberMeLink(); } this.writeFields(); + if (hasFieldData) { + this.reapplyDonationAmtAfterSwap(); + } this._form.onSubmit.subscribe(() => { if (this.rememberMeOptIn) { this.readFields(); @@ -714,6 +722,49 @@ export class RememberMe { } } } + /** + * SwapAmounts replaces the donationAmt radio DOM nodes ~1 second after page + * load (triggered by DonationFrequency.load() setTimeout). When that happens + * the selection the RememberMe just wrote gets wiped out. + * + * This method subscribes to the first onFrequencyChange event and, after a + * short delay to let SwapAmounts finish its DOM update, re-applies only the + * donation amount. It unsubscribes immediately so it only fires once and + * never interferes with manual donor interactions. + */ + private reapplyDonationAmtAfterSwap() { + const savedAmt = this.fieldData[this.fieldDonationAmountRadioName]; + if (!savedAmt) return; + + const handler = () => { + // SwapAmounts calls _amount.load() after swapList — give it a tick to settle + window.setTimeout(() => { + const fieldSelector = + "[name='" + this.fieldDonationAmountRadioName + "']"; + let radio = document.querySelector( + fieldSelector + "[value='" + savedAmt + "']" + ) as HTMLInputElement; + if (radio) { + radio.click(); + } else { + // Custom amount: click "Other" radio then fill the text input + const otherRadio = document.querySelector( + fieldSelector + "[value='Other'], " + + fieldSelector + "[value='other'], " + + fieldSelector + "[value='OTHER']" + ) as HTMLInputElement; + if (otherRadio) otherRadio.click(); + const otherField = document.querySelector( + "input[name='" + this.fieldDonationAmountOtherName + "']" + ) as HTMLInputElement; + this.setFieldValue(otherField, savedAmt, true); + } + }, 200); + }; + + // Subscribe once: fires on the first frequency change then auto-unsubscribes + this._frequency.onFrequencyChange.one(handler); + } private isJson(str: string) { try { JSON.parse(str); From 9e53e664d106df73d13ed308089e3282a1e2dc61 Mon Sep 17 00:00:00 2001 From: plpmd Date: Thu, 16 Jul 2026 12:44:59 -0300 Subject: [PATCH 07/10] docs: add info for new hide and fieldClearLabel options --- reference-materials/docs/rememberme.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/reference-materials/docs/rememberme.md b/reference-materials/docs/rememberme.md index bcd12892..56acf6a5 100644 --- a/reference-materials/docs/rememberme.md +++ b/reference-materials/docs/rememberme.md @@ -20,6 +20,8 @@ To enable, add a 'RememberMe' property to the 'options' object in your engrid th **fieldClearSelectorTargetLocation**: A string that is set to either 'before' or 'after'. Defaults to 'before'. +**fieldClearLabel**: A string containing the text for the "Clear Autofill" link that appears when data is being auto-filled. Defaults to `(clear autofill)`. + **cookieName**: String dictating the name of the cookie stores the autofill data. Defaults to 'engrid-autofill'. **cookieExpirationDays**: Number of days for the cookie expiration. Defaults to 365. @@ -32,6 +34,8 @@ To enable, add a 'RememberMe' property to the 'options' object in your engrid th **fieldDonationAmountOtherCheckboxID**: This is deprecated and will be removed. +**hide**: Boolean. If set to `true`, the Remember Me opt-in element is rendered with the `hide` CSS class, effectively keeping it invisible while still allowing the component to function. Useful when clients want autofill behavior without displaying the opt-in checkbox to the donor. Defaults to `false`. + **encryptData**: Boolean. If set to true, the saved form details are encrypted with browser-native AES-GCM (Web Crypto) before being stored, and the resulting bytes are base64-encoded. This applies to both the local cookie and the remote-iframe cookie. The encryption key is randomly generated once per device and kept in `localStorage` (never written to the cookie itself, so it never travels with the transported value). In remote mode, the key and store live in the iframe's origin, so multiple sites sharing that remote origin (e.g. FWW and FWA) share them automatically — however, note that this relies on localStorage access within the cross-origin iframe, which is subject to Chrome's third-party storage partitioning (see known limitations). If the key is missing or decryption otherwise fails (a different device, or cleared storage), the component silently discards the data and falls back to the normal, no-autofill experience. Defaults to false. When enabled with `remoteUrl`, the remote page must implement the matching encrypt/decrypt protocol (see `data-remember.html` for a reference implementation). --- From 9e4c6be746eddc1ec721f30214ca2b3d6cea9c6c Mon Sep 17 00:00:00 2001 From: plpmd Date: Thu, 16 Jul 2026 14:41:20 -0300 Subject: [PATCH 08/10] fix: prevent donor interaction overwrite --- packages/scripts/dist/remember-me.d.ts | 13 ++++++-- packages/scripts/dist/remember-me.js | 33 +++++++++++++++++-- packages/scripts/src/remember-me.ts | 44 ++++++++++++++++++++++++-- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/packages/scripts/dist/remember-me.d.ts b/packages/scripts/dist/remember-me.d.ts index faf378c9..32281125 100644 --- a/packages/scripts/dist/remember-me.d.ts +++ b/packages/scripts/dist/remember-me.d.ts @@ -109,9 +109,18 @@ export declare class RememberMe { * * This method subscribes to the first onFrequencyChange event and, after a * short delay to let SwapAmounts finish its DOM update, re-applies only the - * donation amount. It unsubscribes immediately so it only fires once and - * never interferes with manual donor interactions. + * donation amount. It unsubscribes immediately so it only fires once. + * + * To avoid overwriting a manual donor interaction (if the donor changes + * frequency before the automated SwapAmounts fires), the handler checks + * whether the current amount selection still matches what writeFields set. + * If the donor already picked a different amount, we skip re-application. */ private reapplyDonationAmtAfterSwap; + /** + * Returns the currently selected donation amount value, or null if nothing + * is selected. Checks both predefined radio buttons and the "Other" text input. + */ + private getCurrentSelectedAmount; private isJson; } diff --git a/packages/scripts/dist/remember-me.js b/packages/scripts/dist/remember-me.js index 7a3ca36b..eb0a3124 100644 --- a/packages/scripts/dist/remember-me.js +++ b/packages/scripts/dist/remember-me.js @@ -612,16 +612,30 @@ export class RememberMe { * * This method subscribes to the first onFrequencyChange event and, after a * short delay to let SwapAmounts finish its DOM update, re-applies only the - * donation amount. It unsubscribes immediately so it only fires once and - * never interferes with manual donor interactions. + * donation amount. It unsubscribes immediately so it only fires once. + * + * To avoid overwriting a manual donor interaction (if the donor changes + * frequency before the automated SwapAmounts fires), the handler checks + * whether the current amount selection still matches what writeFields set. + * If the donor already picked a different amount, we skip re-application. */ reapplyDonationAmtAfterSwap() { const savedAmt = this.fieldData[this.fieldDonationAmountRadioName]; if (!savedAmt) return; + // Capture the amount that writeFields just set so we can detect manual changes + const amountAtRegistration = this.getCurrentSelectedAmount(); const handler = () => { // SwapAmounts calls _amount.load() after swapList — give it a tick to settle window.setTimeout(() => { + // If the donor manually changed the amount since registration, + // do not overwrite their choice. + const currentAmt = this.getCurrentSelectedAmount(); + if (currentAmt !== null && + currentAmt !== "" && + currentAmt !== amountAtRegistration) { + return; + } const fieldSelector = "[name='" + this.fieldDonationAmountRadioName + "']"; let radio = document.querySelector(fieldSelector + "[value='" + savedAmt + "']"); if (radio) { @@ -642,6 +656,21 @@ export class RememberMe { // Subscribe once: fires on the first frequency change then auto-unsubscribes this._frequency.onFrequencyChange.one(handler); } + /** + * Returns the currently selected donation amount value, or null if nothing + * is selected. Checks both predefined radio buttons and the "Other" text input. + */ + getCurrentSelectedAmount() { + const fieldSelector = "[name='" + this.fieldDonationAmountRadioName + "']"; + const checkedRadio = document.querySelector(fieldSelector + ":checked"); + if (!checkedRadio) + return null; + if (checkedRadio.value.toLowerCase() === "other") { + const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']"); + return otherField ? otherField.value : null; + } + return checkedRadio.value; + } isJson(str) { try { JSON.parse(str); diff --git a/packages/scripts/src/remember-me.ts b/packages/scripts/src/remember-me.ts index 35e9621d..ccba9a23 100644 --- a/packages/scripts/src/remember-me.ts +++ b/packages/scripts/src/remember-me.ts @@ -729,16 +729,34 @@ export class RememberMe { * * This method subscribes to the first onFrequencyChange event and, after a * short delay to let SwapAmounts finish its DOM update, re-applies only the - * donation amount. It unsubscribes immediately so it only fires once and - * never interferes with manual donor interactions. + * donation amount. It unsubscribes immediately so it only fires once. + * + * To avoid overwriting a manual donor interaction (if the donor changes + * frequency before the automated SwapAmounts fires), the handler checks + * whether the current amount selection still matches what writeFields set. + * If the donor already picked a different amount, we skip re-application. */ private reapplyDonationAmtAfterSwap() { const savedAmt = this.fieldData[this.fieldDonationAmountRadioName]; if (!savedAmt) return; + // Capture the amount that writeFields just set so we can detect manual changes + const amountAtRegistration = this.getCurrentSelectedAmount(); + const handler = () => { // SwapAmounts calls _amount.load() after swapList — give it a tick to settle window.setTimeout(() => { + // If the donor manually changed the amount since registration, + // do not overwrite their choice. + const currentAmt = this.getCurrentSelectedAmount(); + if ( + currentAmt !== null && + currentAmt !== "" && + currentAmt !== amountAtRegistration + ) { + return; + } + const fieldSelector = "[name='" + this.fieldDonationAmountRadioName + "']"; let radio = document.querySelector( @@ -765,6 +783,28 @@ export class RememberMe { // Subscribe once: fires on the first frequency change then auto-unsubscribes this._frequency.onFrequencyChange.one(handler); } + + /** + * Returns the currently selected donation amount value, or null if nothing + * is selected. Checks both predefined radio buttons and the "Other" text input. + */ + private getCurrentSelectedAmount(): string | null { + const fieldSelector = + "[name='" + this.fieldDonationAmountRadioName + "']"; + const checkedRadio = document.querySelector( + fieldSelector + ":checked" + ) as HTMLInputElement; + if (!checkedRadio) return null; + if ( + checkedRadio.value.toLowerCase() === "other" + ) { + const otherField = document.querySelector( + "input[name='" + this.fieldDonationAmountOtherName + "']" + ) as HTMLInputElement; + return otherField ? otherField.value : null; + } + return checkedRadio.value; + } private isJson(str: string) { try { JSON.parse(str); From 705fd4a3e83256187f6332ec84009e8059f3e1fb Mon Sep 17 00:00:00 2001 From: plpmd Date: Thu, 16 Jul 2026 15:07:11 -0300 Subject: [PATCH 09/10] fix: review comments --- packages/scripts/dist/remember-me.d.ts | 8 +++---- packages/scripts/dist/remember-me.js | 27 ++++++++++++----------- packages/scripts/src/remember-me.ts | 30 ++++++++++++++------------ reference-materials/docs/rememberme.md | 4 +++- 4 files changed, 38 insertions(+), 31 deletions(-) diff --git a/packages/scripts/dist/remember-me.d.ts b/packages/scripts/dist/remember-me.d.ts index 32281125..f7e7ef2a 100644 --- a/packages/scripts/dist/remember-me.d.ts +++ b/packages/scripts/dist/remember-me.d.ts @@ -111,10 +111,10 @@ export declare class RememberMe { * short delay to let SwapAmounts finish its DOM update, re-applies only the * donation amount. It unsubscribes immediately so it only fires once. * - * To avoid overwriting a manual donor interaction (if the donor changes - * frequency before the automated SwapAmounts fires), the handler checks - * whether the current amount selection still matches what writeFields set. - * If the donor already picked a different amount, we skip re-application. + * To avoid overwriting a manual donor interaction, the handler checks + * whether the current amount selection is empty/wiped (as SwapAmounts does) + * OR still matches what writeFields originally set. If the donor already + * picked a different amount, we skip re-application. */ private reapplyDonationAmtAfterSwap; /** diff --git a/packages/scripts/dist/remember-me.js b/packages/scripts/dist/remember-me.js index eb0a3124..9db01415 100644 --- a/packages/scripts/dist/remember-me.js +++ b/packages/scripts/dist/remember-me.js @@ -568,7 +568,7 @@ export class RememberMe { // recurrfreq is a radio group — find the specific radio with the saved value and click it const savedValue = this.fieldData[this.fieldNames[i]]; if (savedValue) { - const freqRadio = document.querySelector(fieldSelector + "[value='" + savedValue + "']"); + const freqRadio = document.querySelector(fieldSelector + "[value='" + CSS.escape(savedValue) + "']"); if (freqRadio) { freqRadio.click(); } @@ -576,7 +576,8 @@ export class RememberMe { } else if (this.fieldDonationAmountRadioName === this.fieldNames[i]) { const savedAmt = this.fieldData[this.fieldNames[i]]; - field = document.querySelector(fieldSelector + "[value='" + savedAmt + "']"); + const escapedAmt = CSS.escape(savedAmt); + field = document.querySelector(fieldSelector + "[value='" + escapedAmt + "']"); if (field) { // Saved value matches a predefined radio option — just click it field.click(); @@ -614,10 +615,10 @@ export class RememberMe { * short delay to let SwapAmounts finish its DOM update, re-applies only the * donation amount. It unsubscribes immediately so it only fires once. * - * To avoid overwriting a manual donor interaction (if the donor changes - * frequency before the automated SwapAmounts fires), the handler checks - * whether the current amount selection still matches what writeFields set. - * If the donor already picked a different amount, we skip re-application. + * To avoid overwriting a manual donor interaction, the handler checks + * whether the current amount selection is empty/wiped (as SwapAmounts does) + * OR still matches what writeFields originally set. If the donor already + * picked a different amount, we skip re-application. */ reapplyDonationAmtAfterSwap() { const savedAmt = this.fieldData[this.fieldDonationAmountRadioName]; @@ -628,16 +629,18 @@ export class RememberMe { const handler = () => { // SwapAmounts calls _amount.load() after swapList — give it a tick to settle window.setTimeout(() => { - // If the donor manually changed the amount since registration, - // do not overwrite their choice. const currentAmt = this.getCurrentSelectedAmount(); - if (currentAmt !== null && - currentAmt !== "" && - currentAmt !== amountAtRegistration) { + // Only re-apply if the selection is now empty (DOM was swapped out) + // or still matches what we originally wrote. If the donor manually + // selected a different amount, respect their choice. + const selectionWiped = currentAmt === null || currentAmt === ""; + const selectionUnchanged = currentAmt === amountAtRegistration; + if (!selectionWiped && !selectionUnchanged) { return; } const fieldSelector = "[name='" + this.fieldDonationAmountRadioName + "']"; - let radio = document.querySelector(fieldSelector + "[value='" + savedAmt + "']"); + const escapedAmt = CSS.escape(savedAmt); + let radio = document.querySelector(fieldSelector + "[value='" + escapedAmt + "']"); if (radio) { radio.click(); } diff --git a/packages/scripts/src/remember-me.ts b/packages/scripts/src/remember-me.ts index ccba9a23..68414b09 100644 --- a/packages/scripts/src/remember-me.ts +++ b/packages/scripts/src/remember-me.ts @@ -678,7 +678,7 @@ export class RememberMe { const savedValue = this.fieldData[this.fieldNames[i]]; if (savedValue) { const freqRadio = document.querySelector( - fieldSelector + "[value='" + savedValue + "']" + fieldSelector + "[value='" + CSS.escape(savedValue) + "']" ) as HTMLInputElement; if (freqRadio) { freqRadio.click(); @@ -686,8 +686,9 @@ export class RememberMe { } } else if (this.fieldDonationAmountRadioName === this.fieldNames[i]) { const savedAmt = this.fieldData[this.fieldNames[i]]; + const escapedAmt = CSS.escape(savedAmt); field = document.querySelector( - fieldSelector + "[value='" + savedAmt + "']" + fieldSelector + "[value='" + escapedAmt + "']" ) as HTMLInputElement; if (field) { // Saved value matches a predefined radio option — just click it @@ -731,10 +732,10 @@ export class RememberMe { * short delay to let SwapAmounts finish its DOM update, re-applies only the * donation amount. It unsubscribes immediately so it only fires once. * - * To avoid overwriting a manual donor interaction (if the donor changes - * frequency before the automated SwapAmounts fires), the handler checks - * whether the current amount selection still matches what writeFields set. - * If the donor already picked a different amount, we skip re-application. + * To avoid overwriting a manual donor interaction, the handler checks + * whether the current amount selection is empty/wiped (as SwapAmounts does) + * OR still matches what writeFields originally set. If the donor already + * picked a different amount, we skip re-application. */ private reapplyDonationAmtAfterSwap() { const savedAmt = this.fieldData[this.fieldDonationAmountRadioName]; @@ -746,21 +747,22 @@ export class RememberMe { const handler = () => { // SwapAmounts calls _amount.load() after swapList — give it a tick to settle window.setTimeout(() => { - // If the donor manually changed the amount since registration, - // do not overwrite their choice. const currentAmt = this.getCurrentSelectedAmount(); - if ( - currentAmt !== null && - currentAmt !== "" && - currentAmt !== amountAtRegistration - ) { + + // Only re-apply if the selection is now empty (DOM was swapped out) + // or still matches what we originally wrote. If the donor manually + // selected a different amount, respect their choice. + const selectionWiped = currentAmt === null || currentAmt === ""; + const selectionUnchanged = currentAmt === amountAtRegistration; + if (!selectionWiped && !selectionUnchanged) { return; } const fieldSelector = "[name='" + this.fieldDonationAmountRadioName + "']"; + const escapedAmt = CSS.escape(savedAmt); let radio = document.querySelector( - fieldSelector + "[value='" + savedAmt + "']" + fieldSelector + "[value='" + escapedAmt + "']" ) as HTMLInputElement; if (radio) { radio.click(); diff --git a/reference-materials/docs/rememberme.md b/reference-materials/docs/rememberme.md index 56acf6a5..1491e538 100644 --- a/reference-materials/docs/rememberme.md +++ b/reference-materials/docs/rememberme.md @@ -32,11 +32,13 @@ To enable, add a 'RememberMe' property to the 'options' object in your engrid th **fieldDonationRecurrPayRadioName**: A string containing the name of the Engaging Networks frequency field. You can probably let this stay defaulted. Defaults to 'transaction.recurrpay' +**fieldDonationRecurrFreqRadioName**: A string containing the name of the Engaging Networks recurring frequency radio buttons. You can probably let this stay defaulted. Defaults to 'transaction.recurrfreq' + **fieldDonationAmountOtherCheckboxID**: This is deprecated and will be removed. **hide**: Boolean. If set to `true`, the Remember Me opt-in element is rendered with the `hide` CSS class, effectively keeping it invisible while still allowing the component to function. Useful when clients want autofill behavior without displaying the opt-in checkbox to the donor. Defaults to `false`. -**encryptData**: Boolean. If set to true, the saved form details are encrypted with browser-native AES-GCM (Web Crypto) before being stored, and the resulting bytes are base64-encoded. This applies to both the local cookie and the remote-iframe cookie. The encryption key is randomly generated once per device and kept in `localStorage` (never written to the cookie itself, so it never travels with the transported value). In remote mode, the key and store live in the iframe's origin, so multiple sites sharing that remote origin (e.g. FWW and FWA) share them automatically — however, note that this relies on localStorage access within the cross-origin iframe, which is subject to Chrome's third-party storage partitioning (see known limitations). If the key is missing or decryption otherwise fails (a different device, or cleared storage), the component silently discards the data and falls back to the normal, no-autofill experience. Defaults to false. When enabled with `remoteUrl`, the remote page must implement the matching encrypt/decrypt protocol (see `data-remember.html` for a reference implementation). +**encryptData**: Boolean. If set to true, the saved form details are encrypted with browser-native AES-GCM (Web Crypto) before being stored, and the resulting bytes are base64-encoded. This applies to both the local cookie and the remote-iframe cookie. The encryption key is randomly generated once per device and kept in `localStorage` (never written to the cookie itself, so it never travels with the transported value). In remote mode, the key and store live in the iframe's origin, so multiple sites sharing that remote origin (e.g. FWW and FWA) share them automatically — however, note that this relies on localStorage access within the cross-origin iframe, which is subject to Chrome's third-party storage partitioning (see known limitations). If the key is missing or decryption otherwise fails (a different device, or cleared storage), the component silently discards the data and falls back to the normal, no-autofill experience. Defaults to false. When enabled with `remoteUrl`, the remote page must implement the matching encrypt/decrypt protocol (see the "Sample Remote URL Page Markup" section below for a reference starting point). --- From 474d2d6a901abb617ab4d61d1013aa6011f9ad64 Mon Sep 17 00:00:00 2001 From: plpmd Date: Mon, 20 Jul 2026 14:32:15 -0300 Subject: [PATCH 10/10] fix: prevent null data bug --- packages/scripts/dist/remember-me.js | 24 +++++++++++++++++------- packages/scripts/src/remember-me.ts | 26 +++++++++++++++++--------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/packages/scripts/dist/remember-me.js b/packages/scripts/dist/remember-me.js index 9db01415..3bbad453 100644 --- a/packages/scripts/dist/remember-me.js +++ b/packages/scripts/dist/remember-me.js @@ -92,7 +92,9 @@ export class RememberMe { data.key && data.value !== undefined && data.key === this.cookieName) { - this.updateFieldData(data.value); + if (data.value !== null) { + this.updateFieldData(data.value); + } this.writeFields(); let hasFieldData = Object.keys(this.fieldData).length > 0; if (!hasFieldData) { @@ -153,12 +155,20 @@ export class RememberMe { } } updateFieldData(jsonData) { - if (jsonData) { - let data = JSON.parse(jsonData); - for (let i = 0; i < this.fieldNames.length; i++) { - if (data[this.fieldNames[i]] !== undefined) { - this.fieldData[this.fieldNames[i]] = decodeURIComponent(data[this.fieldNames[i]]); - } + if (!jsonData) + return; + let data; + try { + data = JSON.parse(jsonData); + } + catch (e) { + // Payload is not valid JSON (e.g. corrupted or unexpected ciphertext). + // Fall back silently to the no-autofill experience. + return; + } + for (let i = 0; i < this.fieldNames.length; i++) { + if (data[this.fieldNames[i]] !== undefined) { + this.fieldData[this.fieldNames[i]] = decodeURIComponent(data[this.fieldNames[i]]); } } } diff --git a/packages/scripts/src/remember-me.ts b/packages/scripts/src/remember-me.ts index 68414b09..20e80cb3 100644 --- a/packages/scripts/src/remember-me.ts +++ b/packages/scripts/src/remember-me.ts @@ -145,7 +145,9 @@ export class RememberMe { data.value !== undefined && data.key === this.cookieName ) { - this.updateFieldData(data.value); + if (data.value !== null) { + this.updateFieldData(data.value); + } this.writeFields(); let hasFieldData = Object.keys(this.fieldData).length > 0; if (!hasFieldData) { @@ -202,14 +204,20 @@ export class RememberMe { } } private updateFieldData(jsonData: string) { - if (jsonData) { - let data = JSON.parse(jsonData); - for (let i = 0; i < this.fieldNames.length; i++) { - if (data[this.fieldNames[i]] !== undefined) { - this.fieldData[this.fieldNames[i]] = decodeURIComponent( - data[this.fieldNames[i]] - ); - } + if (!jsonData) return; + let data: DataObj; + try { + data = JSON.parse(jsonData); + } catch (e) { + // Payload is not valid JSON (e.g. corrupted or unexpected ciphertext). + // Fall back silently to the no-autofill experience. + return; + } + for (let i = 0; i < this.fieldNames.length; i++) { + if (data[this.fieldNames[i]] !== undefined) { + this.fieldData[this.fieldNames[i]] = decodeURIComponent( + data[this.fieldNames[i]] + ); } } }