Merge pull request #1602 from mozilla/fxa-monitor-exp

Opt-in/out FxA Experiment
This commit is contained in:
luke crouch 2020-04-02 09:45:23 -05:00 коммит произвёл GitHub
Родитель bab91bbfef 32c27c67fd
Коммит 84b5b5bdb7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
16 изменённых файлов: 344 добавлений и 26 удалений

Просмотреть файл

@ -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",

Просмотреть файл

@ -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 = {

Просмотреть файл

@ -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("/");
}

72
public/css/experiment.css Normal file
Просмотреть файл

@ -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;
}

Просмотреть файл

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill="#9059ff" d="M6 14a1 1 0 0 1-.707-.293l-3-3a1 1 0 0 1 1.414-1.414l2.157 2.157 6.316-9.023a1 1 0 0 1 1.639 1.146l-7 10a1 1 0 0 1-.732.427A.863.863 0 0 1 6 14z"></path></svg>

После

Ширина:  |  Высота:  |  Размер: 267 B

Просмотреть файл

@ -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) {
});
}
})();

Просмотреть файл

@ -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",
});
}
});
}
})();

Просмотреть файл

@ -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

Просмотреть файл

@ -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");
}
})();

Просмотреть файл

@ -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) {

Просмотреть файл

@ -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",

Просмотреть файл

@ -21,7 +21,7 @@
<link rel="icon" href="/img/favicons/favicon-128.png" sizes="128x128" />
<link rel="icon" href="/img/favicons/favicon-256.png" sizes="256x256" />
</head>
<body {{> analytics/default_dataset }} data-bento-app-id="fx-monitor">
<body {{> analytics/default_dataset }} data-bento-app-id="fx-monitor" {{#if experimentBranch }} {{> analytics/experiment }} {{/if}}>
{{> header/header }}
{{{ body }}}
{{> footer }}

Просмотреть файл

@ -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}}

Просмотреть файл

@ -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"

Просмотреть файл

@ -7,6 +7,15 @@
<input id="scan-email" class="input-group-field email-to-hash" type="email" name="email" placeholder="{{ getString "scan-placeholder" }}" aria-label="{{ getString "scan-placeholder" }}" autocomplete="off" />
<span id="invalid-email-message" class="error-message">{{ getString "scan-error" }}</span>
</div>
{{#if isUserInExperiment}}
<div class="input-group create-fxa-wrapper">
<label for="createFxaCheckbox" class="create-fxa-checkbox-wrapper">
<input {{#unless experimentBranchB }} checked {{/unless}} class="create-fxa-checkbox-input" id="createFxaCheckbox" type="checkbox" />
<span class="create-fxa-checkbox-checkmark"></span>
</label>
<p>Stay safe: get automated alerts when an account is breached.</p>
</div>
{{/if}}
<input type="hidden" name="emailHash" />
{{#if scanFeaturedBreach}}
<input id="featured" type="hidden" name="featuredBreach" value="{{ featuredBreach.Name }}"/>
@ -15,7 +24,7 @@
{{#if featuredBreach}}
<input type="submit" value="{{ getString "find-out" }}"/>
{{else}}
<input type="submit" value="{{ getString "check-for-breaches" }}"/>
<input {{#if isUserInExperiment}} class="add-metrics-flow-values" data-entrypoint_experiment="growthuserflow1" data-entrypoint_variation={{ experimentBranch }} data-form_type="other" {{> analytics/fxa id="fx-monitor-check-for-breaches-blue-btn" }} {{/if}} type="submit" value="{{ getString "check-for-breaches" }}"/>
{{/if}}
{{> forms/loader }}
</div>

Просмотреть файл

@ -6,6 +6,7 @@
<link rel="stylesheet" href="/css/breach-detail.css">
<link rel="stylesheet" href="/css/breach-stats.css">
<link rel="stylesheet" href="/css/dashboard.css">
<link rel="stylesheet" href="/css/experiment.css">
<link rel="stylesheet" href="/css/email-card.css">
<link rel="stylesheet" href="/css/feature-tip-group.css">
<link rel="stylesheet" href="/css/footer.css">