diff --git a/controllers/home.js b/controllers/home.js index 092c2db54..b572dca8b 100644 --- a/controllers/home.js +++ b/controllers/home.js @@ -13,6 +13,12 @@ async function home(req, res) { csrfToken: req.csrfToken(), }; + const coinFlipNumber = Math.random() * 100; + const experimentBranch = getExperimentBranch(req, coinFlipNumber); + + const isUserInExperiment = (experimentBranch === "vb"); + const experimentBranchB = (experimentBranch === "vb" && isUserInExperiment); + let featuredBreach = null; let scanFeaturedBreach = false; @@ -41,6 +47,8 @@ async function home(req, res) { scanFeaturedBreach, pageToken: formTokens.pageToken, csrfToken: formTokens.csrfToken, + experimentBranch, + experimentBranchB, }); } @@ -50,9 +58,39 @@ async function home(req, res) { scanFeaturedBreach, pageToken: formTokens.pageToken, csrfToken: formTokens.csrfToken, + experimentBranch, + isUserInExperiment, + experimentBranchB, }); } +function getExperimentBranch(req, sorterNum) { + + if (req.headers && !req.headers["accept-language"].includes("en") ){ + return false; + } + + // If URL param has experimentBranch entry, use that branch; + if (req.query.experimentBranch) { + if (!["va", "vb"].includes(req.query.experimentBranch)) { + return false; + } + req.session.experimentBranch = req.query.experimentBranch; + return req.query.experimentBranch; + } + + // If user was already assigned a branch, stay in that branch; + if (req.session.experimentBranch) { return req.session.experimentBranch; } + + // Split into two categories + if (sorterNum <= 50) { + req.session.experimentBranch = "vb"; + return "vb"; + } + + return "va"; +} + function getAllBreaches(req, res) { return res.render("top-level-page", { title: "Firefox Monitor", diff --git a/controllers/oauth.js b/controllers/oauth.js index 4ad8e807e..02915cefe 100644 --- a/controllers/oauth.js +++ b/controllers/oauth.js @@ -12,9 +12,14 @@ const HIBP = require("../hibp"); const mozlog = require("../log"); const sha1 = require("../sha1-utils"); - const log = mozlog("controllers.oauth"); +const utmArray = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"]; + +function getUTMNames() { + return utmArray; +} + function init(req, res, next, client = FxAOAuthClient) { // Set a random state string in a cookie so that we can verify // the user when they're redirected back to us after auth. @@ -23,12 +28,18 @@ function init(req, res, next, client = FxAOAuthClient) { const url = new URL(client.code.getUri({state})); const fxaParams = new URL(req.url, AppConstants.SERVER_URL); + req.session.utmContents = {}; + url.searchParams.append("access_type", "offline"); url.searchParams.append("action", "email"); for (const param of fxaParams.searchParams.keys()) { + if (utmArray.includes(param)) { + req.session.utmContents[param] = fxaParams.searchParams.get(param); + } url.searchParams.append(param, fxaParams.searchParams.get(param)); } + res.redirect(url); } @@ -53,6 +64,16 @@ async function confirmed(req, res, next, client = FxAOAuthClient) { const existingUser = await DB.getSubscriberByEmail(email); req.session.user = existingUser; + const returnURL = new URL("/user/dashboard", AppConstants.SERVER_URL); + + if (req.session.utmContents) { + getUTMNames().forEach(param => { + if (req.session.utmContents[param]) { + returnURL.searchParams.append(param, req.session.utmContents[param]); + } + }); + } + // Check if user is signing up or signing in, // then add new users to db and send email. if (!existingUser || existingUser.fxa_refresh_token === null) { @@ -67,7 +88,7 @@ async function confirmed(req, res, next, client = FxAOAuthClient) { unsafeBreachesForEmail = await HIBP.getBreachesForEmail( sha1(email), req.app.locals.breaches, - true, + true ); const utmID = "report"; @@ -90,11 +111,12 @@ async function confirmed(req, res, next, client = FxAOAuthClient) { } ); req.session.user = verifiedSubscriber; - return res.redirect("/user/dashboard"); + + return res.redirect(returnURL.pathname + returnURL.search); } // Update existing user's FxA data await DB._updateFxAData(existingUser, fxaUser.accessToken, fxaUser.refreshToken, fxaProfileData); - res.redirect("/user/dashboard"); + res.redirect(returnURL.pathname + returnURL.search); } module.exports = { diff --git a/controllers/user.js b/controllers/user.js index 97acefc32..8d4a02b38 100644 --- a/controllers/user.js +++ b/controllers/user.js @@ -552,7 +552,15 @@ async function getBreachStats(req, res) { function logout(req, res) { - req.session.reset(); + if (req.session.experimentBranch) { + // Persist experimentBranch across session reset + const experimentBranch = req.session.experimentBranch; + req.session.reset(); + req.session.experimentBranch = experimentBranch; + } else { + req.session.reset(); + } + res.redirect("/"); } diff --git a/public/css/experiment.css b/public/css/experiment.css new file mode 100644 index 000000000..2e4057f41 --- /dev/null +++ b/public/css/experiment.css @@ -0,0 +1,72 @@ +.temp-exp { + position: fixed; + background: purple; + color: white; + width: auto; + top: 0; + right: 0; + text-align: center; + z-index: 9999; + padding: 0.5em 1em; + opacity: 0.5; +} + +.input-group.create-fxa-wrapper { + display: flex; + align-items: center; + width: 100%; + max-width: 248px; + margin: 4px auto 16px; +} + +.create-fxa-wrapper p { + text-align: left; + color: var(--grey3); + margin: 0; + padding: 0 0 0 12px; + font-size: 14px; + line-height: 18px; +} + +.create-fxa-checkbox-wrapper { + position: relative; +} + +.create-fxa-checkbox-input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.create-fxa-checkbox-checkmark:hover { + background: #cfcfd8; +} + +.create-fxa-checkbox-checkmark { + position: relative; + height: 24px; + width: 24px; + background-color: rgba(255, 255, 255, 1); + border-radius: 4px; + cursor: pointer; + display: block; +} + +.create-fxa-checkbox-checkmark::after { + content: ""; + width: 100%; + height: 100%; + display: none; + left: 0; + top: 0; + border-radius: 4px; + background-size: 100% auto; + background-repeat: no-repeat; + background-image: url("/img/svg/purple-check.svg"); +} + +.create-fxa-checkbox-input:checked ~ .create-fxa-checkbox-checkmark::after { + display: block; +} diff --git a/public/img/svg/purple-check.svg b/public/img/svg/purple-check.svg new file mode 100644 index 000000000..fcb101c86 --- /dev/null +++ b/public/img/svg/purple-check.svg @@ -0,0 +1 @@ + diff --git a/public/js/fxa-analytics.js b/public/js/fxa-analytics.js index d48e0f55f..301401074 100644 --- a/public/js/fxa-analytics.js +++ b/public/js/fxa-analytics.js @@ -153,7 +153,7 @@ function sendRecommendationPings(ctaSelector) { }); - document.querySelectorAll(".open-oauth").forEach( async(el) => { + document.querySelectorAll(".open-oauth, .add-metrics-flow-values").forEach( async(el) => { const fxaUrl = new URL("/metrics-flow?", document.body.dataset.fxaAddress); try { @@ -204,5 +204,3 @@ function sendRecommendationPings(ctaSelector) { }); } })(); - - diff --git a/public/js/monitor.js b/public/js/monitor.js index 8d60abf8c..f6bc6c8c1 100644 --- a/public/js/monitor.js +++ b/public/js/monitor.js @@ -36,17 +36,96 @@ function isValidEmail(val) { } -function doOauth(el) { +function doOauth(el, {emailWatch = false} = {}) { + // Growth Experiment: To sidestep the breach scans form, we have to check if + // there is an email address entered into #scan-user-email form. + // If so, we set it similiar to what would happen on form submit. + // + // Options was added to limit how the watched email input field is injected + // in this function. It only moves it if options.emailWatch is set to TRUE; + let url = new URL("/oauth/init", document.body.dataset.serverUrl); url = getFxaUtms(url); - ["flowId", "flowBeginTime", "entrypoint"].forEach(key => { - url.searchParams.append(key, encodeURIComponent(el.dataset[key])); - }); - if (sessionStorage && sessionStorage.length > 0) { - const lastScannedEmail = sessionStorage.getItem(`scanned_${sessionStorage.length}`); - if (lastScannedEmail) { - url.searchParams.append("email", lastScannedEmail); + + ["flowId", "flowBeginTime", "entrypoint", "entrypoint_experiment", "entrypoint_variation", "form_type"].forEach(key => { + if (el.dataset[key]) { + url.searchParams.append(key, encodeURIComponent(el.dataset[key])); } + }); + + if (!sessionStorage) { + window.location.assign(url); + return; + } + + const scannedEmailId = document.querySelector("#scan-user-email input[name=scannedEmailId]"); + + // Preserve entire control function + if (!emailWatch) { + if (sessionStorage && sessionStorage.length > 0) { + + const lastScannedEmail = sessionStorage.getItem("lastScannedEmail"); + if (lastScannedEmail) { + url.searchParams.append("email", lastScannedEmail); + } + } + window.location.assign(url); + return; + } + + // Growth Experiment: This logic is complex to handle the different scenarios of users logging into FxA. + let email = false; + + if (document.querySelector("#scan-user-email input[type=email]")) { + email = document.querySelector("#scan-user-email input[type=email]").value; + + if (!isValidEmail(email)) { + email = false; + } + } + + // Growth Experiment: Reset UTMs from in-line body tag data elements. + ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content" ].forEach(key => { + if (document.body.dataset[key]) { + url.searchParams.delete(key); + url.searchParams.append(key, document.body.dataset[key]); + } + }); + + if (sessionStorage && sessionStorage.length > 0) { + + const lastScannedEmail = sessionStorage.getItem("lastScannedEmail"); + + if (email && lastScannedEmail) { + switch (email) { + case lastScannedEmail: + // The last saved email address and the current entry match, so route it to FxA + email = lastScannedEmail; + break; + case !lastScannedEmail: + // The last saved email address and the current entry DIFFER, so create + // a new entry, launch a new FxA login session with new email prefilled. + sessionStorage.removeItem("lastScannedEmail"); + sessionStorage.setItem("lastScannedEmail", email); + scannedEmailId.value = sessionStorage.length; + break; + } + } else { + if (lastScannedEmail) { + // Control method. User set this by checking breach results + email = lastScannedEmail; + } + } + } else if (email && sessionStorage) { + // Applies to first time user in experiment has no previous FxA ties. + sessionStorage.removeItem("lastScannedEmail"); + sessionStorage.setItem("lastScannedEmail", email); + scannedEmailId.value = sessionStorage.length; + } + + // Append whichever email was set, and start OAuth flow! + if (email) { + url.searchParams.append("email", email); } window.location.assign(url); } @@ -369,4 +448,72 @@ function addBentoObserver(){ const dropDownMenu = document.querySelector(".mobile-nav.show-mobile"); dropDownMenu.addEventListener("click", () => toggleDropDownMenu(dropDownMenu)); + + if (document.body.dataset.experiment) { + const submitBtn = document.querySelector("#scan-user-email input[type='submit']"); + const createFxaCheckbox = document.getElementById("createFxaCheckbox"); + + if (createFxaCheckbox) { + createFxaCheckbox.addEventListener("change", (e)=> { + document.body.dataset.utm_content = "opt-out"; + if (event.target.checked) { + document.body.dataset.utm_content = "opt-in"; + } + }); + } + + submitBtn.addEventListener("click", (e)=> { + document.body.dataset.utm_content = "opt-out"; + + // Email Validation + const scanForm = document.getElementById("scan-user-email"); + const scanFormEmailValue = document.querySelector("#scan-user-email input[type='email']").value; + + if (scanFormEmailValue.length < 1 || !isValidEmail(scanFormEmailValue)) { + scanForm.classList.add("invalid"); + return; + } + + if (createFxaCheckbox && createFxaCheckbox.checked) { + // Applies only to Branches VB and VC, if the checkbox is CHECKED. + e.preventDefault(); + + // Analytics + document.body.dataset.utm_content = "opt-in"; + e.target.dataset.entrypoint = "fx-monitor-alert-me-blue-link"; + if (typeof(ga) !== "undefined") { + ga("send", { + hitType: "event", + eventCategory: "growthuserflow1", + eventAction: "opt-in", + eventLabel: "fx-monitor-alert-me-blue-link", + }); + } + + doOauth(e.target, {emailWatch: true}); + return; + } + + const scanFormActionURL = new URL(scanForm.action); + + ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content" ].forEach(key => { + if (document.body.dataset[key]) { + scanFormActionURL.searchParams.append(key, document.body.dataset[key]); + } + }); + + const revisedActionURL = scanFormActionURL.pathname + scanFormActionURL.search; + + scanForm.action = revisedActionURL; + + if (typeof(ga) !== "undefined") { + ga("send", { + hitType: "event", + eventCategory: "growthuserflow1", + eventAction: "opt-out", + eventLabel: "fx-monitor-alert-me-blue-link", + }); + } + }); + } })(); diff --git a/public/js/scan-email.js b/public/js/scan-email.js index 70179e7a6..9c38e130b 100644 --- a/public/js/scan-email.js +++ b/public/js/scan-email.js @@ -31,8 +31,14 @@ async function hashEmailAndSend(emailFormSubmitEvent) { // set unhashed email in client's sessionStorage and send key to server // so we can pluck these out later in scan-results and not lose them on back clicks if (sessionStorage) { - sessionStorage.setItem(`scanned_${(sessionStorage.length + 1)}`, userEmail); - emailForm.querySelector("input[name=scannedEmailId]").value = sessionStorage.length; + + const lastScannedEmail = sessionStorage.getItem("lastScannedEmail"); + + if (!lastScannedEmail || lastScannedEmail !== userEmail) { + sessionStorage.removeItem("lastScannedEmail"); + sessionStorage.setItem("lastScannedEmail", userEmail); + emailForm.querySelector("input[name=scannedEmailId]").value = sessionStorage.length; + } } // clear input, send ping, and submit diff --git a/public/js/scan-results.js b/public/js/scan-results.js index a76e2b8de..091ce66de 100644 --- a/public/js/scan-results.js +++ b/public/js/scan-results.js @@ -3,7 +3,6 @@ (() => { if (document.getElementById("scannedEmail")) { const scannedEmail = document.getElementById("scannedEmail"); - const emailId = scannedEmail.dataset.scannedEmailId; - scannedEmail.textContent = sessionStorage.getItem(`scanned_${emailId}`); + scannedEmail.textContent = sessionStorage.getItem("lastScannedEmail"); } })(); diff --git a/server.js b/server.js index 33196a853..7aa371006 100644 --- a/server.js +++ b/server.js @@ -95,6 +95,7 @@ const imgSrc = [ "'self'", "https://www.google-analytics.com", "https://firefoxusercontent.com", + "https://mozillausercontent.com/", "https://monitor.cdn.mozilla.net/", ]; @@ -103,6 +104,7 @@ const connectSrc = [ "https://code.cdn.mozilla.net/fonts/", "https://www.google-analytics.com", "https://accounts.firefox.com", + "https://accounts.stage.mozaws.net/metrics-flow", ]; if (AppConstants.FXA_ENABLED) { diff --git a/tests/controllers/user.test.js b/tests/controllers/user.test.js index 5bddc5365..8067b69c4 100644 --- a/tests/controllers/user.test.js +++ b/tests/controllers/user.test.js @@ -261,6 +261,10 @@ test("user add request with invalid email throws error", async () => { test("user verify request with valid token but no session renders email verified page", async () => { const validToken = TEST_EMAIL_ADDRESSES.unverified_email_on_firefox_account.verification_token; + const mockReturnedBreaches = testBreaches.slice(0,2); + HIBP.subscribeHash = jest.fn(); + HIBP.getBreachesForEmail = jest.fn(); + HIBP.getBreachesForEmail.mockReturnValue(mockReturnedBreaches); const req = httpMocks.createRequest({ method: "GET", @@ -283,6 +287,10 @@ test("user verify request with valid token verifies user and redirects to dashbo const validToken = TEST_EMAIL_ADDRESSES.unverified_email_on_firefox_account.verification_token; const testSubscriberEmail = "firefoxaccount@test.com"; const testSubscriber = await DB.getSubscriberByEmail(testSubscriberEmail); + const mockReturnedBreaches = testBreaches.slice(0,2); + HIBP.subscribeHash = jest.fn(); + HIBP.getBreachesForEmail = jest.fn(); + HIBP.getBreachesForEmail.mockReturnValue(mockReturnedBreaches); const req = httpMocks.createRequest({ method: "GET", diff --git a/views/layouts/default.hbs b/views/layouts/default.hbs index 94318132a..e92125509 100644 --- a/views/layouts/default.hbs +++ b/views/layouts/default.hbs @@ -21,7 +21,7 @@ - analytics/default_dataset }} data-bento-app-id="fx-monitor"> + analytics/default_dataset }} data-bento-app-id="fx-monitor" {{#if experimentBranch }} {{> analytics/experiment }} {{/if}}> {{> header/header }} {{{ body }}} {{> footer }} diff --git a/views/partials/analytics/default_dataset.hbs b/views/partials/analytics/default_dataset.hbs index e31b25995..68e40067a 100644 --- a/views/partials/analytics/default_dataset.hbs +++ b/views/partials/analytics/default_dataset.hbs @@ -1,11 +1,14 @@ data-server-url="{{ SERVER_URL }}" data-logos-origin="{{ LOGOS_ORIGIN }}" - data-fxa-enabled="fxa-enabled" data-fxa-address={{ getFxaUrl }} -data-utm_source="{{ SERVER_URL }}" -data-utm_campaign="fx-monitor-fxa-integration" - + +{{#if req.session.experimentBranch}} + data-utm_source="{{ UTM_SOURCE }}" +{{else}} + data-utm_source="{{ SERVER_URL }}" +{{/if}} + {{#if req.session.user}} data-signed-in-user="true" {{else}} diff --git a/views/partials/analytics/experiment.hbs b/views/partials/analytics/experiment.hbs new file mode 100644 index 000000000..492bb0cb8 --- /dev/null +++ b/views/partials/analytics/experiment.hbs @@ -0,0 +1,4 @@ +data-utm_term={{experimentBranch}} +data-experiment={{experimentBranch}} +data-utm_content="{{#if experimentBranchB }}opt-out{{else}}opt-in{{/if}}" +data-utm_campaign="growthuserflow1" diff --git a/views/partials/forms/scan-email-form.hbs b/views/partials/forms/scan-email-form.hbs index fe52016a1..9b68e38ea 100644 --- a/views/partials/forms/scan-email-form.hbs +++ b/views/partials/forms/scan-email-form.hbs @@ -7,6 +7,15 @@ {{ getString "scan-error" }} + {{#if isUserInExperiment}} +
+ +

Stay safe: get automated alerts when an account is breached.

+
+ {{/if}} {{#if scanFeaturedBreach}} @@ -15,7 +24,7 @@ {{#if featuredBreach}} {{else}} - + analytics/fxa id="fx-monitor-check-for-breaches-blue-btn" }} {{/if}} type="submit" value="{{ getString "check-for-breaches" }}"/> {{/if}} {{> forms/loader }} diff --git a/views/partials/imports.hbs b/views/partials/imports.hbs index 8b9211f69..b2d42d160 100644 --- a/views/partials/imports.hbs +++ b/views/partials/imports.hbs @@ -6,6 +6,7 @@ +