merge: main -> MNTOR-1486-tab-titles
|
@ -217,25 +217,9 @@ workflows:
|
|||
- lint-js
|
||||
- lint-css
|
||||
- lint-l10n
|
||||
- unit-tests:
|
||||
filters:
|
||||
tags:
|
||||
only: /.*/
|
||||
- unit-tests-psql:
|
||||
name: "unit-tests on psql 9.6"
|
||||
postgres_version: "9.6.24"
|
||||
filters:
|
||||
tags:
|
||||
only: /.*/
|
||||
- unit-tests-psql:
|
||||
name: "unit-tests on psql 13.7"
|
||||
postgres_version: "13.7"
|
||||
filters:
|
||||
tags:
|
||||
only: /.*/
|
||||
- deploy:
|
||||
requires:
|
||||
- unit-tests
|
||||
- lint-js
|
||||
filters:
|
||||
tags:
|
||||
only: /.*/
|
||||
|
@ -251,7 +235,7 @@ workflows:
|
|||
name: Deploy main to Heroku
|
||||
app-name: $HEROKU_MAIN_APP_NAME
|
||||
requires:
|
||||
- unit-tests
|
||||
- lint-js
|
||||
filters:
|
||||
branches:
|
||||
only: main
|
||||
|
@ -260,7 +244,7 @@ workflows:
|
|||
name: Deploy l10n to Heroku
|
||||
app-name: $HEROKU_LOCALIZATION_APP_NAME
|
||||
requires:
|
||||
- unit-tests
|
||||
- lint-js
|
||||
filters:
|
||||
branches:
|
||||
only: localization
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
db/migration
|
||||
coverage
|
||||
loadtests
|
||||
public/dist
|
||||
tests
|
||||
dist
|
||||
dist
|
||||
scripts
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"es2022": true,
|
||||
"node": true,
|
||||
"jest": true
|
||||
},
|
||||
|
@ -51,7 +51,7 @@
|
|||
"check-file/filename-naming-convention": [
|
||||
"error",
|
||||
{
|
||||
"**/*.{js,css}": "CAMEL_CASE"
|
||||
"**/*.{js,css} !src/db/migrations": "CAMEL_CASE"
|
||||
},
|
||||
{
|
||||
"ignoreMiddleExtensions": true
|
||||
|
|
|
@ -45,10 +45,7 @@ jobs:
|
|||
with:
|
||||
node-version: 18.12.1
|
||||
|
||||
- name: Install dependencies /src
|
||||
run: npm install --workspace=src
|
||||
|
||||
- name: Install dependencies root
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Copy env var
|
||||
run: cp .env-dist .env
|
||||
|
@ -60,7 +57,7 @@ jobs:
|
|||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npm run e2e --workspace=src
|
||||
run: npm run e2e
|
||||
env:
|
||||
E2E_TEST_ENV: ${{ inputs.environment != null && inputs.environment || 'local' }}
|
||||
E2E_TEST_BASE_URL: ${{ secrets.E2E_TEST_BASE_URL }}
|
||||
|
|
|
@ -13,8 +13,7 @@ jobs:
|
|||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18.12.x'
|
||||
- run: npm ci --workspace=src
|
||||
- run: npm ci
|
||||
- run: cp .env-dist .env
|
||||
- run: npm run build --if-present --workspace=src
|
||||
- run: npm test --workspace=src
|
||||
- run: npm run build
|
||||
- run: npm test
|
||||
|
|
12
.htmllintrc
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
// names of npm modules to load into htmllint
|
||||
"plugins": [],
|
||||
|
||||
"attr-bans": ["align", "background", "bgcolor", "border", "frameborder", "longdesc", "marginwidth", "marginheight", "onclick", "scrolling", "style", "width"],
|
||||
"attr-name-style": "dash",
|
||||
"attr-req-value": false,
|
||||
"class-style": "dash",
|
||||
"id-class-style": "dash",
|
||||
"indent-style": "spaces",
|
||||
"indent-width": 2
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
tests/
|
|
@ -4,10 +4,14 @@
|
|||
],
|
||||
"rules": {
|
||||
"alpha-value-notation": "number",
|
||||
"import-notation": "string",
|
||||
"no-descending-specificity": [
|
||||
true,
|
||||
{
|
||||
"severity": "warning"
|
||||
"severity": "warning",
|
||||
"ignore": [
|
||||
"selectors-within-list"
|
||||
]
|
||||
}
|
||||
],
|
||||
"property-no-vendor-prefix": null,
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
// See https://jestjs.io/docs/en/manual-mocks
|
||||
function MessageValidator () {}
|
||||
MessageValidator.prototype.validate = function (hash, cb) {
|
||||
if (hash.Signature === 'invalid') {
|
||||
cb(new Error('The message signature is invalid.'))
|
||||
return
|
||||
}
|
||||
cb(null, hash)
|
||||
}
|
||||
|
||||
module.exports = MessageValidator
|
|
@ -1,85 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
/* eslint-disable no-process-env */
|
||||
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
require('dotenv').config({ path: path.join(__dirname, '.env') })
|
||||
|
||||
const requiredEnvVars = [
|
||||
'NODE_ENV',
|
||||
'SERVER_URL',
|
||||
'LOGOS_ORIGIN',
|
||||
'COOKIE_SECRET',
|
||||
'SESSION_DURATION_HOURS',
|
||||
'SMTP_URL',
|
||||
'EMAIL_FROM',
|
||||
'SES_CONFIG_SET',
|
||||
'SES_NOTIFICATION_LOG_ONLY',
|
||||
'BREACH_RESOLUTION_ENABLED',
|
||||
'FXA_ENABLED',
|
||||
'FXA_SETTINGS_URL',
|
||||
'FX_REMOTE_SETTINGS_WRITER_SERVER',
|
||||
'FX_REMOTE_SETTINGS_WRITER_USER',
|
||||
'FX_REMOTE_SETTINGS_WRITER_PASS',
|
||||
'MOZLOG_FMT',
|
||||
'MOZLOG_LEVEL',
|
||||
'OAUTH_AUTHORIZATION_URI',
|
||||
'OAUTH_TOKEN_URI',
|
||||
'OAUTH_PROFILE_URI',
|
||||
'OAUTH_CLIENT_ID',
|
||||
'OAUTH_CLIENT_SECRET',
|
||||
'HIBP_KANON_API_ROOT',
|
||||
'HIBP_KANON_API_TOKEN',
|
||||
'HIBP_API_ROOT',
|
||||
'HIBP_RELOAD_BREACHES_TIMER',
|
||||
'HIBP_THROTTLE_DELAY',
|
||||
'HIBP_THROTTLE_MAX_TRIES',
|
||||
'HIBP_NOTIFY_TOKEN',
|
||||
'DATABASE_URL',
|
||||
'PAGE_TOKEN_TIMER',
|
||||
'PRODUCT_PROMOS_ENABLED',
|
||||
'REDIS_URL',
|
||||
'SENTRY_DSN_LEGACY',
|
||||
'DELETE_UNVERIFIED_SUBSCRIBERS_TIMER',
|
||||
'EXPERIMENT_ACTIVE',
|
||||
'MAX_NUM_ADDRESSES'
|
||||
]
|
||||
|
||||
const optionalEnvVars = [
|
||||
'PORT',
|
||||
'RECRUITMENT_BANNER_LINK',
|
||||
'RECRUITMENT_BANNER_TEXT',
|
||||
'GEOIP_GEOLITE2_PATH',
|
||||
'GEOIP_GEOLITE2_CITY_FILENAME',
|
||||
'GEOIP_GEOLITE2_COUNTRY_FILENAME',
|
||||
'VPN_PROMO_BLOCKED_LOCALES',
|
||||
'EDUCATION_VIDEO_URL_RELAY',
|
||||
'EDUCATION_VIDEO_URL_VPN',
|
||||
'ADMINS',
|
||||
'MONTHLY_CRON_ENABLED',
|
||||
'MONTHLY_CRON_LIMIT'
|
||||
]
|
||||
|
||||
const AppConstants = { }
|
||||
|
||||
if (!process.env.SERVER_URL && process.env.NODE_ENV === 'heroku') {
|
||||
process.env.SERVER_URL = `https://${process.env.HEROKU_APP_NAME}.herokuapp.com`
|
||||
}
|
||||
|
||||
for (const v of requiredEnvVars) {
|
||||
if (process.env[v] === undefined) {
|
||||
throw new Error(`Required environment variable was not set: ${v}`)
|
||||
}
|
||||
AppConstants[v] = process.env[v]
|
||||
}
|
||||
|
||||
optionalEnvVars.forEach(key => {
|
||||
if (key in process.env) AppConstants[key] = process.env[key]
|
||||
})
|
||||
|
||||
AppConstants.VPN_PROMO_BLOCKED_LOCALES = AppConstants.VPN_PROMO_BLOCKED_LOCALES?.split(',')
|
||||
|
||||
AppConstants.AD_UNIT_TOTAL = fs.readdirSync(path.join(__dirname, 'views/partials/ad-units')).length
|
||||
|
||||
module.exports = Object.freeze(AppConstants)
|
|
@ -1,64 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const HIBP = require('../hibp')
|
||||
const DB = require('../db/DB')
|
||||
const { changePWLinks } = require('../lib/changePWLinks')
|
||||
const { getAllEmailsAndBreaches } = require('./user')
|
||||
|
||||
async function getBreachDetail (req, res) {
|
||||
const allBreaches = req.app.locals.breaches
|
||||
const breachName = req.params.breachName
|
||||
const featuredBreach = HIBP.getBreachByName(allBreaches, breachName)
|
||||
|
||||
if (!featuredBreach) {
|
||||
return res.redirect('/')
|
||||
}
|
||||
|
||||
const affectedEmails = []
|
||||
|
||||
if (req.session && req.session.user) {
|
||||
const user = await DB.getSubscriberById(req.session.user.id)
|
||||
req.session.user = user
|
||||
|
||||
const allEmailsAndBreaches = await getAllEmailsAndBreaches(req.session.user, allBreaches)
|
||||
for (const verifiedEmail of allEmailsAndBreaches.verifiedEmails) {
|
||||
for (const breach of verifiedEmail.breaches) {
|
||||
if (breach.Name === breachName) {
|
||||
affectedEmails.push({
|
||||
emailAddress: verifiedEmail.email,
|
||||
recencyIndex: breach.recencyIndex,
|
||||
isResolved: breach.IsResolved
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const changePWLink = getChangePWLink(featuredBreach)
|
||||
res.render('breach-detail', {
|
||||
title: req.fluentFormat('home-title'),
|
||||
featuredBreach,
|
||||
changePWLink,
|
||||
affectedEmails
|
||||
})
|
||||
}
|
||||
|
||||
function getChangePWLink (breach) {
|
||||
if (!breach.DataClasses.includes('passwords')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (changePWLinks.hasOwnProperty(breach.Name)) {
|
||||
return changePWLinks[breach.Name]
|
||||
}
|
||||
|
||||
if (breach.Domain) {
|
||||
return 'https://www.' + breach.Domain
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBreachDetail
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const AppConstants = require('../app-constants')
|
||||
const mozlog = require('../log')
|
||||
const { version, homepage, supportedLocales } = require('../package.json')
|
||||
|
||||
const log = mozlog('controllers.dockerflow')
|
||||
const versionJsonPath = path.join(__dirname, '..', 'version.json')
|
||||
|
||||
// If the version.json file already exists (e.g., created by circle + docker),
|
||||
// don't need to generate it
|
||||
if (!fs.existsSync(versionJsonPath)) {
|
||||
log.info('generating')
|
||||
let commit
|
||||
try {
|
||||
commit = require('git-rev-sync').short()
|
||||
} catch (err) {
|
||||
log.error('generating', { err })
|
||||
}
|
||||
|
||||
const versionJson = {
|
||||
commit,
|
||||
source: homepage,
|
||||
version,
|
||||
languages: supportedLocales
|
||||
}
|
||||
|
||||
fs.writeFileSync(versionJsonPath, JSON.stringify(versionJson, null, 2) + '\n')
|
||||
}
|
||||
|
||||
function vers (req, res) {
|
||||
if (AppConstants.NODE_ENV === 'heroku') {
|
||||
/* eslint-disable no-process-env */
|
||||
return res.json({
|
||||
commit: process.env.HEROKU_SLUG_COMMIT,
|
||||
version: process.env.HEROKU_SLUG_COMMIT,
|
||||
source: homepage,
|
||||
languages: '*'
|
||||
})
|
||||
/* eslint-enable no-process-env */
|
||||
}
|
||||
return res.sendFile(versionJsonPath)
|
||||
}
|
||||
|
||||
function heartbeat (req, res) {
|
||||
return res.send('OK')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
vers,
|
||||
heartbeat
|
||||
}
|
|
@ -1,142 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const EmailUtils = require('../email-utils')
|
||||
const AppConstants = require('../app-constants')
|
||||
const path = require('path')
|
||||
const { readFile } = require('fs/promises')
|
||||
|
||||
const partials = ['alert', 'report', 'email_verify', 'email-monthly-unresolved']
|
||||
const mockBreaches = ['Dropbox', 'Apollo', 'Adobe']
|
||||
const mockBreachStats = {
|
||||
passwords: {
|
||||
count: 1,
|
||||
numResolved: 0
|
||||
},
|
||||
numBreaches: {
|
||||
count: 2,
|
||||
numResolved: 0,
|
||||
numUnresolved: 2
|
||||
},
|
||||
monitoredEmails: {
|
||||
count: 2
|
||||
}
|
||||
}
|
||||
|
||||
async function getEmailMockup (req, res) {
|
||||
if (!['dev', 'heroku', 'stage'].includes(AppConstants.NODE_ENV)) return res.sendStatus(404)
|
||||
|
||||
if (!req.query.partial) {
|
||||
return res.redirect('/email-l10n/?partial=email_verify&type=email_verify') // default when no specific partial requested
|
||||
} else if (!partials.includes(req.query.partial)) {
|
||||
return res.sendStatus(404) // requested partial is not in partials list
|
||||
}
|
||||
|
||||
const emailStr = await readFile(path.resolve('views/layouts/email-2022.hbs'), { encoding: 'utf-8' })
|
||||
const emailStyle = emailStr.substring(emailStr.indexOf('<style>'), emailStr.indexOf('</style>') + 8)
|
||||
const { unsafeBreachesForEmail, emailSubject, breachAlert, unsubscribeUrl, heading, subheading } = getPartialData(req.query.type, req.app.locals.breaches)
|
||||
const utmCampaign = req.query.type
|
||||
|
||||
res.render('email_l10n', {
|
||||
layout: 'email_l10n_mockups.hbs',
|
||||
unsafeBreachesForEmail,
|
||||
supportedLocales: req.supportedLocales,
|
||||
whichPartial: `email_partials/${req.query.partial}`,
|
||||
partialType: req.query.type,
|
||||
breachedEmail: req.user?.primary_email || 'breachedEmail@testing.com',
|
||||
breachAlert,
|
||||
emailSubject: req.fluentFormat(emailSubject),
|
||||
heading: req.fluentFormat(heading),
|
||||
subheading: req.fluentFormat(subheading),
|
||||
ctaHref: EmailUtils.getEmailCtaHref(utmCampaign, 'dashboard-cta'),
|
||||
utmCampaign,
|
||||
emailStyle,
|
||||
unsubscribeUrl,
|
||||
csrfToken: req.csrfToken(),
|
||||
breachStats: mockBreachStats
|
||||
})
|
||||
}
|
||||
|
||||
function getPartialData (partialType, breaches) {
|
||||
const unsafeBreachesForEmail = []
|
||||
let emailSubject, breachAlert, unsubscribeUrl, heading, subheading
|
||||
|
||||
switch (partialType) {
|
||||
case 'email_verify':
|
||||
emailSubject = 'email-subject-verify'
|
||||
heading = 'email-verify-heading'
|
||||
subheading = 'email-verify-subhead'
|
||||
break
|
||||
case 'noBreaches':
|
||||
emailSubject = 'email-subject-no-breaches'
|
||||
heading = 'email-breach-summary'
|
||||
break
|
||||
case 'singleBreach':
|
||||
emailSubject = 'email-subject-found-breaches'
|
||||
heading = 'email-breach-summary'
|
||||
unsafeBreachesForEmail.push(breaches.find(breach => breach.Name === mockBreaches[0]))
|
||||
break
|
||||
case 'multipleBreaches':
|
||||
emailSubject = 'email-subject-found-breaches'
|
||||
heading = 'email-breach-summary'
|
||||
mockBreaches.forEach(name => {
|
||||
unsafeBreachesForEmail.push(breaches.find(breach => breach.Name === name))
|
||||
})
|
||||
break
|
||||
case 'alert':
|
||||
emailSubject = 'breach-alert-subject'
|
||||
heading = 'email-spotted-new-breach'
|
||||
breachAlert = breaches.find(breach => breach.Name === 'LinkedIn')
|
||||
break
|
||||
case 'email-monthly-unresolved':
|
||||
emailSubject = 'email-unresolved-heading'
|
||||
heading = 'email-unresolved-heading'
|
||||
subheading = 'email-unresolved-subhead'
|
||||
unsubscribeUrl = 'fakeunsubscribe.test.com'
|
||||
break
|
||||
}
|
||||
|
||||
return { unsafeBreachesForEmail, emailSubject, breachAlert, unsubscribeUrl, heading, subheading }
|
||||
}
|
||||
|
||||
async function sendTestEmail (req, res) {
|
||||
const { unsafeBreachesForEmail, emailSubject, breachAlert, heading, subheading } = getPartialData(req.body.partialType, req.app.locals.breaches)
|
||||
const supportedLocales = req.supportedLocales
|
||||
|
||||
const unsubscribeUrl = EmailUtils.getMonthlyUnsubscribeUrl(req.user, 'monthly-unresolved', 'unsubscribe-cta')
|
||||
const utmCampaign = req.body.partialType
|
||||
const context = {
|
||||
whichPartial: req.body.whichPartial,
|
||||
supportedLocales,
|
||||
heading: req.fluentFormat(heading),
|
||||
subheading: req.fluentFormat(subheading),
|
||||
breachedEmail: req.user.primary_email,
|
||||
unsafeBreachesForEmail,
|
||||
breachAlert,
|
||||
breachStats: req.user.breach_stats,
|
||||
unsubscribeUrl,
|
||||
ctaHref: EmailUtils.getEmailCtaHref(utmCampaign, 'dashboard-cta'),
|
||||
utmCampaign
|
||||
}
|
||||
|
||||
await EmailUtils.sendEmail(req.body.recipientEmail, req.fluentFormat(emailSubject), 'email-2022', context)
|
||||
|
||||
res.send(`
|
||||
<h2>Email sent!</h2>
|
||||
<a href='/email-l10n/'>Go Back</a> | <a href='/user/logout'>Sign Out</a>
|
||||
`)
|
||||
}
|
||||
|
||||
function notFound (req, res) {
|
||||
res.status(404)
|
||||
res.render('subpage', {
|
||||
analyticsID: 'error',
|
||||
headline: req.fluentFormat('error-headline'),
|
||||
subhead: req.fluentFormat('home-not-found')
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getEmailMockup,
|
||||
notFound,
|
||||
sendTestEmail
|
||||
}
|
|
@ -1,141 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const { negotiateLanguages, acceptedLanguages } = require('fluent-langneg')
|
||||
|
||||
const AppConstants = require('../app-constants')
|
||||
const DB = require('../db/DB')
|
||||
const EmailUtils = require('../email-utils')
|
||||
const HIBP = require('../hibp')
|
||||
const { LocaleUtils } = require('../locale-utils')
|
||||
const mozlog = require('../log')
|
||||
|
||||
const log = mozlog('controllers.hibp')
|
||||
|
||||
// Get addresses and language from either subscribers or
|
||||
// email_addresses fields
|
||||
|
||||
function getAddressesAndLanguageForEmail (recipient) {
|
||||
const signupLanguage = recipient.signup_language
|
||||
if (recipient.hasOwnProperty('email') && recipient.email) {
|
||||
// email_addresses record, check all_emails_to_primary
|
||||
if (recipient.all_emails_to_primary) {
|
||||
return {
|
||||
recipientEmail: recipient.primary_email,
|
||||
breachedEmail: recipient.email,
|
||||
signupLanguage
|
||||
}
|
||||
}
|
||||
return {
|
||||
recipientEmail: recipient.email,
|
||||
breachedEmail: recipient.email,
|
||||
signupLanguage
|
||||
}
|
||||
}
|
||||
return {
|
||||
recipientEmail: recipient.primary_email,
|
||||
breachedEmail: recipient.primary_email,
|
||||
signupLanguage
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whenever a breach is detected on the HIBP side, HIBP sends a request to this endpoint.
|
||||
* A breach notification contains the following parameters:
|
||||
* - breachName
|
||||
* - hashPrefix
|
||||
* - hashSuffixes
|
||||
* More about how account identities are anonymized: https://blog.mozilla.org/security/2018/06/25/scanning-breached-accounts-k-anonymity/
|
||||
*/
|
||||
async function notify (req, res) {
|
||||
if (!req.token || req.token !== AppConstants.HIBP_NOTIFY_TOKEN) {
|
||||
const errorMessage = 'HIBP notify endpoint requires valid authorization token.'
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
if (!['breachName', 'hashPrefix', 'hashSuffixes'].every(req.body.hasOwnProperty, req.body)) {
|
||||
throw new Error('Breach notification requires breachName, hashPrefix, and hashSuffixes.')
|
||||
}
|
||||
|
||||
const reqBreachName = req.body.breachName.toLowerCase()
|
||||
const reqHashPrefix = req.body.hashPrefix.toLowerCase()
|
||||
let breachAlert = HIBP.getBreachByName(req.app.locals.breaches, reqBreachName)
|
||||
|
||||
if (!breachAlert) {
|
||||
// If breach isn't found, try to reload breaches from HIBP
|
||||
await HIBP.loadBreachesIntoApp(req.app)
|
||||
breachAlert = HIBP.getBreachByName(req.app.locals.breaches, reqBreachName)
|
||||
if (!breachAlert) {
|
||||
// If breach *still* isn't found, we have a real error
|
||||
throw new Error('Unrecognized breach: ' + reqBreachName)
|
||||
}
|
||||
}
|
||||
|
||||
if (breachAlert.IsSpamList || breachAlert.IsFabricated || !breachAlert.IsVerified || breachAlert.Domain === '') {
|
||||
log.info(`${breachAlert.Name} is fabricated, a spam list, not associated with a website, or unverified. \n Breach Alert not sent.`)
|
||||
return res.status(200).json(
|
||||
{ info: 'Breach loaded into database. Subscribers not notified.' }
|
||||
)
|
||||
}
|
||||
|
||||
const hashes = req.body.hashSuffixes.map(suffix => reqHashPrefix + suffix.toLowerCase())
|
||||
const subscribers = await DB.getSubscribersByHashes(hashes)
|
||||
const emailAddresses = await DB.getEmailAddressesByHashes(hashes)
|
||||
const recipients = subscribers.concat(emailAddresses)
|
||||
log.info('notification', { length: recipients.length, breachAlertName: breachAlert.Name })
|
||||
|
||||
const utmID = 'breach-alert'
|
||||
const notifiedRecipients = []
|
||||
|
||||
for (const recipient of recipients) {
|
||||
log.info('notify', { recipient })
|
||||
// Get subscriber ID from "subscriber_id" property (if email_addresses record)
|
||||
// or from "id" property (if subscribers record)
|
||||
const subscriberId = recipient.subscriber_id || recipient.id
|
||||
const { recipientEmail, breachedEmail, signupLanguage } = getAddressesAndLanguageForEmail(recipient)
|
||||
const ctaHref = EmailUtils.getEmailCtaHref(utmID, 'dashboard-cta', subscriberId)
|
||||
|
||||
const requestedLanguage = signupLanguage ? acceptedLanguages(signupLanguage) : ''
|
||||
const supportedLocales = negotiateLanguages(
|
||||
requestedLanguage,
|
||||
req.app.locals.AVAILABLE_LANGUAGES,
|
||||
{ defaultLocale: 'en' }
|
||||
)
|
||||
|
||||
const subject = LocaleUtils.fluentFormat(supportedLocales, 'breach-alert-subject')
|
||||
const heading = LocaleUtils.fluentFormat(supportedLocales, 'email-spotted-new-breach')
|
||||
const template = 'email-2022'
|
||||
if (!notifiedRecipients.includes(breachedEmail)) {
|
||||
await EmailUtils.sendEmail(
|
||||
recipientEmail, subject, template,
|
||||
{
|
||||
breachedEmail,
|
||||
recipientEmail,
|
||||
subscriberId,
|
||||
supportedLocales,
|
||||
breachAlert,
|
||||
SERVER_URL: AppConstants.SERVER_URL,
|
||||
unsubscribeUrl: EmailUtils.getUnsubscribeUrl(recipient, utmID),
|
||||
ctaHref,
|
||||
utmCampaign: utmID,
|
||||
whichPartial: 'email_partials/alert',
|
||||
heading
|
||||
}
|
||||
)
|
||||
notifiedRecipients.push(breachedEmail)
|
||||
}
|
||||
}
|
||||
log.info('notified', { length: notifiedRecipients.length })
|
||||
res.status(200)
|
||||
res.json(
|
||||
{ info: 'Notified subscribers of breach.' }
|
||||
)
|
||||
}
|
||||
|
||||
async function breaches (req, res, next) {
|
||||
res.append('Last-Modified', req.app.locals.mostRecentBreachDateTime)
|
||||
res.json(req.app.locals.breaches)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
notify,
|
||||
breaches
|
||||
}
|
|
@ -1,169 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const AppConstants = require('../app-constants')
|
||||
const DB = require('../db/DB')
|
||||
const HIBP = require('../hibp')
|
||||
const { scanResult } = require('../scan-results')
|
||||
const {
|
||||
generatePageToken,
|
||||
getExperimentFlags,
|
||||
getUTMContents,
|
||||
setAdUnitCookie
|
||||
} = require('./utils')
|
||||
|
||||
const EXPERIMENTS_ENABLED = (AppConstants.EXPERIMENT_ACTIVE === '1')
|
||||
|
||||
function _getFeaturedBreach (allBreaches, breachQueryValue) {
|
||||
if (!breachQueryValue) {
|
||||
return null
|
||||
}
|
||||
const lowercaseBreachValue = breachQueryValue.toLowerCase()
|
||||
return HIBP.getBreachByName(allBreaches, lowercaseBreachValue)
|
||||
}
|
||||
|
||||
async function home (req, res) {
|
||||
const formTokens = {
|
||||
pageToken: AppConstants.PAGE_TOKEN_TIMER > 0 ? generatePageToken(req) : '',
|
||||
csrfToken: req.csrfToken()
|
||||
}
|
||||
|
||||
const adUnitNum = setAdUnitCookie(req, res)
|
||||
|
||||
let featuredBreach = null
|
||||
let scanFeaturedBreach = false
|
||||
|
||||
if (req.session.user && !req.query.breach) {
|
||||
return res.redirect('/user/dashboard')
|
||||
}
|
||||
|
||||
// Rewrites the /share/{COLOR} links to /
|
||||
if (req.session.redirectHome) {
|
||||
req.session.redirectHome = false
|
||||
return res.redirect('/')
|
||||
}
|
||||
|
||||
// Note - If utmOverrides get set, they are unenrolled from the experiment
|
||||
const utmOverrides = getUTMContents(req)
|
||||
const experimentFlags = getExperimentFlags(req, EXPERIMENTS_ENABLED)
|
||||
|
||||
if (req.params && req.params.breach) {
|
||||
req.query.breach = req.params.breach
|
||||
}
|
||||
|
||||
if (req.query.breach) {
|
||||
featuredBreach = _getFeaturedBreach(req.app.locals.breaches, req.query.breach)
|
||||
|
||||
if (!featuredBreach) {
|
||||
return notFound(req, res)
|
||||
}
|
||||
|
||||
const scanRes = await scanResult(req)
|
||||
|
||||
if (scanRes.doorhangerScan) {
|
||||
return res.render('scan', Object.assign(scanRes, formTokens))
|
||||
}
|
||||
scanFeaturedBreach = true
|
||||
|
||||
return res.render('monitor', {
|
||||
title: req.fluentFormat('home-title'),
|
||||
featuredBreach,
|
||||
scanFeaturedBreach,
|
||||
pageToken: formTokens.pageToken,
|
||||
csrfToken: formTokens.csrfToken,
|
||||
experimentFlags,
|
||||
utmOverrides,
|
||||
adUnit: `ad-units/ad-unit-${adUnitNum}`
|
||||
})
|
||||
}
|
||||
|
||||
res.render('monitor', {
|
||||
title: req.fluentFormat('home-title'),
|
||||
featuredBreach,
|
||||
scanFeaturedBreach,
|
||||
pageToken: formTokens.pageToken,
|
||||
csrfToken: formTokens.csrfToken,
|
||||
experimentFlags,
|
||||
utmOverrides,
|
||||
adUnit: `ad-units/ad-unit-${adUnitNum}`
|
||||
})
|
||||
}
|
||||
|
||||
function getAllBreaches (req, res) {
|
||||
return res.render('top-level-page', {
|
||||
title: 'Firefox Monitor',
|
||||
whichPartial: 'top-level/all-breaches'
|
||||
})
|
||||
}
|
||||
|
||||
function getSecurityTips (req, res) {
|
||||
return res.render('top-level-page', {
|
||||
title: req.fluentFormat('home-title'),
|
||||
whichPartial: 'top-level/security-tips'
|
||||
})
|
||||
}
|
||||
|
||||
function getAboutPage (req, res) {
|
||||
return res.render('about', {
|
||||
title: req.fluentFormat('about-firefox-monitor')
|
||||
})
|
||||
}
|
||||
|
||||
function getBentoStrings (req, res) {
|
||||
const localizedBentoStrings = {
|
||||
bentoButtonTitle: req.fluentFormat('bento-button-title'),
|
||||
bentoHeadline: req.fluentFormat('fx-makes-tech'),
|
||||
bentoBottomLink: req.fluentFormat('made-by-mozilla'),
|
||||
fxDesktop: req.fluentFormat('fx-desktop'),
|
||||
fxMobile: req.fluentFormat('fx-mobile'),
|
||||
fxMonitor: req.fluentFormat('fx-monitor'),
|
||||
pocket: req.fluentFormat('pocket'),
|
||||
mozVPN: 'Mozilla VPN',
|
||||
mobileCloseBentoButtonTitle: req.fluentFormat('mobile-close-bento-button-title')
|
||||
}
|
||||
return res.json(localizedBentoStrings)
|
||||
}
|
||||
|
||||
function _addToWaitlistsJoined (user, waitlist) {
|
||||
if (!user.waitlists_joined) {
|
||||
return { [waitlist]: { notified: false } }
|
||||
}
|
||||
user.waitlists_joined[waitlist] = { notified: false }
|
||||
return user.waitlists_joined
|
||||
}
|
||||
|
||||
function addEmailToWaitlist (req, res) {
|
||||
if (!req.user) {
|
||||
return res.redirect('/')
|
||||
}
|
||||
const user = req.user
|
||||
const updatedWaitlistsJoined = _addToWaitlistsJoined(user, 'remove_data')
|
||||
DB.setWaitlistsJoined({ user, updatedWaitlistsJoined })
|
||||
return res.json('email-added')
|
||||
}
|
||||
|
||||
function testSentry (req, res) {
|
||||
if (!req.user || !req.user.primary_email.endsWith('@mozilla.com')) {
|
||||
return res.redirect('/')
|
||||
}
|
||||
throw new Error('Successfully tested exception handling')
|
||||
}
|
||||
|
||||
function notFound (req, res) {
|
||||
res.status(404)
|
||||
res.render('subpage', {
|
||||
analyticsID: 'error',
|
||||
headline: req.fluentFormat('error-headline'),
|
||||
subhead: req.fluentFormat('home-not-found')
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addEmailToWaitlist,
|
||||
getAboutPage,
|
||||
getAllBreaches,
|
||||
getBentoStrings,
|
||||
getSecurityTips,
|
||||
home,
|
||||
notFound,
|
||||
testSentry
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
// IP location data includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com.
|
||||
// For testing, you can compare IPs to the corresponding json, eg: https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
|
||||
|
||||
'use strict'
|
||||
|
||||
const AppConstants = require('../app-constants')
|
||||
const { readLocationData } = require('../ip-location-service')
|
||||
|
||||
async function getIpLocation (req, res) {
|
||||
let clientIp
|
||||
|
||||
switch (AppConstants.NODE_ENV) {
|
||||
case 'dev':
|
||||
clientIp = req.headers['test-ip'] || '216.160.83.56' // fallback an IP that exists in limited/local GeoLite2 test DB
|
||||
break
|
||||
case 'heroku':
|
||||
case 'stage':
|
||||
clientIp = req.headers['test-ip'] || req.ip // Use "test-ip" header if available, fallback to original IP
|
||||
break
|
||||
default:
|
||||
clientIp = req.ip
|
||||
}
|
||||
|
||||
if (clientIp === req.session.locationData?.clientIp) {
|
||||
return res.status(200).json(req.session.locationData) // return cached data
|
||||
}
|
||||
|
||||
const locationData = await readLocationData(clientIp, req.supportedLocales)
|
||||
|
||||
if (locationData) {
|
||||
req.session.locationData = Object.assign(locationData, { clientIp }) // cache new location data, adding clientIP key
|
||||
return res.status(200).json(req.session.locationData)
|
||||
}
|
||||
|
||||
return res.status(200).json({ clientIp })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getIpLocation
|
||||
}
|
|
@ -1,114 +0,0 @@
|
|||
'use strict'
|
||||
const { URL } = require('url')
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const AppConstants = require('../app-constants')
|
||||
const DB = require('../db/DB')
|
||||
const EmailUtils = require('../email-utils')
|
||||
const { FXA, FxAOAuthClient } = require('../lib/fxa')
|
||||
const { FluentError } = require('../locale-utils')
|
||||
const HIBP = require('../hibp')
|
||||
const mozlog = require('../log')
|
||||
const sha1 = require('../sha1-utils')
|
||||
|
||||
const log = mozlog('controllers.oauth')
|
||||
|
||||
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.
|
||||
const state = crypto.randomBytes(40).toString('hex')
|
||||
req.session.state = state
|
||||
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()) {
|
||||
url.searchParams.append(param, fxaParams.searchParams.get(param))
|
||||
}
|
||||
|
||||
res.redirect(url)
|
||||
}
|
||||
|
||||
async function confirmed (req, res, next, client = FxAOAuthClient) {
|
||||
if (!req.session.state) {
|
||||
throw new FluentError('oauth-invalid-session')
|
||||
}
|
||||
|
||||
if (req.session.state !== req.query.state) {
|
||||
throw new FluentError('oauth-invalid-session')
|
||||
}
|
||||
|
||||
const fxaUser = await client.code.getToken(req.originalUrl, { state: req.session.state })
|
||||
// Clear the session.state to clean up and avoid any replays
|
||||
req.session.state = null
|
||||
log.debug('fxa-confirmed-fxaUser', fxaUser)
|
||||
const fxaProfileData = await FXA.getProfileData(fxaUser.accessToken)
|
||||
log.debug('fxa-confirmed-profile-data', fxaProfileData)
|
||||
const email = JSON.parse(fxaProfileData).email
|
||||
|
||||
const existingUser = await DB.getSubscriberByEmail(email)
|
||||
req.session.user = existingUser
|
||||
|
||||
const returnURL = new URL('/user/dashboard', AppConstants.SERVER_URL)
|
||||
const originalURL = new URL(req.originalUrl, AppConstants.SERVER_URL)
|
||||
|
||||
for (const [key, value] of originalURL.searchParams.entries()) {
|
||||
if (key.startsWith('utm_')) returnURL.searchParams.append(key, value)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// req.session.newUser determines whether or not we show "fxa_new_user_bar" in template
|
||||
req.session.newUser = true
|
||||
const signupLanguage = req.headers['accept-language']
|
||||
const verifiedSubscriber = await DB.addSubscriber(email, signupLanguage, fxaUser.accessToken, fxaUser.refreshToken, fxaProfileData)
|
||||
|
||||
// duping some of user/verify for now
|
||||
let unsafeBreachesForEmail = []
|
||||
|
||||
unsafeBreachesForEmail = await HIBP.getBreachesForEmail(
|
||||
sha1(email),
|
||||
req.app.locals.breaches,
|
||||
true
|
||||
)
|
||||
|
||||
const utmID = 'report'
|
||||
const reportSubject = unsafeBreachesForEmail.length ? req.fluentFormat('email-subject-found-breaches') : req.fluentFormat('email-subject-no-breaches')
|
||||
|
||||
await EmailUtils.sendEmail(
|
||||
email,
|
||||
reportSubject,
|
||||
'email-2022',
|
||||
{
|
||||
supportedLocales: req.supportedLocales,
|
||||
breachedEmail: email,
|
||||
recipientEmail: email,
|
||||
date: req.fluentFormat(new Date()),
|
||||
unsafeBreachesForEmail,
|
||||
ctaHref: EmailUtils.getEmailCtaHref(utmID, 'dashboard-cta'),
|
||||
utmCampaign: utmID,
|
||||
unsubscribeUrl: EmailUtils.getUnsubscribeUrl(verifiedSubscriber, utmID),
|
||||
whichPartial: 'email_partials/report',
|
||||
heading: req.fluentFormat('email-breach-summary')
|
||||
}
|
||||
)
|
||||
req.session.user = verifiedSubscriber
|
||||
|
||||
return res.redirect(returnURL.pathname + returnURL.search)
|
||||
}
|
||||
// Update existing user's FxA data
|
||||
await DB._updateFxAData(existingUser, fxaUser.accessToken, fxaUser.refreshToken, fxaProfileData)
|
||||
|
||||
res.redirect(returnURL.pathname + returnURL.search)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
confirmed
|
||||
}
|
|
@ -1,104 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
const AppConstants = require('../app-constants')
|
||||
const { FluentError } = require('../locale-utils')
|
||||
// const { generatePageToken } = require("./utils");
|
||||
const mozlog = require('../log')
|
||||
const { scanResult } = require('../scan-results')
|
||||
const sha1 = require('../sha1-utils')
|
||||
|
||||
const log = mozlog('controllers.scan')
|
||||
|
||||
function _decryptPageToken (encryptedPageToken) {
|
||||
let decipher
|
||||
|
||||
if (encryptedPageToken.slice(24, 25) === '.') {
|
||||
// iv becomes 24 characters long when represented as base64: ceil(16 / 3) * 4 = 24
|
||||
const iv = Buffer.from(encryptedPageToken.slice(0, 24), 'base64')
|
||||
const key = crypto.pbkdf2Sync(AppConstants.COOKIE_SECRET, iv.toString(), 10000, 32, 'sha512')
|
||||
|
||||
decipher = crypto.createDecipheriv('aes-256-cbc', key, iv)
|
||||
encryptedPageToken = encryptedPageToken.slice(25)
|
||||
} else {
|
||||
// TODO: replace deprecated api
|
||||
// eslint-disable-next-line n/no-deprecated-api
|
||||
decipher = crypto.createDecipher('aes-256-cbc', AppConstants.COOKIE_SECRET)
|
||||
}
|
||||
|
||||
const decryptedPageToken = Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedPageToken, 'base64')),
|
||||
decipher.final()
|
||||
]).toString('utf8')
|
||||
|
||||
return JSON.parse(decryptedPageToken)
|
||||
}
|
||||
|
||||
function _validatePageToken (pageToken, req) {
|
||||
const requestIP = req.headers['x-real-ip'] || req.ip
|
||||
const pageTokenIP = pageToken.ip
|
||||
if (pageToken.ip !== requestIP) {
|
||||
log.error('_validatePageToken', { msg: 'IP mis-match', pageTokenIP, requestIP })
|
||||
return false
|
||||
}
|
||||
if (Date.now() - new Date(pageToken.date) >= AppConstants.PAGE_TOKEN_TIMER * 1000) {
|
||||
log.error('_validatePageToken', { msg: 'expired pageToken' })
|
||||
return false
|
||||
}
|
||||
/* TODO: block on scans-per-ip instead of scans-per-timespan
|
||||
if (req.session.scans.length > 5) {
|
||||
console.log("too many scans this session");
|
||||
return res.render("error");
|
||||
}
|
||||
if (!req.session.scans.includes(emailHash)) {
|
||||
console.log(`adding ${emailHash} to session scans`);
|
||||
req.session.scans.push(emailHash);
|
||||
}
|
||||
*/
|
||||
return pageToken
|
||||
}
|
||||
|
||||
async function post (req, res) {
|
||||
const emailHash = req.body.emailHash
|
||||
const encryptedPageToken = req.body.pageToken
|
||||
let validPageToken = false
|
||||
|
||||
// for #688: use a page token to check that scans come from real pages
|
||||
if (AppConstants.PAGE_TOKEN_TIMER > 0) {
|
||||
if (!encryptedPageToken) {
|
||||
throw new FluentError('error-scan-page-token')
|
||||
}
|
||||
const decryptedPageToken = _decryptPageToken(encryptedPageToken)
|
||||
validPageToken = _validatePageToken(decryptedPageToken, req)
|
||||
|
||||
if (!validPageToken) {
|
||||
throw new FluentError('error-scan-page-token')
|
||||
}
|
||||
}
|
||||
|
||||
if (!emailHash || emailHash === sha1('')) {
|
||||
return res.redirect('/')
|
||||
}
|
||||
|
||||
const scanRes = await scanResult(req)
|
||||
|
||||
const formTokens = {
|
||||
pageToken: encryptedPageToken,
|
||||
csrfToken: req.csrfToken()
|
||||
}
|
||||
|
||||
if (req.session.user && scanRes.selfScan && !req.body.featuredBreach) {
|
||||
return res.redirect('/user/dashboard')
|
||||
}
|
||||
res.render('scan', Object.assign(scanRes, formTokens))
|
||||
}
|
||||
|
||||
function get (req, res) {
|
||||
res.redirect('/')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
post,
|
||||
get
|
||||
}
|
|
@ -1,95 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const MessageValidator = require('sns-validator')
|
||||
|
||||
const DB = require('../db/DB')
|
||||
|
||||
const mozlog = require('../log')
|
||||
|
||||
const validator = new MessageValidator()
|
||||
const log = mozlog('controllers.ses')
|
||||
|
||||
async function notification (req, res) {
|
||||
const message = JSON.parse(req.body)
|
||||
return new Promise((resolve, reject) => {
|
||||
validator.validate(message, async (err, message) => {
|
||||
if (err) {
|
||||
log.error('notification', { err })
|
||||
const body = 'Access denied. ' + err.message
|
||||
res.status(401).send(body)
|
||||
return reject(body)
|
||||
}
|
||||
|
||||
await handleNotification(message)
|
||||
|
||||
res.status(200).json(
|
||||
{ status: 'OK' }
|
||||
)
|
||||
return resolve('OK')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function handleNotification (notification) {
|
||||
log.info('received-SNS', { id: notification.MessageId })
|
||||
const message = JSON.parse(notification.Message)
|
||||
if (message.hasOwnProperty('eventType')) {
|
||||
await handleSESMessage(message)
|
||||
}
|
||||
if (message.hasOwnProperty('event')) {
|
||||
await handleFxAMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFxAMessage (message) {
|
||||
switch (message.event) {
|
||||
case 'delete':
|
||||
await handleDeleteMessage(message)
|
||||
break
|
||||
default:
|
||||
log.info('unhandled-event', { event: message.event })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteMessage (message) {
|
||||
await DB.deleteSubscriberByFxAUID(message.uid)
|
||||
}
|
||||
|
||||
async function handleSESMessage (message) {
|
||||
switch (message.eventType) {
|
||||
case 'Bounce':
|
||||
await handleBounceMessage(message)
|
||||
break
|
||||
case 'Complaint':
|
||||
await handleComplaintMessage(message)
|
||||
break
|
||||
default:
|
||||
log.info('unhandled-eventType', { type: message.eventType })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBounceMessage (message) {
|
||||
const bounce = message.bounce
|
||||
if (bounce.bounceType === 'Permanent') {
|
||||
return await removeSubscribersFromDB(bounce.bouncedRecipients)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplaintMessage (message) {
|
||||
const complaint = message.complaint
|
||||
return await removeSubscribersFromDB(complaint.complainedRecipients)
|
||||
}
|
||||
|
||||
async function removeSubscribersFromDB (recipients) {
|
||||
for (const recipient of recipients) {
|
||||
await DB.removeEmail(recipient.emailAddress)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
notification,
|
||||
handleNotification,
|
||||
handleBounceMessage,
|
||||
handleComplaintMessage,
|
||||
removeSubscribersFromDB
|
||||
}
|
|
@ -1,633 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const AppConstants = require('../app-constants')
|
||||
const isemail = require('isemail')
|
||||
|
||||
const DB = require('../db/DB')
|
||||
const EmailUtils = require('../email-utils')
|
||||
const { FluentError, LocaleUtils } = require('../locale-utils')
|
||||
const { FXA } = require('../lib/fxa')
|
||||
const HIBP = require('../hibp')
|
||||
const { resultsSummary } = require('../scan-results')
|
||||
const sha1 = require('../sha1-utils')
|
||||
|
||||
const EXPERIMENTS_ENABLED = (AppConstants.EXPERIMENT_ACTIVE === '1')
|
||||
const {
|
||||
getExperimentFlags,
|
||||
getUTMContents,
|
||||
setAdUnitCookie
|
||||
} = require('./utils')
|
||||
|
||||
const FXA_MONITOR_SCOPE = 'https://identity.mozilla.com/apps/monitor'
|
||||
|
||||
async function removeEmail (req, res) {
|
||||
const emailId = req.body.emailId
|
||||
const sessionUser = req.user
|
||||
const existingEmail = await DB.getEmailById(emailId)
|
||||
if (existingEmail.subscriber_id !== sessionUser.id) {
|
||||
throw new FluentError('error-not-subscribed')
|
||||
}
|
||||
|
||||
DB.removeOneSecondaryEmail(emailId)
|
||||
res.redirect('/user/preferences')
|
||||
}
|
||||
|
||||
async function resendEmail (req, res) {
|
||||
const emailId = req.body.emailId
|
||||
const sessionUser = req.user
|
||||
const existingEmail = await DB.getEmailById(emailId)
|
||||
|
||||
if (!existingEmail || !existingEmail.subscriber_id) {
|
||||
throw new FluentError('user-verify-token-error')
|
||||
}
|
||||
|
||||
if (existingEmail.subscriber_id !== sessionUser.id) {
|
||||
// TODO: more specific error message?
|
||||
throw new FluentError('user-verify-token-error')
|
||||
}
|
||||
|
||||
const unverifiedEmailAddressRecord = await DB.resetUnverifiedEmailAddress(emailId)
|
||||
|
||||
const email = unverifiedEmailAddressRecord.email
|
||||
await EmailUtils.sendEmail(
|
||||
email,
|
||||
req.fluentFormat('email-subject-verify'),
|
||||
'email-2022',
|
||||
{
|
||||
recipientEmail: email,
|
||||
supportedLocales: req.supportedLocales,
|
||||
ctaHref: EmailUtils.getVerificationUrl(unverifiedEmailAddressRecord),
|
||||
utmCampaign: 'email_verify',
|
||||
unsubscribeUrl: EmailUtils.getUnsubscribeUrl(unverifiedEmailAddressRecord, 'account-verification-email'),
|
||||
whichPartial: 'email_partials/email_verify',
|
||||
heading: req.fluentFormat('email-verify-heading'),
|
||||
subheading: req.fluentFormat('email-verify-subhead')
|
||||
}
|
||||
)
|
||||
|
||||
// TODO: what should we return to the client?
|
||||
return res.json('Resent the email')
|
||||
}
|
||||
|
||||
async function updateCommunicationOptions (req, res) {
|
||||
const sessionUser = req.user
|
||||
// 0 = Send breach alerts to the email address found in brew breach.
|
||||
// 1 = Send all breach alerts to user's primary email address.
|
||||
const allEmailsToPrimary = (Number(req.body.communicationOption) === 1)
|
||||
const updatedSubscriber = await DB.setAllEmailsToPrimary(sessionUser, allEmailsToPrimary)
|
||||
req.session.user = updatedSubscriber
|
||||
|
||||
return res.json('Comm options updated')
|
||||
}
|
||||
|
||||
async function resolveBreach (req, res) {
|
||||
const sessionUser = req.user
|
||||
// TODO: verify that req.body.emailAddressId belongs to sessionUser
|
||||
const updatedSubscriber = await DB.setResolvedBreach({
|
||||
subscriber: sessionUser,
|
||||
emailAddresses: req.body.emailAddressId,
|
||||
recencyIndex: req.body.recencyIndex
|
||||
})
|
||||
req.session.user = updatedSubscriber
|
||||
return res.json('Breach marked as resolved.')
|
||||
}
|
||||
|
||||
function _checkForDuplicateEmail (sessionUser, email) {
|
||||
email = email.toLowerCase()
|
||||
if (email === sessionUser.primary_email.toLowerCase()) {
|
||||
throw new FluentError('user-add-duplicate-email')
|
||||
}
|
||||
for (const secondaryEmail of sessionUser.email_addresses) {
|
||||
if (email === secondaryEmail.email.toLowerCase()) {
|
||||
throw new FluentError('user-add-duplicate-email')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function add (req, res) {
|
||||
const sessionUser = await req.user
|
||||
const email = req.body.email
|
||||
if (!email || !isemail.validate(email)) {
|
||||
throw new FluentError('user-add-invalid-email')
|
||||
}
|
||||
|
||||
if (sessionUser.email_addresses.length >= AppConstants.MAX_NUM_ADDRESSES) {
|
||||
throw new FluentError('user-add-too-many-emails')
|
||||
}
|
||||
_checkForDuplicateEmail(sessionUser, email)
|
||||
|
||||
const unverifiedSubscriber = await DB.addSubscriberUnverifiedEmailHash(
|
||||
req.session.user, email
|
||||
)
|
||||
|
||||
await EmailUtils.sendEmail(
|
||||
email,
|
||||
req.fluentFormat('email-subject-verify'),
|
||||
'email-2022',
|
||||
{
|
||||
breachedEmail: email,
|
||||
recipientEmail: email,
|
||||
supportedLocales: req.supportedLocales,
|
||||
ctaHref: EmailUtils.getVerificationUrl(unverifiedSubscriber),
|
||||
utmCampaign: 'email_verify',
|
||||
unsubscribeUrl: EmailUtils.getUnsubscribeUrl(unverifiedSubscriber, 'account-verification-email'),
|
||||
whichPartial: 'email_partials/email_verify',
|
||||
heading: req.fluentFormat('email-verify-heading'),
|
||||
subheading: req.fluentFormat('email-verify-subhead')
|
||||
}
|
||||
)
|
||||
|
||||
// send users coming from the dashboard back to the dashboard
|
||||
// and set req.session.lastAddedEmail to show a confirmation message.
|
||||
if (req.headers.referer.endsWith('/dashboard')) {
|
||||
req.session.lastAddedEmail = email
|
||||
return res.redirect('/user/dashboard')
|
||||
}
|
||||
|
||||
res.redirect('/user/preferences')
|
||||
}
|
||||
|
||||
function getResolvedBreachesForEmail (user, email) {
|
||||
if (user.breaches_resolved === null) {
|
||||
return []
|
||||
}
|
||||
return user.breaches_resolved.hasOwnProperty(email) ? user.breaches_resolved[email] : []
|
||||
}
|
||||
|
||||
function addResolvedOrNot (foundBreaches, resolvedBreaches) {
|
||||
const annotatedBreaches = []
|
||||
if (AppConstants.BREACH_RESOLUTION_ENABLED !== '1') {
|
||||
return foundBreaches
|
||||
}
|
||||
for (const breach of foundBreaches) {
|
||||
const IsResolved = !!resolvedBreaches.includes(breach.recencyIndex)
|
||||
annotatedBreaches.push(Object.assign({ IsResolved }, breach))
|
||||
}
|
||||
return annotatedBreaches
|
||||
}
|
||||
|
||||
function addRecencyIndex (foundBreaches) {
|
||||
const annotatedBreaches = []
|
||||
// slice() the array to make a copy so before reversing so we don't
|
||||
// reverse foundBreaches in-place
|
||||
const oldestToNewestFoundBreaches = foundBreaches.slice().reverse()
|
||||
oldestToNewestFoundBreaches.forEach((annotatingBreach, index) => {
|
||||
const foundBreach = foundBreaches.find(foundBreach => foundBreach.Name === annotatingBreach.Name)
|
||||
annotatedBreaches.push(Object.assign({ recencyIndex: index }, foundBreach))
|
||||
})
|
||||
return annotatedBreaches.reverse()
|
||||
}
|
||||
|
||||
async function bundleVerifiedEmails (options) {
|
||||
const { user, email, recordId, recordVerified, allBreaches } = options
|
||||
const lowerCaseEmailSha = sha1(email.toLowerCase())
|
||||
const foundBreaches = await HIBP.getBreachesForEmail(lowerCaseEmailSha, allBreaches, true, false)
|
||||
const foundBreachesWithRecency = addRecencyIndex(foundBreaches)
|
||||
const resolvedBreaches = getResolvedBreachesForEmail(user, email)
|
||||
const foundBreachesWithResolutions = addResolvedOrNot(foundBreachesWithRecency, resolvedBreaches)
|
||||
const filteredAnnotatedFoundBreaches = HIBP.filterBreaches(foundBreachesWithResolutions)
|
||||
|
||||
const emailEntry = {
|
||||
email,
|
||||
breaches: filteredAnnotatedFoundBreaches,
|
||||
primary: email === user.primary_email,
|
||||
id: recordId,
|
||||
verified: recordVerified
|
||||
}
|
||||
|
||||
return emailEntry
|
||||
}
|
||||
|
||||
async function getAllEmailsAndBreaches (user, allBreaches) {
|
||||
const monitoredEmails = await DB.getUserEmails(user.id)
|
||||
let verifiedEmails = []
|
||||
const unverifiedEmails = []
|
||||
verifiedEmails.push(await bundleVerifiedEmails({ user, email: user.primary_email, recordId: user.id, recordVerified: user.primary_verified, allBreaches }))
|
||||
for (const email of monitoredEmails) {
|
||||
if (email.verified) {
|
||||
verifiedEmails.push(await bundleVerifiedEmails({ user, email: email.email, recordId: email.id, recordVerified: email.verified, allBreaches }))
|
||||
} else {
|
||||
unverifiedEmails.push(email)
|
||||
}
|
||||
}
|
||||
verifiedEmails = getNewBreachesForEmailEntriesSinceDate(verifiedEmails, user.breaches_last_shown)
|
||||
return { verifiedEmails, unverifiedEmails }
|
||||
}
|
||||
|
||||
function getNewBreachesForEmailEntriesSinceDate (emailEntries, date) {
|
||||
for (const emailEntry of emailEntries) {
|
||||
const newBreachesForEmail = emailEntry.breaches.filter(breach => breach.AddedDate >= date)
|
||||
|
||||
for (const newBreachForEmail of newBreachesForEmail) {
|
||||
newBreachForEmail.NewBreach = true // add "NewBreach" property to the new breach.
|
||||
emailEntry.hasNewBreaches = newBreachesForEmail.length // add the number of new breaches to the email
|
||||
}
|
||||
}
|
||||
return emailEntries
|
||||
}
|
||||
|
||||
async function getDashboard (req, res) {
|
||||
const user = req.user
|
||||
const allBreaches = req.app.locals.breaches
|
||||
const { verifiedEmails, unverifiedEmails } = await getAllEmailsAndBreaches(user, allBreaches)
|
||||
const utmOverrides = getUTMContents(req)
|
||||
const supportedLocalesIncludesEnglish = req.supportedLocales.includes('en')
|
||||
const experimentFlags = getExperimentFlags(req, EXPERIMENTS_ENABLED)
|
||||
|
||||
let lastAddedEmail = null
|
||||
|
||||
req.session.user = await DB.setBreachesLastShownNow(user)
|
||||
if (req.session.lastAddedEmail) {
|
||||
lastAddedEmail = req.session.lastAddedEmail
|
||||
req.session.lastAddedEmail = null
|
||||
}
|
||||
|
||||
const adUnitNum = setAdUnitCookie(req, res)
|
||||
|
||||
if (!req.session.statsUpdated) {
|
||||
// update user's breach stats in DB once per session without blocking render
|
||||
DB.updateBreachStats(user.id, resultsSummary(verifiedEmails))
|
||||
req.session.statsUpdated = true
|
||||
}
|
||||
|
||||
res.render('dashboards', {
|
||||
title: req.fluentFormat('Firefox Monitor'),
|
||||
csrfToken: req.csrfToken(),
|
||||
lastAddedEmail,
|
||||
verifiedEmails,
|
||||
unverifiedEmails,
|
||||
supportedLocalesIncludesEnglish,
|
||||
whichPartial: 'dashboards/breaches-dash',
|
||||
experimentFlags,
|
||||
utmOverrides,
|
||||
adUnit: `ad-units/ad-unit-${adUnitNum}`
|
||||
})
|
||||
}
|
||||
|
||||
async function _verify (req) {
|
||||
const verifiedEmailHash = await DB.verifyEmailHash(req.query.token)
|
||||
let unsafeBreachesForEmail = []
|
||||
unsafeBreachesForEmail = await HIBP.getBreachesForEmail(
|
||||
sha1(verifiedEmailHash.email.toLowerCase()),
|
||||
req.app.locals.breaches,
|
||||
true
|
||||
)
|
||||
|
||||
const utmID = 'report'
|
||||
const reportSubject = unsafeBreachesForEmail.length ? req.fluentFormat('email-subject-found-breaches') : req.fluentFormat('email-subject-no-breaches')
|
||||
|
||||
await EmailUtils.sendEmail(
|
||||
verifiedEmailHash.email,
|
||||
reportSubject,
|
||||
'email-2022',
|
||||
{
|
||||
breachedEmail: verifiedEmailHash.email,
|
||||
recipientEmail: verifiedEmailHash.email,
|
||||
supportedLocales: req.supportedLocales,
|
||||
unsafeBreachesForEmail,
|
||||
ctaHref: EmailUtils.getEmailCtaHref(utmID, 'dashboard-cta'),
|
||||
utmCampaign: utmID,
|
||||
unsubscribeUrl: EmailUtils.getUnsubscribeUrl(verifiedEmailHash, utmID),
|
||||
whichPartial: 'email_partials/report',
|
||||
heading: req.fluentFormat('email-breach-summary')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function verify (req, res) {
|
||||
if (!req.query.token) {
|
||||
throw new FluentError('user-verify-token-error')
|
||||
}
|
||||
const existingEmail = await DB.getEmailByToken(req.query.token)
|
||||
|
||||
if (!existingEmail) {
|
||||
throw new FluentError('error-not-subscribed')
|
||||
}
|
||||
|
||||
const sessionUser = await req.user
|
||||
if (sessionUser && existingEmail.subscriber_id !== sessionUser.id) {
|
||||
// TODO: more specific error message?
|
||||
// e.g., "This email verification token is not valid for this account"
|
||||
throw new FluentError('user-verify-token-error')
|
||||
}
|
||||
|
||||
if (!existingEmail.verified) {
|
||||
await _verify(req)
|
||||
}
|
||||
|
||||
if (sessionUser) {
|
||||
res.redirect('/user/dashboard')
|
||||
return
|
||||
}
|
||||
res.render('subpage', {
|
||||
title: 'Email Verified',
|
||||
whichPartial: 'subpages/confirm',
|
||||
email: existingEmail.email
|
||||
})
|
||||
}
|
||||
|
||||
// legacy /user/unsubscribe controller for pre-FxA unsubscribe links
|
||||
async function getUnsubscribe (req, res) {
|
||||
if (!req.query.token) {
|
||||
throw new FluentError('user-unsubscribe-token-error')
|
||||
}
|
||||
|
||||
const subscriber = await DB.getSubscriberByToken(req.query.token)
|
||||
if (subscriber && subscriber.fxa_profile_json !== null) {
|
||||
// Token is for a primary email address of an FxA subscriber:
|
||||
// redirect to preferences to remove Firefox Monitor
|
||||
return res.redirect('/user/preferences')
|
||||
}
|
||||
|
||||
const emailAddress = await DB.getEmailByToken(req.query.token)
|
||||
if (!subscriber && !emailAddress) {
|
||||
// Unknown token:
|
||||
// throw error
|
||||
throw new FluentError('error-not-subscribed')
|
||||
}
|
||||
|
||||
// Token is for an old pre-FxA subscriber, or a secondary email address:
|
||||
// render the unsubscribe page
|
||||
res.render('subpage', {
|
||||
title: req.fluentFormat('user-unsubscribe-title'),
|
||||
whichPartial: 'subpages/unsubscribe',
|
||||
token: req.query.token,
|
||||
hash: req.query.hash
|
||||
})
|
||||
}
|
||||
|
||||
async function getRemoveFxm (req, res) {
|
||||
const sessionUser = req.user
|
||||
|
||||
res.render('subpage', {
|
||||
title: req.fluentFormat('remove-fxm'),
|
||||
subscriber: sessionUser,
|
||||
whichPartial: 'subpages/remove_fxm',
|
||||
csrfToken: req.csrfToken()
|
||||
})
|
||||
}
|
||||
|
||||
async function postRemoveFxm (req, res) {
|
||||
const sessionUser = req.user
|
||||
await DB.removeSubscriber(sessionUser)
|
||||
await FXA.revokeOAuthTokens(sessionUser)
|
||||
|
||||
req.session.destroy()
|
||||
res.redirect('/')
|
||||
}
|
||||
|
||||
function _updateResolvedBreaches (options) {
|
||||
const {
|
||||
user,
|
||||
affectedEmail,
|
||||
isResolved,
|
||||
recencyIndexNumber
|
||||
} = options
|
||||
// TODO: clarify the logic here. maybe change the endpoint to PUT /breach-resolution
|
||||
// with the new resolution value ?
|
||||
const userBreachesResolved = user.breaches_resolved === null ? {} : user.breaches_resolved
|
||||
if (isResolved === 'false') {
|
||||
if (Array.isArray(userBreachesResolved[affectedEmail])) {
|
||||
userBreachesResolved[affectedEmail].push(recencyIndexNumber)
|
||||
return userBreachesResolved
|
||||
}
|
||||
userBreachesResolved[affectedEmail] = [recencyIndexNumber]
|
||||
return userBreachesResolved
|
||||
}
|
||||
userBreachesResolved[affectedEmail] = userBreachesResolved[affectedEmail].filter(el => el !== recencyIndexNumber)
|
||||
return userBreachesResolved
|
||||
}
|
||||
|
||||
async function postResolveBreach (req, res) {
|
||||
const sessionUser = req.user
|
||||
const { affectedEmail, recencyIndex, isResolved } = req.body
|
||||
const recencyIndexNumber = Number(recencyIndex)
|
||||
const affectedEmailIsSubscriberRecord = sessionUser.primary_email === affectedEmail
|
||||
const affectedEmailInEmailAddresses = sessionUser.email_addresses.filter(ea => ea.email === affectedEmail)
|
||||
|
||||
if (!affectedEmailIsSubscriberRecord && !affectedEmailInEmailAddresses) {
|
||||
return res.json('Error: affectedEmail is not valid for this subscriber')
|
||||
}
|
||||
|
||||
const updatedResolvedBreaches = _updateResolvedBreaches({
|
||||
user: sessionUser,
|
||||
affectedEmail,
|
||||
isResolved,
|
||||
recencyIndexNumber
|
||||
})
|
||||
|
||||
const updatedSubscriber = await DB.setBreachesResolved(
|
||||
{ user: sessionUser, updatedResolvedBreaches }
|
||||
)
|
||||
req.session.user = updatedSubscriber
|
||||
// return res.json("Breach marked as resolved.");
|
||||
// Currently we're sending { affectedEmail, recencyIndex, isResolved, passwordsExposed } in req.body
|
||||
// Not sure if we need all of these or need to send other/additional values?
|
||||
|
||||
if (isResolved === 'true') {
|
||||
// the user clicked "Undo" so mark the breach as unresolved
|
||||
return res.redirect('/')
|
||||
}
|
||||
|
||||
const allBreaches = req.app.locals.breaches
|
||||
const { verifiedEmails } = await getAllEmailsAndBreaches(req.session.user, allBreaches)
|
||||
|
||||
const userBreachStats = resultsSummary(verifiedEmails)
|
||||
const numTotalBreaches = userBreachStats.numBreaches.count
|
||||
const numResolvedBreaches = userBreachStats.numBreaches.numResolved
|
||||
|
||||
DB.updateBreachStats(sessionUser.id, userBreachStats)
|
||||
|
||||
const localizedModalStrings = {
|
||||
headline: '',
|
||||
progressMessage: '',
|
||||
progressStatus: req.fluentFormat('progress-status', {
|
||||
numResolvedBreaches,
|
||||
numTotalBreaches
|
||||
}
|
||||
),
|
||||
headlineClassName: ''
|
||||
}
|
||||
|
||||
switch (numResolvedBreaches) {
|
||||
case 1:
|
||||
localizedModalStrings.headline = req.fluentFormat('confirmation-1-subhead')
|
||||
localizedModalStrings.progressMessage = req.fluentFormat('confirmation-1-body')
|
||||
localizedModalStrings.headlineClassName = 'overlay-resolved-first-breach'
|
||||
break
|
||||
|
||||
case 2:
|
||||
localizedModalStrings.headline = req.fluentFormat('confirmation-2-subhead')
|
||||
localizedModalStrings.progressMessage = req.fluentFormat('confirmation-2-body')
|
||||
localizedModalStrings.headlineClassName = 'overlay-take-that-hackers'
|
||||
break
|
||||
|
||||
case 3:
|
||||
localizedModalStrings.headline = req.fluentFormat('confirmation-3-subhead')
|
||||
// TO CONSIDER: The "confirmation-3-body" string contains nested markup.
|
||||
// We'll either have to remove it (requiring a string change), or we will have
|
||||
// to inject it into the template using innerHTML (scaryish).
|
||||
// Defaulting to the generic progressMessage for now.
|
||||
localizedModalStrings.progressMessage = req.fluentFormat('generic-confirmation-message', {
|
||||
numUnresolvedBreaches: numTotalBreaches - numResolvedBreaches
|
||||
})
|
||||
localizedModalStrings.headlineClassName = 'overlay-another-breach-resolved'
|
||||
break
|
||||
|
||||
case numTotalBreaches:
|
||||
localizedModalStrings.headline = req.fluentFormat('confirmation-2-subhead')
|
||||
localizedModalStrings.progressMessage = req.fluentFormat('progress-complete')
|
||||
localizedModalStrings.headlineClassName = 'overlay-marked-as-resolved'
|
||||
break
|
||||
|
||||
default:
|
||||
if (numResolvedBreaches > 3) {
|
||||
localizedModalStrings.headline = req.fluentFormat('confirmation-2-subhead')
|
||||
localizedModalStrings.progressMessage = req.fluentFormat('confirmation-2-body')
|
||||
localizedModalStrings.headlineClassName = 'overlay-marked-as-resolved'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
res.json(localizedModalStrings)
|
||||
}
|
||||
|
||||
async function postUnsubscribe (req, res) {
|
||||
const { token, emailHash } = req.body
|
||||
|
||||
if (!token || !emailHash) {
|
||||
throw new FluentError('user-unsubscribe-token-email-error')
|
||||
}
|
||||
|
||||
// legacy unsubscribe link page uses removeSubscriberByToken
|
||||
const unsubscribedUser = await DB.removeSubscriberByToken(token, emailHash)
|
||||
if (!unsubscribedUser) {
|
||||
const emailAddress = await DB.getEmailByToken(token)
|
||||
if (!emailAddress) {
|
||||
throw new FluentError('error-not-subscribed')
|
||||
}
|
||||
await DB.removeOneSecondaryEmail(emailAddress.id)
|
||||
return res.redirect('/user/preferences')
|
||||
}
|
||||
await FXA.revokeOAuthTokens(unsubscribedUser)
|
||||
req.session.destroy()
|
||||
res.redirect('/')
|
||||
}
|
||||
|
||||
async function getUnsubscribeMonthly (req, res) {
|
||||
if (!req.query.token) return res.redirect('/')
|
||||
|
||||
await DB.updateMonthlyEmailOptout(req.query.token)
|
||||
return res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script type="text/javascript" src="/dist/app.js" defer></script>
|
||||
<title>${LocaleUtils.fluentFormat(req.supportedLocales, 'home-title')}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>${LocaleUtils.fluentFormat(req.supportedLocales, 'changes-saved')}</h2>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
}
|
||||
|
||||
async function getPreferences (req, res) {
|
||||
const user = req.user
|
||||
const allBreaches = req.app.locals.breaches
|
||||
const { verifiedEmails, unverifiedEmails } = await getAllEmailsAndBreaches(user, allBreaches)
|
||||
|
||||
res.render('dashboards', {
|
||||
title: 'Firefox Monitor',
|
||||
whichPartial: 'dashboards/preferences',
|
||||
csrfToken: req.csrfToken(),
|
||||
verifiedEmails,
|
||||
unverifiedEmails
|
||||
})
|
||||
}
|
||||
|
||||
// This endpoint returns breach stats for Firefox clients to display
|
||||
// in about:protections
|
||||
//
|
||||
// Firefox sends a signed JWT in the Authorization header. We verify this JWT
|
||||
// with the FXA verification endpoint before we return breach stats.
|
||||
//
|
||||
// To test this endpoint, see the "Test Firefox Integration" section of the README.
|
||||
async function getBreachStats (req, res) {
|
||||
if (!req.token) {
|
||||
return res.status(401).json({
|
||||
errorMessage: 'User breach stats requires an FXA OAuth token passed in the Authorization header.'
|
||||
})
|
||||
}
|
||||
const fxaResponse = await FXA.verifyOAuthToken(req.token)
|
||||
if (fxaResponse.name === 'HTTPError') {
|
||||
return res.status(fxaResponse.response.statusCode).json({
|
||||
errorMessage: 'Could not verify FXA OAuth token. FXA returned message: ' + fxaResponse.response.statusMessage
|
||||
})
|
||||
}
|
||||
if (!fxaResponse.body.scope.includes(FXA_MONITOR_SCOPE)) {
|
||||
return res.status(401).json({
|
||||
errorMessage: 'The provided token does not include Monitor scope.'
|
||||
})
|
||||
}
|
||||
const user = await DB.getSubscriberByFxaUid(fxaResponse.body.user)
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
errorMessage: 'Cannot find Monitor subscriber for that FXA.'
|
||||
})
|
||||
}
|
||||
const allBreaches = req.app.locals.breaches
|
||||
const { verifiedEmails } = await getAllEmailsAndBreaches(user, allBreaches)
|
||||
const breachStats = resultsSummary(verifiedEmails)
|
||||
const baseStats = {
|
||||
monitoredEmails: breachStats.monitoredEmails.count,
|
||||
numBreaches: breachStats.numBreaches.count,
|
||||
passwords: breachStats.passwords.count
|
||||
}
|
||||
const resolvedStats = {
|
||||
numBreachesResolved: breachStats.numBreaches.numResolved,
|
||||
passwordsResolved: breachStats.passwords.numResolved
|
||||
}
|
||||
const returnStats = (req.query.includeResolved === 'true') ? Object.assign(baseStats, resolvedStats) : baseStats
|
||||
return res.json(returnStats)
|
||||
}
|
||||
|
||||
function logout (req, res) {
|
||||
// Growth Experiment
|
||||
if (EXPERIMENTS_ENABLED && req.session.experimentFlags) {
|
||||
// Persist experimentBranch across session reset
|
||||
const sessionExperimentFlags = req.session.experimentFlags
|
||||
req.session.destroy(() => {
|
||||
req.session = { experimentFlags: sessionExperimentFlags }
|
||||
})
|
||||
|
||||
// Return
|
||||
res.redirect('/')
|
||||
return
|
||||
}
|
||||
|
||||
req.session.destroy()
|
||||
res.redirect('/')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FXA_MONITOR_SCOPE,
|
||||
getPreferences,
|
||||
getDashboard,
|
||||
getBreachStats,
|
||||
getAllEmailsAndBreaches,
|
||||
add,
|
||||
verify,
|
||||
getUnsubscribe,
|
||||
postUnsubscribe,
|
||||
getUnsubscribeMonthly,
|
||||
getRemoveFxm,
|
||||
postRemoveFxm,
|
||||
postResolveBreach,
|
||||
logout,
|
||||
removeEmail,
|
||||
resendEmail,
|
||||
updateCommunicationOptions,
|
||||
resolveBreach
|
||||
}
|
|
@ -1,237 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const crypto = require('crypto')
|
||||
const uuidv4 = require('uuid/v4')
|
||||
const mozlog = require('../log')
|
||||
const log = mozlog('controllers.utils')
|
||||
|
||||
const AppConstants = require('../app-constants')
|
||||
|
||||
function generatePageToken (req) {
|
||||
const pageToken = { ip: req.ip, date: new Date(), nonce: uuidv4() }
|
||||
|
||||
const iv = crypto.randomBytes(16) // AES uses block sizes of 16 bytes
|
||||
const key = crypto.pbkdf2Sync(AppConstants.COOKIE_SECRET, iv.toString(), 10000, 32, 'sha512')
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
|
||||
|
||||
const encryptedPageToken = Buffer.concat([
|
||||
cipher.update(JSON.stringify(pageToken)),
|
||||
cipher.final()
|
||||
])
|
||||
|
||||
return iv.toString('base64') + '.' + encryptedPageToken.toString('base64')
|
||||
|
||||
/* TODO: block on scans-per-ip instead of scans-per-timespan
|
||||
if (req.session.scans === undefined){
|
||||
console.log("session scans undefined");
|
||||
req.session.scans = [];
|
||||
}
|
||||
req.session.numScans = req.session.scans.length;
|
||||
*/
|
||||
}
|
||||
|
||||
function hasUserSignedUpForWaitlist (user, waitlist) {
|
||||
if (!user.waitlists_joined) {
|
||||
return false
|
||||
}
|
||||
if (user.waitlists_joined.hasOwnProperty(waitlist)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getTotalPercentage (variations) {
|
||||
let percentage = 0
|
||||
// calculate and store total percentage of variations
|
||||
for (const v in variations) {
|
||||
if (variations.hasOwnProperty(v) && typeof variations[v] === 'number') {
|
||||
// multiply by 100 to allow for percentages to the hundredth
|
||||
// (and avoid floating point math errors)
|
||||
percentage += (variations[v] * 100)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPercentage = percentage / 100
|
||||
|
||||
// Make sure totalPercent is between 0 and 100
|
||||
if (totalPercentage === 0 || totalPercentage > 100) {
|
||||
throw new Error(`The total percentage ${totalPercentage} is out of bounds!`)
|
||||
}
|
||||
|
||||
return percentage
|
||||
}
|
||||
|
||||
function chooseVariation (variations, sorterNum) {
|
||||
const totalPercentage = getTotalPercentage(variations)
|
||||
|
||||
// make sure random number falls in the distribution range
|
||||
let runningTotal
|
||||
let choice
|
||||
|
||||
if (sorterNum <= totalPercentage) {
|
||||
runningTotal = 0
|
||||
|
||||
// loop through all variations
|
||||
for (const v in variations) {
|
||||
// check if random number falls within current variation range
|
||||
if (sorterNum <= (variations[v] + runningTotal)) {
|
||||
// if so, we have found our variation
|
||||
choice = v
|
||||
break
|
||||
}
|
||||
|
||||
// tally variation percentages for the next loop iteration
|
||||
runningTotal += variations[v]
|
||||
}
|
||||
}
|
||||
|
||||
return choice
|
||||
}
|
||||
|
||||
function unEnrollSession (session) {
|
||||
session.excludeFromExperiment = true
|
||||
session.experimentBranch = false
|
||||
session.treatmentBranch = false
|
||||
session.controlBranch = false
|
||||
}
|
||||
|
||||
function setBranchVariable (branch, sessionExperimentFlags) {
|
||||
if (branch === 'va') {
|
||||
sessionExperimentFlags.controlBranch = true
|
||||
sessionExperimentFlags.treatmentBranch = false
|
||||
}
|
||||
if (branch === 'vb') {
|
||||
sessionExperimentFlags.treatmentBranch = true
|
||||
sessionExperimentFlags.controlBranch = false
|
||||
}
|
||||
}
|
||||
|
||||
function getExperimentBranch (req, sorterNum = false, language = false, variations) {
|
||||
const sessionExperimentFlags = req.session.experimentFlags
|
||||
|
||||
if (sessionExperimentFlags.excludeFromExperiment && !req.query.experimentBranch) {
|
||||
log.debug('This session has already been excluded from the experiment')
|
||||
unEnrollSession(sessionExperimentFlags)
|
||||
return false
|
||||
}
|
||||
|
||||
// If we cannot parse req.headers["accept-language"], we should not
|
||||
// enroll users in the experiment.
|
||||
if (language && !req.headers?.['accept-language']) {
|
||||
log.debug('No headers or accept-language information present.')
|
||||
unEnrollSession(sessionExperimentFlags)
|
||||
return false
|
||||
}
|
||||
|
||||
// If the user doesn't have the requested variant langauge selected as their primary language,
|
||||
// we do not enroll them in the experiment.
|
||||
if (language) {
|
||||
if (!Array.isArray(language)) {
|
||||
throw new Error('The language param is not an array')
|
||||
}
|
||||
const lang = req.headers['accept-language'].split(',')
|
||||
|
||||
// Check to make sure one of the experiment langauge(s) is the top-preferred language.
|
||||
const firstLangMatch = (element) => lang[0].includes(element)
|
||||
if (language && !language.some(firstLangMatch)) {
|
||||
log.debug(`Preferred language is not [${language}] variant: ${lang[0]}`)
|
||||
unEnrollSession(sessionExperimentFlags)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// If URL param has experimentBranch entry, use that branch;
|
||||
if (req.query.experimentBranch) {
|
||||
if (!Object.keys(variations).includes(req.query.experimentBranch)) {
|
||||
log.debug('The requested branch is unknown: ', req.query.experimentBranch)
|
||||
unEnrollSession(sessionExperimentFlags)
|
||||
return false
|
||||
}
|
||||
log.debug('This session has been set to the requested branch: ', req.query.experimentBranch)
|
||||
sessionExperimentFlags.excludeFromExperiment = false
|
||||
sessionExperimentFlags.experimentBranch = req.query.experimentBranch
|
||||
setBranchVariable(sessionExperimentFlags.experimentBranch, sessionExperimentFlags)
|
||||
return req.query.experimentBranch
|
||||
}
|
||||
|
||||
// If user was already assigned a branch, stay in that branch;
|
||||
if (sessionExperimentFlags.experimentBranch) {
|
||||
log.debug('This session has already been assigned: ', sessionExperimentFlags.experimentBranch)
|
||||
setBranchVariable(sessionExperimentFlags.experimentBranch, sessionExperimentFlags)
|
||||
return sessionExperimentFlags.experimentBranch
|
||||
}
|
||||
|
||||
if (sorterNum === false) {
|
||||
sorterNum = Math.floor(Math.random() * 10000) + 1
|
||||
sorterNum = sorterNum / 100
|
||||
// sorterNum = Math.floor(Math.random() * 100);
|
||||
log.debug('No coinflip number provided. Coinflip number is ', sorterNum)
|
||||
} else {
|
||||
log.debug('Coinflip number provided. Coinflip number is ', sorterNum)
|
||||
}
|
||||
|
||||
const assignedCohort = chooseVariation(variations, sorterNum)
|
||||
|
||||
if (!assignedCohort) {
|
||||
log.debug('This session has randomly been removed from the experiment')
|
||||
sessionExperimentFlags.excludeFromExperiment = true
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug(`This session has been randomly assigned to the ${assignedCohort} cohort.`)
|
||||
sessionExperimentFlags.experimentBranch = assignedCohort
|
||||
setBranchVariable(sessionExperimentFlags.experimentBranch, sessionExperimentFlags)
|
||||
return assignedCohort
|
||||
}
|
||||
|
||||
function getUTMContents (req) {
|
||||
if (!req) {
|
||||
throw new Error('No request available')
|
||||
}
|
||||
|
||||
// If UTMs are set previously, set them again.
|
||||
if (req.session.utmOverrides) {
|
||||
return req.session.utmOverrides
|
||||
}
|
||||
|
||||
req.session.utmOverrides = false
|
||||
return false
|
||||
}
|
||||
|
||||
function getExperimentFlags (req, EXPERIMENTS_ENABLED) {
|
||||
if (!req) {
|
||||
throw new Error('No request available')
|
||||
}
|
||||
|
||||
if (req.session.experimentFlags && EXPERIMENTS_ENABLED) {
|
||||
return req.session.experimentFlags
|
||||
}
|
||||
|
||||
const experimentFlags = {
|
||||
experimentBranch: false,
|
||||
treatmentBranch: false,
|
||||
controlBranch: false,
|
||||
excludeFromExperiment: false
|
||||
}
|
||||
|
||||
req.session.experimentFlags = experimentFlags
|
||||
return experimentFlags
|
||||
}
|
||||
|
||||
function setAdUnitCookie (req, res) {
|
||||
const oldNum = parseInt(req.cookies?.adUnit) || Math.ceil(Math.random() * AppConstants.AD_UNIT_TOTAL)
|
||||
const newNum = oldNum % AppConstants.AD_UNIT_TOTAL + 1
|
||||
|
||||
res.cookie('adUnit', newNum, { path: '/', sameSite: 'Lax', maxAge: 60 * 60 * 24 * 30, httpOnly: true })
|
||||
|
||||
return newNum
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generatePageToken,
|
||||
hasUserSignedUpForWaitlist,
|
||||
getExperimentBranch,
|
||||
getExperimentFlags,
|
||||
getUTMContents,
|
||||
setAdUnitCookie
|
||||
}
|
488
db/DB.js
|
@ -1,488 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const uuidv4 = require('uuid/v4')
|
||||
const Knex = require('knex')
|
||||
|
||||
const { FluentError } = require('../locale-utils')
|
||||
const AppConstants = require('../app-constants')
|
||||
const { FXA } = require('../lib/fxa')
|
||||
const HIBP = require('../hibp')
|
||||
const getSha1 = require('../sha1-utils')
|
||||
const mozlog = require('../log')
|
||||
|
||||
const knexConfig = require('./knexfile')
|
||||
|
||||
let knex = Knex(knexConfig)
|
||||
|
||||
const log = mozlog('DB')
|
||||
|
||||
const DB = {
|
||||
async getSubscriberByToken (token) {
|
||||
const res = await knex('subscribers')
|
||||
.where('primary_verification_token', '=', token)
|
||||
|
||||
return res[0]
|
||||
},
|
||||
|
||||
async getEmailByToken (token) {
|
||||
const res = await knex('email_addresses')
|
||||
.where('verification_token', '=', token)
|
||||
|
||||
return res[0]
|
||||
},
|
||||
|
||||
async getEmailById (emailAddressId) {
|
||||
const res = await knex('email_addresses')
|
||||
.where('id', '=', emailAddressId)
|
||||
|
||||
return res[0]
|
||||
},
|
||||
|
||||
async getSubscriberByTokenAndHash (token, emailSha1) {
|
||||
const res = await knex.table('subscribers')
|
||||
.first()
|
||||
.where({
|
||||
primary_verification_token: token,
|
||||
primary_sha1: emailSha1
|
||||
})
|
||||
return res
|
||||
},
|
||||
|
||||
async joinEmailAddressesToSubscriber (subscriber) {
|
||||
if (subscriber) {
|
||||
const emailAddressRecords = await knex('email_addresses').where({
|
||||
subscriber_id: subscriber.id
|
||||
})
|
||||
subscriber.email_addresses = emailAddressRecords.map(
|
||||
emailAddress => ({ id: emailAddress.id, email: emailAddress.email })
|
||||
)
|
||||
}
|
||||
return subscriber
|
||||
},
|
||||
|
||||
async getSubscriberById (id) {
|
||||
const [subscriber] = await knex('subscribers').where({
|
||||
id
|
||||
})
|
||||
const subscriberAndEmails = await this.joinEmailAddressesToSubscriber(subscriber)
|
||||
return subscriberAndEmails
|
||||
},
|
||||
|
||||
async getSubscriberByFxaUid (uid) {
|
||||
const [subscriber] = await knex('subscribers').where({
|
||||
fxa_uid: uid
|
||||
})
|
||||
const subscriberAndEmails = await this.joinEmailAddressesToSubscriber(subscriber)
|
||||
return subscriberAndEmails
|
||||
},
|
||||
|
||||
async getSubscriberByEmail (email) {
|
||||
const [subscriber] = await knex('subscribers').where({
|
||||
primary_email: email,
|
||||
primary_verified: true
|
||||
})
|
||||
const subscriberAndEmails = await this.joinEmailAddressesToSubscriber(subscriber)
|
||||
return subscriberAndEmails
|
||||
},
|
||||
|
||||
async getEmailAddressRecordByEmail (email) {
|
||||
const emailAddresses = await knex('email_addresses').where({
|
||||
email, verified: true
|
||||
})
|
||||
if (!emailAddresses) {
|
||||
return null
|
||||
}
|
||||
if (emailAddresses.length > 1) {
|
||||
// TODO: handle multiple emails in separate(?) subscriber accounts?
|
||||
log.warn('getEmailAddressRecordByEmail', { msg: 'found the same email multiple times' })
|
||||
}
|
||||
return emailAddresses[0]
|
||||
},
|
||||
|
||||
async addSubscriberUnverifiedEmailHash (user, email) {
|
||||
const res = await knex('email_addresses').insert({
|
||||
subscriber_id: user.id,
|
||||
email,
|
||||
sha1: getSha1(email),
|
||||
verification_token: uuidv4(),
|
||||
verified: false
|
||||
}).returning('*')
|
||||
return res[0]
|
||||
},
|
||||
|
||||
async resetUnverifiedEmailAddress (emailAddressId) {
|
||||
const newVerificationToken = uuidv4()
|
||||
const res = await knex('email_addresses')
|
||||
.update({
|
||||
verification_token: newVerificationToken,
|
||||
updated_at: knex.fn.now()
|
||||
})
|
||||
.where('id', emailAddressId)
|
||||
.returning('*')
|
||||
return res[0]
|
||||
},
|
||||
|
||||
async verifyEmailHash (token) {
|
||||
const unverifiedEmail = await this.getEmailByToken(token)
|
||||
if (!unverifiedEmail) {
|
||||
throw new FluentError('Error message for this verification email timed out or something went wrong.')
|
||||
}
|
||||
const verifiedEmail = await this._verifyNewEmail(unverifiedEmail)
|
||||
return verifiedEmail[0]
|
||||
},
|
||||
|
||||
// TODO: refactor into an upsert? https://jaketrent.com/post/upsert-knexjs/
|
||||
// Used internally, ideally should not be called by consumers.
|
||||
async _getSha1EntryAndDo (sha1, aFoundCallback, aNotFoundCallback) {
|
||||
const existingEntries = await knex('subscribers')
|
||||
.where('primary_sha1', sha1)
|
||||
|
||||
if (existingEntries.length && aFoundCallback) {
|
||||
return await aFoundCallback(existingEntries[0])
|
||||
}
|
||||
|
||||
if (!existingEntries.length && aNotFoundCallback) {
|
||||
return await aNotFoundCallback()
|
||||
}
|
||||
},
|
||||
|
||||
// Used internally.
|
||||
async _addEmailHash (sha1, email, signupLanguage, verified = false) {
|
||||
try {
|
||||
return await this._getSha1EntryAndDo(sha1, async aEntry => {
|
||||
// Entry existed, patch the email value if supplied.
|
||||
if (email) {
|
||||
const res = await knex('subscribers')
|
||||
.update({
|
||||
primary_email: email,
|
||||
primary_sha1: getSha1(email.toLowerCase()),
|
||||
primary_verified: verified,
|
||||
updated_at: knex.fn.now()
|
||||
})
|
||||
.where('id', '=', aEntry.id)
|
||||
.returning('*')
|
||||
return res[0]
|
||||
}
|
||||
|
||||
return aEntry
|
||||
}, async () => {
|
||||
// Always add a verification_token value
|
||||
const verificationToken = uuidv4()
|
||||
const res = await knex('subscribers')
|
||||
.insert({
|
||||
primary_sha1: getSha1(email.toLowerCase()),
|
||||
primary_email: email,
|
||||
signup_language: signupLanguage,
|
||||
primary_verification_token: verificationToken,
|
||||
primary_verified: verified
|
||||
})
|
||||
.returning('*')
|
||||
return res[0]
|
||||
})
|
||||
} catch (e) {
|
||||
throw new FluentError('error-could-not-add-email')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a subscriber:
|
||||
* 1. Add a record to subscribers
|
||||
* 2. Immediately call _verifySubscriber
|
||||
* 3. For FxA subscriber, add refresh token and profile data
|
||||
*
|
||||
* @param {string} email to add
|
||||
* @param {string} signupLanguage from Accept-Language
|
||||
* @param {string} fxaAccessToken from Firefox Account Oauth
|
||||
* @param {string} fxaRefreshToken from Firefox Account Oauth
|
||||
* @param {string} fxaProfileData from Firefox Account
|
||||
* @returns {object} subscriber knex object added to DB
|
||||
*/
|
||||
async addSubscriber (email, signupLanguage, fxaAccessToken = null, fxaRefreshToken = null, fxaProfileData = null) {
|
||||
const emailHash = await this._addEmailHash(getSha1(email), email, signupLanguage, true)
|
||||
const verified = await this._verifySubscriber(emailHash)
|
||||
const verifiedSubscriber = Array.isArray(verified) ? verified[0] : null
|
||||
if (fxaRefreshToken || fxaProfileData) {
|
||||
return this._updateFxAData(verifiedSubscriber, fxaAccessToken, fxaRefreshToken, fxaProfileData)
|
||||
}
|
||||
return verifiedSubscriber
|
||||
},
|
||||
|
||||
/**
|
||||
* When an email is verified, convert it into a subscriber:
|
||||
* 1. Subscribe the hash to HIBP
|
||||
* 2. Update our subscribers record to verified
|
||||
* 3. (if opted in) Subscribe the email to Fx newsletter
|
||||
*
|
||||
* @param {object} emailHash knex object in DB
|
||||
* @returns {object} verified subscriber knex object in DB
|
||||
*/
|
||||
async _verifySubscriber (emailHash) {
|
||||
// TODO: move this "up" into controllers/users ?
|
||||
await HIBP.subscribeHash(emailHash.primary_sha1)
|
||||
|
||||
const verifiedSubscriber = await knex('subscribers')
|
||||
.where('primary_email', '=', emailHash.primary_email)
|
||||
.update({
|
||||
primary_verified: true,
|
||||
updated_at: knex.fn.now()
|
||||
})
|
||||
.returning('*')
|
||||
|
||||
return verifiedSubscriber
|
||||
},
|
||||
|
||||
// Verifies new emails added by existing users
|
||||
async _verifyNewEmail (emailHash) {
|
||||
await HIBP.subscribeHash(emailHash.sha1)
|
||||
|
||||
const verifiedEmail = await knex('email_addresses')
|
||||
.where('id', '=', emailHash.id)
|
||||
.update({
|
||||
verified: true
|
||||
})
|
||||
.returning('*')
|
||||
|
||||
return verifiedEmail
|
||||
},
|
||||
|
||||
async getUserEmails (userId) {
|
||||
const userEmails = await knex('email_addresses')
|
||||
.where('subscriber_id', '=', userId)
|
||||
.returning('*')
|
||||
|
||||
return userEmails
|
||||
},
|
||||
|
||||
/**
|
||||
* Update fxa_refresh_token and fxa_profile_json for subscriber
|
||||
*
|
||||
* @param {object} subscriber knex object in DB
|
||||
* @param {string} fxaAccessToken from Firefox Account Oauth
|
||||
* @param {string} fxaRefreshToken from Firefox Account Oauth
|
||||
* @param {string} fxaProfileData from Firefox Account
|
||||
* @returns {object} updated subscriber knex object in DB
|
||||
*/
|
||||
async _updateFxAData (subscriber, fxaAccessToken, fxaRefreshToken, fxaProfileData) {
|
||||
const fxaUID = JSON.parse(fxaProfileData).uid
|
||||
const updated = await knex('subscribers')
|
||||
.where('id', '=', subscriber.id)
|
||||
.update({
|
||||
fxa_uid: fxaUID,
|
||||
fxa_access_token: fxaAccessToken,
|
||||
fxa_refresh_token: fxaRefreshToken,
|
||||
fxa_profile_json: fxaProfileData
|
||||
})
|
||||
.returning('*')
|
||||
const updatedSubscriber = Array.isArray(updated) ? updated[0] : null
|
||||
if (updatedSubscriber) {
|
||||
FXA.destroyOAuthToken({ refresh_token: subscriber.fxa_refresh_token })
|
||||
}
|
||||
return updatedSubscriber
|
||||
},
|
||||
|
||||
async updateFxAProfileData (subscriber, fxaProfileData) {
|
||||
await knex('subscribers').where('id', subscriber.id)
|
||||
.update({
|
||||
fxa_profile_json: fxaProfileData
|
||||
})
|
||||
return this.getSubscriberById(subscriber.id)
|
||||
},
|
||||
|
||||
async setBreachesLastShownNow (subscriber) {
|
||||
// TODO: turn 2 db queries into a single query (also see #942)
|
||||
const nowDateTime = new Date()
|
||||
const nowTimeStamp = nowDateTime.toISOString()
|
||||
await knex('subscribers')
|
||||
.where('id', '=', subscriber.id)
|
||||
.update({
|
||||
breaches_last_shown: nowTimeStamp
|
||||
})
|
||||
return this.getSubscriberByEmail(subscriber.primary_email)
|
||||
},
|
||||
|
||||
async setAllEmailsToPrimary (subscriber, allEmailsToPrimary) {
|
||||
const updated = await knex('subscribers')
|
||||
.where('id', subscriber.id)
|
||||
.update({
|
||||
all_emails_to_primary: allEmailsToPrimary
|
||||
})
|
||||
.returning('*')
|
||||
const updatedSubscriber = Array.isArray(updated) ? updated[0] : null
|
||||
return updatedSubscriber
|
||||
},
|
||||
|
||||
async setBreachesResolved (options) {
|
||||
const { user, updatedResolvedBreaches } = options
|
||||
await knex('subscribers')
|
||||
.where('id', user.id)
|
||||
.update({
|
||||
breaches_resolved: updatedResolvedBreaches
|
||||
})
|
||||
return this.getSubscriberByEmail(user.primary_email)
|
||||
},
|
||||
|
||||
async setWaitlistsJoined (options) {
|
||||
const { user, updatedWaitlistsJoined } = options
|
||||
await knex('subscribers')
|
||||
.where('id', user.id)
|
||||
.update({
|
||||
waitlists_joined: updatedWaitlistsJoined
|
||||
})
|
||||
return this.getSubscriberByEmail(user.primary_email)
|
||||
},
|
||||
|
||||
async removeSubscriber (subscriber) {
|
||||
await knex('email_addresses').where({ subscriber_id: subscriber.id }).del()
|
||||
await knex('subscribers').where({ id: subscriber.id }).del()
|
||||
},
|
||||
|
||||
// This is used by SES callbacks to remove email addresses when recipients
|
||||
// perma-bounce or mark our emails as spam
|
||||
// Removes from either subscribers or email_addresses as necessary
|
||||
async removeEmail (email) {
|
||||
const subscriber = await this.getSubscriberByEmail(email)
|
||||
if (!subscriber) {
|
||||
const emailAddress = await this.getEmailAddressRecordByEmail(email)
|
||||
if (!emailAddress) {
|
||||
log.warn('removed-subscriber-not-found')
|
||||
return
|
||||
}
|
||||
await knex('email_addresses')
|
||||
.where({
|
||||
email,
|
||||
verified: true
|
||||
})
|
||||
.del()
|
||||
return
|
||||
}
|
||||
// If the subscriber has more email_addresses, log the deletion failure
|
||||
if (subscriber.email_addresses.length !== 0) {
|
||||
log.error('removeEmail', {
|
||||
msg: `Unable to delete subscriber ${subscriber.id} with ${subscriber.email_addresses.length} additional email(s).`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await knex('subscribers')
|
||||
.where({
|
||||
primary_verification_token: subscriber.primary_verification_token,
|
||||
primary_sha1: subscriber.primary_sha1
|
||||
})
|
||||
.del()
|
||||
},
|
||||
|
||||
async removeSubscriberByToken (token, emailSha1) {
|
||||
const subscriber = await this.getSubscriberByTokenAndHash(token, emailSha1)
|
||||
if (!subscriber) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Delete subscriber's emails
|
||||
// TODO issue 2744: Replace this code with a DB migration to add cascading deletion
|
||||
await knex('email_addresses').where({ subscriber_id: subscriber.id }).del()
|
||||
|
||||
// Delete the subscriber
|
||||
await knex('subscribers')
|
||||
.where({
|
||||
primary_verification_token: subscriber.primary_verification_token,
|
||||
primary_sha1: subscriber.primary_sha1
|
||||
})
|
||||
.del()
|
||||
return subscriber
|
||||
},
|
||||
|
||||
async removeOneSecondaryEmail (emailId) {
|
||||
await knex('email_addresses')
|
||||
.where({
|
||||
id: emailId
|
||||
})
|
||||
.del()
|
||||
},
|
||||
|
||||
async getSubscribersByHashes (hashes) {
|
||||
return await knex('subscribers').whereIn('primary_sha1', hashes).andWhere('primary_verified', '=', true)
|
||||
},
|
||||
|
||||
async getEmailAddressesByHashes (hashes) {
|
||||
return await knex('email_addresses')
|
||||
.join('subscribers', 'email_addresses.subscriber_id', '=', 'subscribers.id')
|
||||
.whereIn('email_addresses.sha1', hashes)
|
||||
.andWhere('email_addresses.verified', '=', true)
|
||||
},
|
||||
|
||||
async deleteUnverifiedSubscribers () {
|
||||
const expiredDateTime = new Date(Date.now() - AppConstants.DELETE_UNVERIFIED_SUBSCRIBERS_TIMER * 1000)
|
||||
const expiredTimeStamp = expiredDateTime.toISOString()
|
||||
const numDeleted = await knex('subscribers')
|
||||
.where('primary_verified', false)
|
||||
.andWhere('created_at', '<', expiredTimeStamp)
|
||||
.del()
|
||||
log.info('deleteUnverifiedSubscribers', { msg: `Deleted ${numDeleted} rows.` })
|
||||
},
|
||||
|
||||
async deleteSubscriberByFxAUID (fxaUID) {
|
||||
await knex('subscribers').where('fxa_uid', fxaUID).del()
|
||||
},
|
||||
|
||||
async deleteEmailAddressesByUid (uid) {
|
||||
await knex('email_addresses').where({ subscriber_id: uid }).del()
|
||||
},
|
||||
|
||||
async updateBreachStats (id, stats) {
|
||||
await knex('subscribers')
|
||||
.where('id', id)
|
||||
.update({
|
||||
breach_stats: stats
|
||||
})
|
||||
},
|
||||
|
||||
async updateMonthlyEmailTimestamp (email) {
|
||||
const res = await knex('subscribers').update({ monthly_email_at: 'now' })
|
||||
.where('primary_email', email)
|
||||
.returning('monthly_email_at')
|
||||
|
||||
return res
|
||||
},
|
||||
|
||||
async updateMonthlyEmailOptout (token) {
|
||||
await knex('subscribers').update('monthly_email_optout', true).where('primary_verification_token', token)
|
||||
},
|
||||
|
||||
getSubscribersWithUnresolvedBreachesQuery () {
|
||||
return knex('subscribers')
|
||||
.whereRaw('monthly_email_optout IS NOT TRUE')
|
||||
.whereRaw("greatest(created_at, monthly_email_at) < (now() - interval '30 days')")
|
||||
.whereRaw("(breach_stats #>> '{numBreaches, numUnresolved}')::int > 0")
|
||||
},
|
||||
|
||||
async getSubscribersWithUnresolvedBreaches (limit = 0) {
|
||||
let query = this.getSubscribersWithUnresolvedBreachesQuery()
|
||||
.select('primary_email', 'primary_verification_token', 'breach_stats', 'signup_language')
|
||||
if (limit) {
|
||||
query = query.limit(limit).orderBy('created_at')
|
||||
}
|
||||
return await query
|
||||
},
|
||||
|
||||
async getSubscribersWithUnresolvedBreachesCount () {
|
||||
const query = this.getSubscribersWithUnresolvedBreachesQuery()
|
||||
const count = parseInt((await query.count({ count: '*' }))[0].count)
|
||||
return count
|
||||
},
|
||||
|
||||
async createConnection () {
|
||||
if (knex === null) {
|
||||
knex = Knex(knexConfig)
|
||||
}
|
||||
},
|
||||
|
||||
async destroyConnection () {
|
||||
if (knex !== null) {
|
||||
await knex.destroy()
|
||||
knex = null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = DB
|
|
@ -1,28 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const { parse } = require('pg-connection-string')
|
||||
|
||||
const AppConstants = require('../app-constants')
|
||||
const connectionObj = parse(AppConstants.DATABASE_URL)
|
||||
if (AppConstants.NODE_ENV === 'heroku') {
|
||||
connectionObj.ssl = { rejectUnauthorized: false }
|
||||
}
|
||||
|
||||
// For runtime, use DATABASE_URL
|
||||
const RUNTIME_CONFIG = {
|
||||
client: 'postgresql',
|
||||
connection: connectionObj
|
||||
}
|
||||
|
||||
// For tests, use test-DATABASE
|
||||
const testConnectionObj = parse(AppConstants.DATABASE_URL.replace(/\/(\w*)$/, '/test-$1'))
|
||||
const TESTS_CONFIG = {
|
||||
client: 'postgresql',
|
||||
connection: testConnectionObj
|
||||
}
|
||||
|
||||
if (AppConstants.NODE_ENV === 'tests') {
|
||||
module.exports = TESTS_CONFIG
|
||||
} else {
|
||||
module.exports = RUNTIME_CONFIG
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.timestamps(false, true)
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropTimestamps()
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.boolean('fx_newsletter').defaultTo(false)
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropColumn('fx_newsletter')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.string('signup_language')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropColumn('signup_language')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.index('created_at')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropIndex('created_at')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.index('email', 'subscribers_email_idx')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropIndex('email', 'subscribers_email_idx')
|
||||
})
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.string('fxa_refresh_token')
|
||||
table.jsonb('fxa_profile_json')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropColumn('fxa_refresh_token')
|
||||
table.dropColumn('fxa_profile_json')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.index('verified', 'subscribers_verified_idx')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropIndex('verified', 'subscribers_verified_idx')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.string('fxa_uid')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropColumn('fxa_uid')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.timestamp('breaches_last_shown')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropColumn('breaches_last_shown')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('email_addresses', table => {
|
||||
table.timestamps(false)
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('email_addresses', table => {
|
||||
table.dropTimestamps()
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.boolean('all_emails_to_primary').defaultTo(false)
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropColumn('all_emails_to_primary')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.string('fxa_access_token')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex, Promise) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropColumn('fxa_access_token')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('email_addresses', table => {
|
||||
table.index('email', 'email_addresses_email_idx')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('email_addresses', table => {
|
||||
table.dropIndex('email', 'email_addresses_email_idx')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.jsonb('breaches_resolved')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropColumn('breaches_resolved')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.jsonb('waitlists_joined')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('subscribers', table => {
|
||||
table.dropColumn('waitlists_joined')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.integer('kid')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.dropColumn('kid')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = (knex) => {
|
||||
return knex.schema.createTable('removal_pilot', (table) => {
|
||||
table.increments('id').primary()
|
||||
table.string('name').unique()
|
||||
table.integer('enrolled_users').defaultTo(0)
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = (knex) => {
|
||||
return knex.schema.dropTableIfExists('removal_pilot')
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex) {
|
||||
return knex('removal_pilot')
|
||||
.del()
|
||||
.then(function () {
|
||||
// Inserts seed entries
|
||||
return knex('removal_pilot').insert([
|
||||
{ id: 1, name: 'round_01', enrolled_users: 0 }
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex('removal_pilot').del()
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.boolean('removal_optout').defaultTo(false)
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.dropColumn('removal_optout')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.jsonb('breach_stats').defaultTo(null)
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.dropColumn('breach_stats')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.timestamp('monthly_email_at')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.dropColumn('monthly_email_at')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.boolean('monthly_email_optout')
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.dropColumn('monthly_email_optout')
|
||||
})
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.jsonb('breach_resolution').defaultTo(null)
|
||||
})
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('subscribers', (table) => {
|
||||
table.dropColumn('breach_resolution')
|
||||
})
|
||||
}
|
|
@ -1,108 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const getSha1 = require('../../sha1-utils')
|
||||
|
||||
exports.TEST_SUBSCRIBERS = {
|
||||
firefox_account: {
|
||||
id: 12345,
|
||||
primary_sha1: getSha1('firefoxaccount@test.com'),
|
||||
primary_email: 'firefoxaccount@test.com',
|
||||
primary_verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2cf1',
|
||||
primary_verified: true,
|
||||
fxa_access_token: '4a4792b89434153f1a6262fbd6a4510c00834ff842585fc4f4d972da158f0fc0',
|
||||
fxa_refresh_token: '4a4792b89434153f1a6262fbd6a4510c00834ff842585fc4f4d972da158f0fc1',
|
||||
fxa_uid: 12345,
|
||||
fxa_profile_json: {},
|
||||
breaches_last_shown: '2019-04-24 13:27:08.421-05',
|
||||
breaches_resolved: { 'firefoxaccount@test.com': [0] }
|
||||
},
|
||||
all_emails_to_primary: {
|
||||
id: 67890,
|
||||
primary_sha1: getSha1('all_emails_to_primary@test.com'),
|
||||
primary_email: 'all_emails_to_primary@test.com',
|
||||
primary_verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2ca7',
|
||||
primary_verified: true,
|
||||
fxa_refresh_token: '4a4792b89434153f1a6262fbd6a4510c00834ff842585fc4f4d972da158f0fc2',
|
||||
breaches_last_shown: '2019-04-24 13:27:08.421-05',
|
||||
all_emails_to_primary: true
|
||||
},
|
||||
unverified_email: {
|
||||
primary_sha1: getSha1('unverifiedemail@test.com'),
|
||||
primary_email: 'unverifiedemail@test.com',
|
||||
primary_verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2cf0',
|
||||
primary_verified: false
|
||||
},
|
||||
verified_email: {
|
||||
primary_sha1: getSha1('verifiedemail@test.com'),
|
||||
primary_email: 'verifiedemail@test.com',
|
||||
primary_verification_token: '54010800-6c3c-4186-971a-76dc92874941',
|
||||
primary_verified: true,
|
||||
signup_language: 'en-US;q=0.7,en;q=0.3'
|
||||
},
|
||||
has_breaches: {
|
||||
id: 12346,
|
||||
created_at: '2022-06-07 14:29:00.000-05',
|
||||
primary_sha1: getSha1('has-breaches@example.com'),
|
||||
primary_email: 'has-breaches@example.com',
|
||||
primary_verification_token: 'c165711a-69d1-42f1-9850-ce74754f36de',
|
||||
primary_verified: true,
|
||||
fxa_access_token: '5a4792b89434153f1a6262fbd6a4510c00834ff842585fc4f4d972da158f0fc0',
|
||||
fxa_refresh_token: '5a4792b89434153f1a6262fbd6a4510c00834ff842585fc4f4d972da158f0fc1',
|
||||
fxa_uid: 12346,
|
||||
fxa_profile_json: {},
|
||||
breaches_last_shown: '2022-07-08 14:19:00.000-05',
|
||||
breaches_resolved: { 'has-breaches@example.com': [1] },
|
||||
breach_stats: {
|
||||
passwords: {
|
||||
count: 1,
|
||||
numResolved: 0
|
||||
},
|
||||
numBreaches: {
|
||||
count: 2,
|
||||
numResolved: 1,
|
||||
numUnresolved: 1
|
||||
},
|
||||
monitoredEmails: {
|
||||
count: 1
|
||||
}
|
||||
},
|
||||
monthly_email_at: '2022-08-07 14:22:00.000-05',
|
||||
monthly_email_optout: false,
|
||||
signup_language: 'fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7,*;q=0.5'
|
||||
}
|
||||
}
|
||||
|
||||
exports.TEST_EMAIL_ADDRESSES = {
|
||||
firefox_account: {
|
||||
id: 11111,
|
||||
subscriber_id: 12345,
|
||||
sha1: getSha1('firefoxaccount-secondary@test.com'),
|
||||
email: 'firefoxaccount-secondary@test.com',
|
||||
verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2cf2',
|
||||
verified: true
|
||||
},
|
||||
unverified_email_on_firefox_account: {
|
||||
id: 98765,
|
||||
subscriber_id: 12345,
|
||||
sha1: getSha1('firefoxaccount-tertiary@test.com'),
|
||||
email: 'firefoxaccount-tertiary@test.com',
|
||||
verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2cf3',
|
||||
verified: false
|
||||
},
|
||||
all_emails_to_primary: {
|
||||
id: 99999,
|
||||
subscriber_id: 67890,
|
||||
sha1: getSha1('secondary_sending_to_primary@test.com'),
|
||||
email: 'secondary_sending_to_primary@test.com',
|
||||
verification_token: '0e2cb147-2041-4e5b-8ca9-494e773b2cf4',
|
||||
verified: true
|
||||
},
|
||||
has_breaches: {
|
||||
id: 11112,
|
||||
subscriber_id: 12346,
|
||||
sha1: getSha1('has-breaches@example.com'),
|
||||
email: 'has-breaches@example.com',
|
||||
verification_token: 'c165711a-69d1-42f1-9850-ce74754f36df',
|
||||
verified: true
|
||||
}
|
||||
}
|
132
email-utils.js
|
@ -1,132 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
const { URL } = require('url')
|
||||
|
||||
const AppConstants = require('./app-constants')
|
||||
|
||||
const nodemailer = require('nodemailer')
|
||||
const hbs = require('nodemailer-express-handlebars')
|
||||
|
||||
const HBSHelpers = require('./template-helpers/')
|
||||
const mozlog = require('./log')
|
||||
|
||||
const log = mozlog('email-utils')
|
||||
|
||||
const hbsOptions = {
|
||||
viewEngine: {
|
||||
extname: '.hbs',
|
||||
layoutsDir: path.join(__dirname, '/views/layouts'),
|
||||
defaultLayout: 'email-2022',
|
||||
partialsDir: path.join(__dirname, '/views/partials'),
|
||||
helpers: HBSHelpers.helpers
|
||||
},
|
||||
viewPath: path.join(__dirname, '/views/layouts'),
|
||||
extName: '.hbs'
|
||||
}
|
||||
|
||||
// The SMTP transport object. This is initialized to a nodemailer transport
|
||||
// object while reading SMTP credentials, or to a dummy function in debug mode.
|
||||
let gTransporter
|
||||
|
||||
const EmailUtils = {
|
||||
async init (smtpUrl = AppConstants.SMTP_URL) {
|
||||
// Allow a debug mode that will log JSON instead of sending emails.
|
||||
if (!smtpUrl) {
|
||||
log.info('smtpUrl-empty', { message: 'EmailUtils will log a JSON response instead of sending emails.' })
|
||||
gTransporter = nodemailer.createTransport({ jsonTransport: true })
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
gTransporter = nodemailer.createTransport(smtpUrl)
|
||||
const gTransporterVerification = await gTransporter.verify()
|
||||
gTransporter.use('compile', hbs(hbsOptions))
|
||||
return Promise.resolve(gTransporterVerification)
|
||||
},
|
||||
|
||||
sendEmail (aRecipient, aSubject, aTemplate, aContext) {
|
||||
if (!gTransporter) {
|
||||
return Promise.reject(new Error('SMTP transport not initialized'))
|
||||
}
|
||||
|
||||
const emailContext = Object.assign({
|
||||
SERVER_URL: AppConstants.SERVER_URL,
|
||||
layout: aTemplate
|
||||
}, aContext)
|
||||
return new Promise((resolve, reject) => {
|
||||
const emailFrom = AppConstants.EMAIL_FROM
|
||||
const mailOptions = {
|
||||
from: emailFrom,
|
||||
to: aRecipient,
|
||||
subject: aSubject,
|
||||
template: aTemplate,
|
||||
context: emailContext,
|
||||
headers: {
|
||||
'x-ses-configuration-set': AppConstants.SES_CONFIG_SET
|
||||
}
|
||||
}
|
||||
|
||||
gTransporter.sendMail(mailOptions, (error, info) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
if (gTransporter.transporter.name === 'JSONTransport') {
|
||||
log.info('JSONTransport', { message: info.message.toString() })
|
||||
}
|
||||
resolve(info)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
appendUtmParams (url, campaign, content) {
|
||||
const utmParameters = {
|
||||
utm_source: 'fx-monitor',
|
||||
utm_medium: 'email',
|
||||
utm_campaign: campaign,
|
||||
utm_content: content
|
||||
}
|
||||
for (const param in utmParameters) {
|
||||
url.searchParams.append(param, utmParameters[param])
|
||||
}
|
||||
return url
|
||||
},
|
||||
|
||||
getEmailCtaHref (emailType, content, subscriberId = null) {
|
||||
const subscriberParamPath = (subscriberId) ? `/?subscriber_id=${subscriberId}` : '/'
|
||||
const url = new URL(subscriberParamPath, AppConstants.SERVER_URL)
|
||||
return this.appendUtmParams(url, emailType, content)
|
||||
},
|
||||
|
||||
getVerificationUrl (subscriber) {
|
||||
if (!subscriber.verification_token) throw new Error('subscriber has no verification_token')
|
||||
let url = new URL(`${AppConstants.SERVER_URL}/user/verify`)
|
||||
url = this.appendUtmParams(url, 'verified-subscribers', 'account-verification-email')
|
||||
url.searchParams.append('token', encodeURIComponent(subscriber.verification_token))
|
||||
return url
|
||||
},
|
||||
|
||||
getUnsubscribeUrl (subscriber, emailType) {
|
||||
// TODO: email unsubscribe is broken for most emails
|
||||
let url = new URL(`${AppConstants.SERVER_URL}/user/unsubscribe`)
|
||||
const token = (Object.prototype.hasOwnProperty.call(subscriber, 'verification_token')) ? subscriber.verification_token : subscriber.primary_verification_token
|
||||
const hash = (Object.prototype.hasOwnProperty.call(subscriber, 'sha1')) ? subscriber.sha1 : subscriber.primary_sha1
|
||||
url.searchParams.append('token', encodeURIComponent(token))
|
||||
url.searchParams.append('hash', encodeURIComponent(hash))
|
||||
url = this.appendUtmParams(url, 'unsubscribe', emailType)
|
||||
return url
|
||||
},
|
||||
|
||||
getMonthlyUnsubscribeUrl (subscriber, campaign, content) {
|
||||
// TODO: create new subscriptions section in settings to manage all emails and avoid one-off routes like this
|
||||
if (!subscriber.primary_verification_token) throw new Error('subscriber has no primary verification_token')
|
||||
let url = new URL('user/unsubscribe-monthly/', AppConstants.SERVER_URL)
|
||||
|
||||
url = this.appendUtmParams(url, campaign, content)
|
||||
url.searchParams.append('token', encodeURIComponent(subscriber.primary_verification_token))
|
||||
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EmailUtils
|
51
esbuild.js
|
@ -1,16 +1,51 @@
|
|||
const esbuild = require('esbuild')
|
||||
const AppConstants = require('./app-constants')
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
// ESBuild is used to concat and compress the 'client' folder into the 'dist' folder (front-end only)
|
||||
|
||||
import esbuild from 'esbuild'
|
||||
import { readdirSync } from 'node:fs'
|
||||
import AppConstants from './src/appConstants.js'
|
||||
|
||||
const cssPartialDir = 'src/client/css/partials/'
|
||||
const cssPartialPaths = readdirSync(cssPartialDir, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isFile())
|
||||
.map(dirent => cssPartialDir + dirent.name)
|
||||
|
||||
const jsPartialDir = 'src/client/js/partials/'
|
||||
const jsPartialPaths = readdirSync(jsPartialDir, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isFile())
|
||||
.map(dirent => jsPartialDir + dirent.name)
|
||||
|
||||
esbuild.build({
|
||||
logLevel: 'info',
|
||||
bundle: true,
|
||||
entryPoints: ['public/js/app.js', 'public/css/app.css'],
|
||||
entryNames: '[name]',
|
||||
external: ['./public/img/*', './public/fonts/*'],
|
||||
outdir: 'public/dist',
|
||||
entryPoints: ['src/client/js/index.js', 'src/client/css/index.css', ...cssPartialPaths, ...jsPartialPaths],
|
||||
entryNames: '[dir]/[name]',
|
||||
loader: { '.woff2': 'copy' },
|
||||
assetNames: '[dir]/[name]',
|
||||
external: ['*.webp', '*.svg'],
|
||||
outdir: 'dist',
|
||||
format: 'esm',
|
||||
minify: AppConstants.NODE_ENV !== 'dev',
|
||||
sourcemap: AppConstants.NODE_ENV !== 'dev',
|
||||
splitting: true,
|
||||
treeShaking: true
|
||||
splitting: false, // see note below
|
||||
treeShaking: true,
|
||||
platform: 'neutral',
|
||||
define: {
|
||||
buildConstants: JSON.stringify({
|
||||
NODE_ENV: AppConstants.NODE_ENV,
|
||||
GA4_MEASUREMENT_ID: AppConstants.GA4_MEASUREMENT_ID
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
ESBuild automatic code-splitting is disabled for the following reasons:
|
||||
- As of this writing, ESBuild code-splitting is suggested to be experimental/WIP: https://esbuild.github.io/api/#splitting
|
||||
- There is a known bug with ESBuild that loads chunks out of order: https://github.com/evanw/esbuild/issues/399
|
||||
- The complete client bundle is currently only ~10kB transferred; splitting this down further is unlikely to result in significant load speed gains
|
||||
- A ticket was completed (MNTOR-1171) to set up native/logical code-splitting depending on partial.
|
||||
- see also https://github.com/mozilla/blurts-server/pull/2844
|
||||
*/
|
||||
|
|
192
hibp.js
|
@ -1,192 +0,0 @@
|
|||
// TODO: to be deprecated
|
||||
'use strict'
|
||||
|
||||
const got = require('got')
|
||||
|
||||
const AppConstants = require('./app-constants')
|
||||
const { FluentError } = require('./locale-utils')
|
||||
const mozlog = require('./log')
|
||||
const pkg = require('./package.json')
|
||||
|
||||
const HIBP_USER_AGENT = `${pkg.name}/${pkg.version}`
|
||||
// When HIBP "re-names" a breach, it keeps its old 'Name' value but gets a new 'Title'
|
||||
// We use 'Name' in Firefox (via Remote Settings), so we have to maintain our own mapping of re-named breaches.
|
||||
const RENAMED_BREACHES = ['covve']
|
||||
const RENAMED_BREACHES_MAP = {
|
||||
covve: 'db8151dd'
|
||||
}
|
||||
const log = mozlog('hibp')
|
||||
|
||||
const HIBP = {
|
||||
_addStandardOptions (options = {}) {
|
||||
const hibpOptions = {
|
||||
headers: {
|
||||
'User-Agent': HIBP_USER_AGENT
|
||||
},
|
||||
responseType: 'json'
|
||||
}
|
||||
return Object.assign(options, hibpOptions)
|
||||
},
|
||||
|
||||
async _throttledGot (url, reqOptions, tryCount = 1) {
|
||||
let response
|
||||
try {
|
||||
response = await got(url, reqOptions)
|
||||
return response
|
||||
} catch (err) {
|
||||
log.error('_throttledGot', { err })
|
||||
if (err.statusCode === 404) {
|
||||
// 404 can mean "no results", return undefined response; sorry calling code
|
||||
return response
|
||||
} else if (err.statusCode === 429) {
|
||||
log.info('_throttledGot', { err: 'got a 429, tryCount: ' + tryCount })
|
||||
if (tryCount >= AppConstants.HIBP_THROTTLE_MAX_TRIES) {
|
||||
log.error('_throttledGot', { err })
|
||||
throw new FluentError('error-hibp-throttled')
|
||||
} else {
|
||||
tryCount++
|
||||
await new Promise(resolve => setTimeout(resolve, AppConstants.HIBP_THROTTLE_DELAY * tryCount))
|
||||
return await this._throttledGot(url, reqOptions, tryCount)
|
||||
}
|
||||
} else {
|
||||
throw new FluentError('error-hibp-connect')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async req (path, options = {}) {
|
||||
const url = `${AppConstants.HIBP_API_ROOT}${path}`
|
||||
const reqOptions = this._addStandardOptions(options)
|
||||
return await this._throttledGot(url, reqOptions)
|
||||
},
|
||||
|
||||
async kAnonReq (path, options = {}) {
|
||||
// Construct HIBP url and standard headers
|
||||
const url = `${AppConstants.HIBP_KANON_API_ROOT}${path}?code=${encodeURIComponent(AppConstants.HIBP_KANON_API_TOKEN)}`
|
||||
const reqOptions = this._addStandardOptions(options)
|
||||
return await this._throttledGot(url, reqOptions)
|
||||
},
|
||||
|
||||
matchFluentID (dataCategory) {
|
||||
return dataCategory.toLowerCase()
|
||||
.replace(/[^-a-z0-9]/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
},
|
||||
|
||||
formatDataClassesArray (dataCategories) {
|
||||
const formattedArray = []
|
||||
dataCategories.forEach(category => {
|
||||
formattedArray.push(this.matchFluentID(category))
|
||||
})
|
||||
return formattedArray
|
||||
},
|
||||
|
||||
async loadBreachesIntoApp (app) {
|
||||
try {
|
||||
const breachesResponse = await this.req('/breaches')
|
||||
const breaches = []
|
||||
|
||||
for (const breach of breachesResponse.body) {
|
||||
breach.DataClasses = this.formatDataClassesArray(breach.DataClasses)
|
||||
breach.LogoPath = /[^/]*$/.exec(breach.LogoPath)[0]
|
||||
breaches.push(breach)
|
||||
}
|
||||
app.locals.breaches = breaches
|
||||
app.locals.breachesLoadedDateTime = Date.now()
|
||||
app.locals.latestBreach = this.getLatestBreach(breaches)
|
||||
app.locals.mostRecentBreachDateTime = app.locals.latestBreach.AddedDate
|
||||
} catch (error) {
|
||||
throw new FluentError('error-hibp-load-breaches')
|
||||
}
|
||||
log.info('done-loading-breaches', 'great success 👍')
|
||||
},
|
||||
|
||||
async getBreachesForEmail (sha1, allBreaches, includeSensitive = false, filterBreaches = true) {
|
||||
let foundBreaches = []
|
||||
const sha1Prefix = sha1.slice(0, 6).toUpperCase()
|
||||
const path = `/breachedaccount/range/${sha1Prefix}`
|
||||
|
||||
const response = await this.kAnonReq(path)
|
||||
if (!response) {
|
||||
return []
|
||||
}
|
||||
// Parse response body, format:
|
||||
// [
|
||||
// {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
|
||||
// {"hashSuffix":<suffix>,"websites":[<breach1Name>,...]},
|
||||
// ]
|
||||
for (const breachedAccount of response.body) {
|
||||
if (sha1.toUpperCase() === sha1Prefix + breachedAccount.hashSuffix) {
|
||||
foundBreaches = allBreaches.filter(breach => breachedAccount.websites.includes(breach.Name))
|
||||
if (filterBreaches) {
|
||||
foundBreaches = this.filterBreaches(foundBreaches)
|
||||
}
|
||||
|
||||
// NOTE: DO NOT CHANGE THIS SORT LOGIC
|
||||
// We store breach resolutions by recency indices,
|
||||
// so that our DB does not contain any part of any user's list of accounts
|
||||
foundBreaches.sort((a, b) => {
|
||||
return new Date(b.AddedDate) - new Date(a.AddedDate)
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (includeSensitive) {
|
||||
return foundBreaches
|
||||
}
|
||||
return foundBreaches.filter(
|
||||
breach => !breach.IsSensitive
|
||||
)
|
||||
},
|
||||
|
||||
getBreachByName (allBreaches, breachName) {
|
||||
breachName = breachName.toLowerCase()
|
||||
if (RENAMED_BREACHES.includes(breachName)) {
|
||||
breachName = RENAMED_BREACHES_MAP[breachName]
|
||||
}
|
||||
const foundBreach = allBreaches.find(breach => breach.Name.toLowerCase() === breachName)
|
||||
return foundBreach
|
||||
},
|
||||
|
||||
filterBreaches (breaches) {
|
||||
return breaches.filter(
|
||||
breach => !breach.IsRetired &&
|
||||
!breach.IsSpamList &&
|
||||
!breach.IsFabricated &&
|
||||
breach.IsVerified &&
|
||||
breach.Domain !== ''
|
||||
)
|
||||
},
|
||||
|
||||
getLatestBreach (breaches) {
|
||||
let latestBreach = {}
|
||||
let latestBreachDateTime = new Date(0)
|
||||
for (const breach of breaches) {
|
||||
if (breach.IsSensitive) {
|
||||
continue
|
||||
}
|
||||
const breachAddedDate = new Date(breach.AddedDate)
|
||||
if (breachAddedDate > latestBreachDateTime) {
|
||||
latestBreachDateTime = breachAddedDate
|
||||
latestBreach = breach
|
||||
}
|
||||
}
|
||||
return latestBreach
|
||||
},
|
||||
|
||||
async subscribeHash (sha1) {
|
||||
const sha1Prefix = sha1.slice(0, 6).toUpperCase()
|
||||
const path = '/range/subscribe'
|
||||
const options = {
|
||||
method: 'POST',
|
||||
json: { hashPrefix: sha1Prefix }
|
||||
}
|
||||
|
||||
return await this.kAnonReq(path, options)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = HIBP
|
|
@ -1,54 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
const reader = require('@maxmind/geoip2-node').Reader
|
||||
const AppConstants = require('./app-constants')
|
||||
|
||||
let locationDb, timestamp
|
||||
|
||||
async function openLocationDb () {
|
||||
if (locationDb && isFresh()) return console.warn('Location database already open.')
|
||||
|
||||
try {
|
||||
const dbPath = path.join(AppConstants.GEOIP_GEOLITE2_PATH, AppConstants.GEOIP_GEOLITE2_CITY_FILENAME)
|
||||
locationDb = await reader.open(dbPath)
|
||||
} catch (e) {
|
||||
return console.warn('Could not open location database:', e.message)
|
||||
}
|
||||
|
||||
timestamp = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
async function readLocationData (ip, locales) {
|
||||
let locationArr
|
||||
|
||||
if (!isFresh()) await openLocationDb()
|
||||
|
||||
try {
|
||||
const data = locationDb.city(ip)
|
||||
const countryName = data.country?.names[locales.find(locale => data.country?.names[locale])] // find valid locale key and return its value
|
||||
const cityName = data.city?.names[locales.find(locale => data.city?.names[locale])]
|
||||
const subdivisionName = data.subdivisions?.[0].isoCode
|
||||
const subdivisionFiltered = /[A-z]{2,}/.test(subdivisionName) ? subdivisionName : null // return strings that are 2 or more letters, or null (avoid unfamiliar subdivisions like `E` or `09`)
|
||||
|
||||
locationArr = [cityName, subdivisionFiltered, countryName].filter(str => str) // [city name, state code, country code] with non-null items.
|
||||
} catch (e) {
|
||||
return console.warn('Could not read location from database:', e.message)
|
||||
}
|
||||
|
||||
return {
|
||||
shortLocation: locationArr.slice(0, 2).join(', '), // shows the first two location values from the ones available
|
||||
fullLocation: locationArr.join(', ') // shows up to three location values from the ones available
|
||||
}
|
||||
}
|
||||
|
||||
function isFresh () {
|
||||
if (Date.now() - timestamp < 259200000) return true // 1000ms * 60s * 60m * 24h * 3 elapsed time is less than 24hrs
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
openLocationDb,
|
||||
readLocationData
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const changePWLinks = {
|
||||
MySpace: 'https://myspace.com/settings/profile/password',
|
||||
LinkedIn: 'https://www.linkedin.com/psettings/change-password',
|
||||
Dubsmash: 'https://dubsmash.com/reset-password',
|
||||
Canva: 'https://www.canva.com/account',
|
||||
MyFitnessPal: 'https://www.myfitnesspal.com/account/change_password',
|
||||
Adobe: 'https://account.adobe.com/security',
|
||||
Dropbox: 'https://www.dropbox.com/account/security',
|
||||
Houzz: 'https://www.houzz.com/changePassword',
|
||||
Evite: 'https://www.evite.com/settings/password',
|
||||
CafePress: 'https://members.cafepress.com/account/profile.aspx'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
changePWLinks
|
||||
}
|
108
lib/fxa.js
|
@ -1,108 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const ClientOAuth2 = require('client-oauth2')
|
||||
const got = require('got')
|
||||
const { URL } = require('url')
|
||||
|
||||
const AppConstants = require('../app-constants')
|
||||
const mozlog = require('../log')
|
||||
|
||||
const log = mozlog('fxa')
|
||||
|
||||
// This object exists instead of inlining the env vars to make it easy
|
||||
// to abstract fetching API endpoints from the OAuth server (instead
|
||||
// of specifying them in the environment) in the future.
|
||||
const FxAOAuthUtils = {
|
||||
get authorizationUri () { return AppConstants.OAUTH_AUTHORIZATION_URI },
|
||||
get tokenUri () { return AppConstants.OAUTH_TOKEN_URI },
|
||||
get profileUri () { return AppConstants.OAUTH_PROFILE_URI }
|
||||
}
|
||||
|
||||
const FxAOAuthClient = new ClientOAuth2({
|
||||
clientId: AppConstants.OAUTH_CLIENT_ID,
|
||||
clientSecret: AppConstants.OAUTH_CLIENT_SECRET,
|
||||
accessTokenUri: FxAOAuthUtils.tokenUri,
|
||||
authorizationUri: FxAOAuthUtils.authorizationUri,
|
||||
redirectUri: AppConstants.SERVER_URL + '/oauth/confirmed',
|
||||
scopes: ['profile']
|
||||
})
|
||||
|
||||
const FXA = {
|
||||
|
||||
async _postTokenRequest (path, token) {
|
||||
const fxaTokenOrigin = new URL(AppConstants.OAUTH_TOKEN_URI).origin
|
||||
const tokenUrl = `${fxaTokenOrigin}${path}`
|
||||
const tokenBody = (typeof token === 'object') ? token : { token }
|
||||
const tokenOptions = {
|
||||
method: 'POST',
|
||||
json: tokenBody,
|
||||
responseType: 'json'
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await got(tokenUrl, tokenOptions)
|
||||
return response
|
||||
} catch (e) {
|
||||
log.error('_postTokenRequest', { stack: e.stack })
|
||||
return e
|
||||
}
|
||||
},
|
||||
|
||||
async verifyOAuthToken (token) {
|
||||
try {
|
||||
const response = await this._postTokenRequest('/v1/verify', token)
|
||||
return response
|
||||
} catch (e) {
|
||||
log.error('verifyOAuthToken', { stack: e.stack })
|
||||
}
|
||||
},
|
||||
|
||||
async destroyOAuthToken (token) {
|
||||
try {
|
||||
const response = await this._postTokenRequest('/v1/destroy', token)
|
||||
return response
|
||||
} catch (e) {
|
||||
log.error('destroyOAuthToken', { stack: e.stack })
|
||||
}
|
||||
},
|
||||
|
||||
async revokeOAuthTokens (subscriber) {
|
||||
await this.destroyOAuthToken({ token: subscriber.fxa_access_token })
|
||||
await this.destroyOAuthToken({ refresh_token: subscriber.fxa_refresh_token })
|
||||
},
|
||||
|
||||
async getProfileData (accessToken) {
|
||||
try {
|
||||
const data = await got(FxAOAuthUtils.profileUri,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
)
|
||||
return data.body
|
||||
} catch (e) {
|
||||
log.warn('getProfileData', { stack: e.stack })
|
||||
return e
|
||||
}
|
||||
},
|
||||
|
||||
async sendMetricsFlowPing (path) {
|
||||
const fxaMetricsFlowUrl = new URL(path, AppConstants.FXA_SETTINGS_URL)
|
||||
const fxaMetricsFlowOptions = {
|
||||
method: 'GET',
|
||||
headers: { Origin: AppConstants.SERVER_URL }
|
||||
}
|
||||
try {
|
||||
log.info(`GETting ${fxaMetricsFlowUrl}, with options: ${JSON.stringify(fxaMetricsFlowOptions)}`)
|
||||
const fxaResp = await got(fxaMetricsFlowUrl, fxaMetricsFlowOptions)
|
||||
log.info('pinged FXA metrics flow.')
|
||||
return fxaResp
|
||||
} catch (e) {
|
||||
log.error('sendMetricsFlowPing', { stack: e.stack })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FXA,
|
||||
FxAOAuthClient
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
'use strict'
|
||||
|
||||
const got = require('got')
|
||||
const AppConstants = require('../app-constants')
|
||||
|
||||
const BREACHES_COLLECTION = 'fxmonitor-breaches'
|
||||
const FX_RS_COLLECTION = `${AppConstants.FX_REMOTE_SETTINGS_WRITER_SERVER}/buckets/main-workspace/collections/${BREACHES_COLLECTION}`
|
||||
const FX_RS_RECORDS = `${FX_RS_COLLECTION}/records`
|
||||
const FX_RS_WRITER_USER = AppConstants.FX_REMOTE_SETTINGS_WRITER_USER
|
||||
const FX_RS_WRITER_PASS = AppConstants.FX_REMOTE_SETTINGS_WRITER_PASS
|
||||
|
||||
const RemoteSettings = {
|
||||
|
||||
async whichBreachesAreNotInRemoteSettingsYet (breaches) {
|
||||
const fxRSRecords = await got(FX_RS_RECORDS, {
|
||||
responseType: 'json',
|
||||
username: FX_RS_WRITER_USER,
|
||||
password: FX_RS_WRITER_PASS
|
||||
})
|
||||
const remoteSettingsBreachesSet = new Set(
|
||||
fxRSRecords.body.data.map(b => b.Name)
|
||||
)
|
||||
|
||||
return breaches.filter(({ Name }) => !remoteSettingsBreachesSet.has(Name))
|
||||
},
|
||||
|
||||
async postNewBreachToBreachesCollection (data) {
|
||||
// Create the record
|
||||
return await got.post(FX_RS_RECORDS, {
|
||||
username: FX_RS_WRITER_USER,
|
||||
password: FX_RS_WRITER_PASS,
|
||||
json: { data },
|
||||
responseType: 'json'
|
||||
})
|
||||
},
|
||||
|
||||
async requestReviewOnBreachesCollection () {
|
||||
return await got.patch(FX_RS_COLLECTION, {
|
||||
username: FX_RS_WRITER_USER,
|
||||
password: FX_RS_WRITER_PASS,
|
||||
json: { data: { status: 'to-review' } },
|
||||
responseType: 'json'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RemoteSettings
|
108
locale-utils.js
|
@ -1,108 +0,0 @@
|
|||
// TODO: to be deprecated
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// node.js needs Intl.PluralRules polyfill
|
||||
require('intl-pluralrules')
|
||||
|
||||
const { FluentBundle } = require('fluent')
|
||||
|
||||
const mozlog = require('./log')
|
||||
const { supportedLocales } = require('./package.json')
|
||||
|
||||
const log = mozlog('locale-utils')
|
||||
|
||||
const localesDir = 'locales'
|
||||
|
||||
const availableLanguages = []
|
||||
const fluentBundles = {}
|
||||
|
||||
class FluentError extends Error {
|
||||
constructor (fluentID = null, ...params) {
|
||||
super(...params)
|
||||
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, FluentError)
|
||||
}
|
||||
|
||||
this.fluentID = fluentID
|
||||
this.message = fluentID
|
||||
}
|
||||
}
|
||||
|
||||
const LocaleUtils = {
|
||||
init () {
|
||||
let languageDirectories = supportedLocales.split(',')
|
||||
if (supportedLocales === '*') {
|
||||
languageDirectories = fs.readdirSync(localesDir).filter(item => {
|
||||
return (!item.startsWith('.') && fs.lstatSync(path.join(localesDir, item)).isDirectory())
|
||||
})
|
||||
}
|
||||
for (const lang of languageDirectories) {
|
||||
try {
|
||||
const langBundle = new FluentBundle(lang, { useIsolating: false })
|
||||
const ftlFiles = fs.readdirSync(path.join(localesDir, lang)).filter(item => {
|
||||
return (item.endsWith('.ftl'))
|
||||
})
|
||||
for (const file of ftlFiles) {
|
||||
const langFTLSource = fs.readFileSync(path.join(localesDir, lang, file), 'utf8')
|
||||
langBundle.addMessages(langFTLSource)
|
||||
}
|
||||
fluentBundles[lang] = langBundle
|
||||
availableLanguages.push(lang)
|
||||
} catch (e) {
|
||||
log.error('loadFluentBundle', { stack: e.stack })
|
||||
}
|
||||
}
|
||||
log.info('LocaleUtils.init', { availableLanguages })
|
||||
return { availableLanguages, fluentBundles }
|
||||
},
|
||||
|
||||
loadLanguagesIntoApp (app) {
|
||||
app.locals.AVAILABLE_LANGUAGES = availableLanguages
|
||||
app.locals.FLUENT_BUNDLES = fluentBundles
|
||||
},
|
||||
|
||||
fluentFormat (supportedLocales, id, args = null, errors = null) {
|
||||
for (const locale of supportedLocales) {
|
||||
const bundle = fluentBundles[locale]
|
||||
try {
|
||||
const message = bundle.getMessage(id)
|
||||
return bundle.format(message, args)
|
||||
} catch (e) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return id
|
||||
},
|
||||
|
||||
fluentFormatWithFallback (supportedLocales, id, fallbackId, args = null, errors = null) {
|
||||
if (!fallbackId) {
|
||||
log.error('fluentFormatWithFallback: No fallbackId')
|
||||
return false
|
||||
}
|
||||
|
||||
for (const locale of supportedLocales) {
|
||||
const bundle = fluentBundles[locale]
|
||||
if (bundle.hasMessage(id)) {
|
||||
const message = bundle.getMessage(id)
|
||||
return bundle.format(message, args)
|
||||
}
|
||||
|
||||
// If first message id doesn't have translation, use fallback id
|
||||
if (fallbackId && bundle.hasMessage(fallbackId)) {
|
||||
const message = bundle.getMessage(fallbackId)
|
||||
return bundle.format(message, args)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FluentError,
|
||||
LocaleUtils
|
||||
}
|
14
log.js
|
@ -1,14 +0,0 @@
|
|||
// TODO: to be deprecated
|
||||
'use strict'
|
||||
|
||||
const mozlog = require('mozlog')
|
||||
|
||||
const AppConstants = require('./app-constants')
|
||||
|
||||
const log = mozlog({
|
||||
app: 'fx-monitor',
|
||||
level: AppConstants.MOZLOG_LEVEL,
|
||||
fmt: AppConstants.MOZLOG_FMT
|
||||
})
|
||||
|
||||
module.exports = log
|
267
middleware.js
|
@ -1,267 +0,0 @@
|
|||
// TODO: to be deprecated
|
||||
'use strict'
|
||||
|
||||
const { URLSearchParams } = require('url')
|
||||
|
||||
const { negotiateLanguages, acceptedLanguages } = require('fluent-langneg')
|
||||
|
||||
const AppConstants = require('./app-constants')
|
||||
const DB = require('./db/DB')
|
||||
const { FXA } = require('./lib/fxa')
|
||||
const { FluentError } = require('./locale-utils')
|
||||
const mozlog = require('./log')
|
||||
|
||||
const HIBP = require('./hibp')
|
||||
|
||||
const log = mozlog('middleware')
|
||||
|
||||
// adds the request object to a res.local var
|
||||
function addRequestToResponse (req, res, next) {
|
||||
res.locals.req = req
|
||||
next()
|
||||
}
|
||||
|
||||
// picks available language by Accept-Language and assigns to request
|
||||
function pickLanguage (req, res, next) {
|
||||
res.vary('Accept-Language')
|
||||
const requestedLanguage = acceptedLanguages(req.headers['accept-language'])
|
||||
const supportedLocales = negotiateLanguages(
|
||||
requestedLanguage,
|
||||
req.app.locals.AVAILABLE_LANGUAGES,
|
||||
{ defaultLocale: 'en' }
|
||||
)
|
||||
req.supportedLocales = supportedLocales
|
||||
|
||||
req.fluentFormat = (id, args = null, errors = null) => {
|
||||
for (const locale of supportedLocales) {
|
||||
const bundle = req.app.locals.FLUENT_BUNDLES[locale]
|
||||
if (bundle.hasMessage(id)) {
|
||||
const message = bundle.getMessage(id)
|
||||
return bundle.format(message, args)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
async function recordVisitFromEmail (req, res, next) {
|
||||
if (req.query.utm_source && req.query.utm_source !== 'fx-monitor') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
if (req.query.utm_medium && req.query.utm_medium !== 'email') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const breachDetailsRE = /breach-details\/(\w*)$/
|
||||
const capturedMatch = req.path.match(breachDetailsRE)
|
||||
|
||||
// Send engagement ping to FXA if req.query contains a valid subscriber ID
|
||||
if (req.query.subscriber_id && Number.isInteger(Number(req.query.subscriber_id))) {
|
||||
const subscriber = await DB.getSubscriberById(req.query.subscriber_id)
|
||||
|
||||
if (!subscriber.fxa_uid || subscriber.fxa_uid === '') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
const utmContent = (capturedMatch) ? `&utm_content=${capturedMatch[1]}` : ''
|
||||
const fxaMetricsFlowPath = `metrics-flow?utm_source=${req.query.utm_source}&utm_medium=${req.query.utm_medium}${utmContent}&event_type=engage&uid=${subscriber.fxa_uid}&service=${AppConstants.OAUTH_CLIENT_ID}`
|
||||
const fxaResult = await FXA.sendMetricsFlowPing(fxaMetricsFlowPath)
|
||||
log.info(`fxaResult: ${fxaResult}`)
|
||||
}
|
||||
|
||||
// If user is already signed in, proceed
|
||||
if (req.session.user) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
// If user is returning from FXA sign-in, proceed
|
||||
if (req.path === '/oauth/confirmed') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect users who have clicked "Go to Dashboard" from an email and aren't signed in to the /oauth flow.
|
||||
if (
|
||||
req.query.utm_campaign && req.query.utm_campaign === 'go-to-dashboard-link'
|
||||
) {
|
||||
const oauthUrl = new URL('/oauth/init', AppConstants.SERVER_URL);
|
||||
['utm_source', 'utm_campaign', 'utm_medium'].forEach(param => {
|
||||
if (req.query[param]) {
|
||||
oauthUrl.searchParams.append(param, req.query[param])
|
||||
}
|
||||
})
|
||||
req.url = `${oauthUrl.pathname}/${oauthUrl.search}`
|
||||
next()
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// Helps handle errors for all async route controllers
|
||||
// See https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016
|
||||
function asyncMiddleware (fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next)
|
||||
}
|
||||
}
|
||||
|
||||
function logErrors (err, req, res, next) {
|
||||
log.error('error', { stack: err.stack })
|
||||
next(err)
|
||||
}
|
||||
|
||||
function localizeErrorMessages (err, req, res, next) {
|
||||
if (err instanceof FluentError) {
|
||||
err.message = req.fluentFormat(err.fluentID)
|
||||
err.locales = req.supportedLocales
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
|
||||
function clientErrorHandler (err, req, res, next) {
|
||||
if (req.xhr || req.headers['content-type'] === 'application/json') {
|
||||
res.status(500).send({ message: err.message })
|
||||
} else {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
|
||||
function errorHandler (err, req, res, next) {
|
||||
res.status(500)
|
||||
res.render('subpage', {
|
||||
analyticsID: 'error',
|
||||
headline: req.fluentFormat('error-headline'),
|
||||
subhead: err.message
|
||||
})
|
||||
}
|
||||
|
||||
async function _getRequestSessionUser (req, res, next) {
|
||||
if (req.session && req.session.user) {
|
||||
// make sure the user object has all subscribers and email_addresses properties
|
||||
return DB.getSubscriberById(req.session.user.id)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function requireSessionUser (req, res, next) {
|
||||
const user = await _getRequestSessionUser(req)
|
||||
if (!user) {
|
||||
const queryParams = new URLSearchParams(req.query).toString()
|
||||
return res.redirect(`/oauth/init?${queryParams}`)
|
||||
}
|
||||
const fxaProfileData = await FXA.getProfileData(user.fxa_access_token)
|
||||
if (fxaProfileData.hasOwnProperty('name') && fxaProfileData.name === 'HTTPError') {
|
||||
delete req.session.user
|
||||
return res.redirect('/')
|
||||
}
|
||||
await DB.updateFxAProfileData(user, fxaProfileData)
|
||||
req.session.user = user
|
||||
req.user = user
|
||||
next()
|
||||
}
|
||||
|
||||
async function requireAdminUser (req, res, next) {
|
||||
const user = await _getRequestSessionUser(req)
|
||||
if (!user) {
|
||||
const queryParams = new URLSearchParams(req.query).toString()
|
||||
return res.redirect(`/oauth/init?${queryParams}`)
|
||||
}
|
||||
const fxaProfileData = await FXA.getProfileData(user.fxa_access_token)
|
||||
const admins = AppConstants.ADMINS?.split(',') || []
|
||||
const isAdmin = admins.includes(JSON.parse(fxaProfileData).email)
|
||||
|
||||
const hasFxaError = Object.prototype.hasOwnProperty.call(fxaProfileData, 'name') && fxaProfileData.name
|
||||
if (hasFxaError) {
|
||||
delete req.session.user
|
||||
}
|
||||
if (!isAdmin || hasFxaError) {
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
await DB.updateFxAProfileData(user, fxaProfileData)
|
||||
req.session.user = user
|
||||
req.user = user
|
||||
next()
|
||||
}
|
||||
|
||||
function getShareUTMs (req, res, next) {
|
||||
// Step 1: See if the user needs to be redirected to the homepage or to a breach-detail page.
|
||||
const generalShareUrls = [
|
||||
'/share/orange', // Header
|
||||
'/share/purple', // Footer
|
||||
'/share/blue', // user/dashboard
|
||||
'/share/'
|
||||
]
|
||||
|
||||
if (generalShareUrls.includes(req.url)) {
|
||||
// If not breach specific, redirect to "/"
|
||||
req.session.redirectHome = true
|
||||
}
|
||||
|
||||
// If user has no reference to experiment (default), add skip override
|
||||
if (typeof (req.session.experimentFlags) === 'undefined') {
|
||||
req.session.experimentFlags = {
|
||||
excludeFromExperiment: true
|
||||
}
|
||||
}
|
||||
|
||||
const excludedFromExperiment = (req.session.experimentFlags.excludeFromExperiment)
|
||||
|
||||
// Excluse user from experiment if they don't have any experimentFlags set already.
|
||||
if (excludedFromExperiment) {
|
||||
// Step 2: Determine if user needs to have share-link UTMs set
|
||||
const colors = [
|
||||
'orange', // Header
|
||||
'purple', // Footer
|
||||
'blue' // user/dashboard
|
||||
]
|
||||
|
||||
const urlArray = req.url.split('/')
|
||||
const color = urlArray.slice(-1)[0]
|
||||
|
||||
req.session.utmOverrides = {
|
||||
campaignName: 'shareLinkTraffic',
|
||||
campaignTerm: 'default'
|
||||
}
|
||||
|
||||
// Set Color Var in UTM
|
||||
if (color.length && colors.includes(color)) {
|
||||
req.session.utmOverrides.campaignTerm = color
|
||||
}
|
||||
|
||||
if (color.length && !colors.includes(color)) {
|
||||
const allBreaches = req.app.locals.breaches
|
||||
const breachName = color
|
||||
const featuredBreach = HIBP.getBreachByName(allBreaches, breachName)
|
||||
|
||||
if (featuredBreach) {
|
||||
req.session.utmOverrides.campaignTerm = featuredBreach.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude share users
|
||||
req.session.experimentFlags = {
|
||||
excludeFromExperiment: true
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addRequestToResponse,
|
||||
pickLanguage,
|
||||
recordVisitFromEmail,
|
||||
asyncMiddleware,
|
||||
logErrors,
|
||||
localizeErrorMessages,
|
||||
clientErrorHandler,
|
||||
errorHandler,
|
||||
requireSessionUser,
|
||||
requireAdminUser,
|
||||
getShareUTMs
|
||||
}
|
203
package.json
|
@ -1,136 +1,103 @@
|
|||
{
|
||||
"name": "blurts-server",
|
||||
"description": "Firefox Monitor Server",
|
||||
"name": "monitor",
|
||||
"version": "1.0.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/mozilla/blurts-server/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluent/bundle": "^0.17.1",
|
||||
"@fluent/langneg": "^0.6.2",
|
||||
"@maxmind/geoip2-node": "^3.1.0",
|
||||
"@sentry/node": "7.36.0",
|
||||
"@sentry/tracing": "^7.36.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"client-oauth2": "4.3.3",
|
||||
"connect-redis": "^7.0.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"csurf": "1.11.0",
|
||||
"dotenv": "16.0.2",
|
||||
"esbuild": "^0.17.8",
|
||||
"express": "^4.18.2",
|
||||
"express-bearer-token": "2.4.0",
|
||||
"express-handlebars": "6.0.6",
|
||||
"express-session": "1.17.1",
|
||||
"fluent": "0.13.0",
|
||||
"fluent-langneg": "0.2.0",
|
||||
"git-rev-sync": "^3.0.2",
|
||||
"got": "11.8.5",
|
||||
"helmet": "4.2.0",
|
||||
"intl-pluralrules": "1.3.1",
|
||||
"isemail": "3.2.0",
|
||||
"knex": "^2.2.0",
|
||||
"mozlog": "3.0.2",
|
||||
"nodemailer": "^6.7.8",
|
||||
"nodemailer-express-handlebars": "5.0.0",
|
||||
"pg": "^8.7.1",
|
||||
"redis": "^4.6.5",
|
||||
"sns-validator": "0.3.5",
|
||||
"uuid": "3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/gtag.js": "^0.0.12",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.0",
|
||||
"@typescript-eslint/parser": "^5.57.0",
|
||||
"coveralls": "3.1.1",
|
||||
"eslint": "^8.15.0",
|
||||
"eslint-config-standard": "^17.0.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-n": "^15.2.1",
|
||||
"eslint-plugin-promise": "^6.0.1",
|
||||
"jest": "^27.2.5",
|
||||
"node-mocks-http": "1.11.0",
|
||||
"nodemon": "^2.0.20",
|
||||
"redis-mock": "^0.56.3",
|
||||
"stylelint": "^14.9.1",
|
||||
"stylelint-config-standard": "^26.0.0",
|
||||
"typescript": "^5.0.2"
|
||||
},
|
||||
"description": "Firefox Monitor",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "18.12.x",
|
||||
"npm": "8.x"
|
||||
},
|
||||
"homepage": "https://github.com/mozilla/blurts-server",
|
||||
"license": "MPL-2.0",
|
||||
"jest": {
|
||||
"collectCoverageFrom": [
|
||||
"**/*.js",
|
||||
"!coverage/**/**.js",
|
||||
"!db/seeds/**.js",
|
||||
"!db/migrations/**.js",
|
||||
"!public/**/**.js",
|
||||
"!scripts/*.js",
|
||||
"!loadtests/**/**.js",
|
||||
"!.eslintrc.js"
|
||||
"scripts": {
|
||||
"prestart": "npm run build",
|
||||
"start": "node src/app.js",
|
||||
"dev": "nodemon src/app.js",
|
||||
"build": "node esbuild & npm run copy:root & npm run copy:webp & npm run copy:png & npm run build:svg",
|
||||
"build:svg": "svgo -f src/client/images/ -r -o dist/images",
|
||||
"copy:root": "mkdir -p dist/ && cp src/client/*.* dist/",
|
||||
"copy:webp": "mkdir -p dist/images/ && cp -r src/client/images/*.webp dist/images/",
|
||||
"copy:png": "mkdir -p dist/images/email/ && cp -r src/client/images/email/*.png dist/images/email/",
|
||||
"convert:webp": "sh src/scripts/webp.sh",
|
||||
"db:migrate": "node -r dotenv/config node_modules/knex/bin/cli.js migrate:latest --knexfile src/db/knexfile.js",
|
||||
"db:rollback": "node -r dotenv/config node_modules/knex/bin/cli.js migrate:rollback --knexfile src/db/knexfile.js",
|
||||
"test": "NODE_OPTIONS=--loader=testdouble c8 ava",
|
||||
"e2e": "playwright test src/e2e/",
|
||||
"lint": "npm run lint:css && npm run lint:js",
|
||||
"lint:css": "stylelint src/client/css/",
|
||||
"lint:js": "eslint .",
|
||||
"lint:ts": "tsc --noEmit",
|
||||
"fix": "npm run fix:css && npm run fix:js",
|
||||
"fix:js": "eslint . --fix",
|
||||
"fix:css": "stylelint src/client/css/ --fix"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"watch": [
|
||||
"*",
|
||||
".env"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"./tests/jest.setup.js"
|
||||
"ignore": [
|
||||
"src/client/*"
|
||||
],
|
||||
"env": {
|
||||
"LIVE_RELOAD": true
|
||||
},
|
||||
"ext": "js,css,json,ftl,env"
|
||||
},
|
||||
"ava": {
|
||||
"files": [
|
||||
"!src/e2e/"
|
||||
]
|
||||
},
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/mozilla/blurts-server.git"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "npm start -w src",
|
||||
"start:old": "npm run build && node server.js",
|
||||
"dev": "npm run dev -w src",
|
||||
"dev:old": "nodemon server.js",
|
||||
"build": "node esbuild.js",
|
||||
"db:migrate": "knex migrate:latest --knexfile db/knexfile.js",
|
||||
"db:rollback": "knex migrate:rollback --knexfile db/knexfile.js",
|
||||
"docker:build": "docker build -t blurts-server .",
|
||||
"docker:run": "docker run -p 6060:6060 blurts-server",
|
||||
"lint": "npm run lint:css && npm run lint:js",
|
||||
"lint:js": "eslint src",
|
||||
"lint:ts": "tsc --noEmit",
|
||||
"lint:css": "stylelint public/css/",
|
||||
"lint:fluent": "moz-fluent-lint ./locales/en --config .github/linter_config.yml",
|
||||
"fix": "npm run fix:css && npm run fix:js",
|
||||
"fix:js": "eslint . --fix",
|
||||
"fix:css": "stylelint public/css/ --fix",
|
||||
"test:db:migrate": "NODE_ENV=tests knex migrate:latest --knexfile db/knexfile.js --env tests",
|
||||
"test:tests": "NODE_ENV=tests HIBP_THROTTLE_DELAY=1000 HIBP_THROTTLE_MAX_TRIES=3 jest --runInBand --coverage tests/",
|
||||
"test:coveralls": "cat ./coverage/lcov.info | coveralls",
|
||||
"test:integration": "wdio tests/integration/wdio.conf.js",
|
||||
"test:integration-headless": "MOZ_HEADLESS=1 wdio tests/integration/wdio.conf.js",
|
||||
"test:integration-headless-ci": "MOZ_HEADLESS=1 ERROR_SHOTS=1 wdio tests/integration/wdio.conf.js",
|
||||
"test:integration-docker": "MOZ_HEADLESS=1 wdio tests/integration/wdio.docker.js",
|
||||
"test": "npm run test:db:migrate && npm run test:tests && (node scripts/is-coveralls-configured.js && npm run test:coveralls || echo 'Skipping coveralls.')"
|
||||
},
|
||||
"workspaces": [
|
||||
"src"
|
||||
],
|
||||
"nodemonConfig": {
|
||||
"events": {
|
||||
"restart": "npm run build"
|
||||
},
|
||||
"watch": [
|
||||
"./*",
|
||||
".env",
|
||||
".env-dist"
|
||||
],
|
||||
"ignore": [
|
||||
"public/dist/*",
|
||||
"tests/*"
|
||||
],
|
||||
"ext": "js,css,hbs,json,ftl"
|
||||
},
|
||||
"supportedLocales": "cak,cs,cy,da,de,el,en,en-CA,en-GB,es-AR,es-CL,es-ES,es-MX,fi,fr,fy-NL,gn,hu,kab,ia,id,it,ja,nb-NO,nl,nn-NO,pt-BR,pt-PT,ro,ru,sk,sl,sq,sv-SE,tr,uk,vi,zh-CN,zh-TW",
|
||||
"homepage": "https://github.com/mozilla/blurts-server",
|
||||
"license": "MPL-2.0",
|
||||
"volta": {
|
||||
"node": "18.12.1",
|
||||
"npm": "8.19.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluent/bundle": "^0.17.1",
|
||||
"@fluent/langneg": "^0.6.2",
|
||||
"@sentry/node": "^7.40.0",
|
||||
"@sentry/tracing": "^7.38.0",
|
||||
"client-oauth2": "^4.3.3",
|
||||
"connect-redis": "^7.0.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"csrf-csrf": "^2.2.2",
|
||||
"dotenv": "^16.0.3",
|
||||
"esbuild": "^0.17.8",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-session": "^1.17.3",
|
||||
"helmet": "^6.0.0",
|
||||
"knex": "^2.4.2",
|
||||
"mozlog": "^3.0.2",
|
||||
"nodemailer": "^6.9.1",
|
||||
"pg": "^8.9.0",
|
||||
"redis": "^4.6.5",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.32.3",
|
||||
"@types/express": "^4.17.17",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.1",
|
||||
"@typescript-eslint/parser": "^5.59.1",
|
||||
"ava": "^5.1.0",
|
||||
"c8": "^7.12.0",
|
||||
"eslint": "^8.32.0",
|
||||
"eslint-config-standard": "^17.0.0",
|
||||
"eslint-plugin-check-file": "^2.2.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-jsdoc": "^40.0.0",
|
||||
"node-mocks-http": "^1.12.1",
|
||||
"nodemon": "^2.0.20",
|
||||
"redis-mock": "^0.56.3",
|
||||
"stylelint": "^15.6.0",
|
||||
"stylelint-config-standard": "^33.0.0",
|
||||
"svgo": "^3.0.2",
|
||||
"testdouble": "^3.16.8",
|
||||
"typescript": "^5.0.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,12 +13,12 @@ dotenv.config()
|
|||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e/specs',
|
||||
testDir: 'src/e2e/specs',
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 60_000,
|
||||
|
||||
/* Global setup */
|
||||
globalSetup: './e2e/globalSetup.js',
|
||||
globalSetup: 'src/e2e/globalSetup.js',
|
||||
|
||||
/* Max time in milliseconds the whole test suite can to prevent CI breaking. */
|
||||
globalTimeout: 360_000,
|
||||
|
@ -101,7 +101,7 @@ export default defineConfig({
|
|||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
outputDir: './e2e/test-results/',
|
||||
outputDir: 'src/e2e/test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"name": "blurts-server",
|
||||
"description": "The app powering monitor.firefox.com.",
|
||||
"repository": {
|
||||
"url": "https://github.com/mozilla/blurts-server",
|
||||
"license": "MPL-2.0",
|
||||
"tests": "https://travis-ci.org/mozilla/blurts-server/"
|
||||
},
|
||||
"participate": {
|
||||
"home": "https://github.com/mozilla/blurts-server",
|
||||
"docs": "https://github.com/mozilla/blurts-server/blob/master/README.md"
|
||||
},
|
||||
"bugs": {
|
||||
"list": "https://github.com/mozilla/blurts-server/issues",
|
||||
"report": "https://github.com/mozilla/blurts-server/issues/new",
|
||||
"mentored": "https://github.com/mozilla/blurts-server/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22"
|
||||
},
|
||||
"urls": {
|
||||
"prod": "https://monitor.firefox.com",
|
||||
"stage": "https://stage.firefoxmonitor.nonprod.cloudops.mozgcp.net/",
|
||||
"dev": "https://fx-breach-alerts.herokuapp.com/"
|
||||
},
|
||||
"keywords": [
|
||||
"node",
|
||||
"postgres",
|
||||
"security",
|
||||
"api",
|
||||
"email"
|
||||
]
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
@import 'includes/variables.css';
|
||||
@import 'includes/fonts.css';
|
||||
@import 'partials/all-breaches.css';
|
||||
@import 'partials/main.css';
|
||||
@import 'partials/articles.css';
|
||||
@import 'partials/breach-cards.css';
|
||||
@import 'partials/breach-detail.css';
|
||||
@import 'partials/breach-stats.css';
|
||||
@import 'partials/dashboard.css';
|
||||
@import 'partials/email-card.css';
|
||||
@import 'partials/feature-tip-group.css';
|
||||
@import 'partials/footer-about.css';
|
||||
@import 'partials/footer.css';
|
||||
@import 'partials/forms.css';
|
||||
@import 'partials/fx-bento.css';
|
||||
@import 'partials/header.css';
|
||||
@import 'partials/latest-breach.css';
|
||||
@import 'partials/monitor.css';
|
||||
@import 'partials/product-promos.css';
|
||||
@import 'partials/scan-results.css';
|
||||
@import 'partials/security-tips.css';
|
||||
@import 'partials/sign-up-banner.css';
|
||||
@import 'partials/subpage.css';
|
||||
@import 'partials/vpn-banner.css';
|
|
@ -1,39 +0,0 @@
|
|||
@font-face {
|
||||
font-family: Metropolis;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: local('Metropolis-SemiBold'), url('../../fonts/Metropolis/Metropolis-SemiBold.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('Inter-Regular'), url('../../fonts/Inter/Inter-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('Inter-Italic'), url('../../fonts/Inter/Inter-Italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: local('Inter-Bold'), url('../../fonts/Inter/Inter-Bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: local('Inter-BoldItalic'), url('../../fonts/Inter/Inter-BoldItalic.woff2') format('woff2');
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
:root {
|
||||
--take-back-control-gradient: linear-gradient(253deg, #2150c8, #712291);
|
||||
--fxa-sign-up-gradient: linear-gradient(103deg, #374dcc, #9456f9 0%, #394cca 34%, #312a65 69%, #1d1133);
|
||||
--ink-light: #2b1e44;
|
||||
--ink: #20123a;
|
||||
--ink-dark: #1d1133;
|
||||
--padding: 16px;
|
||||
--max-width: 1270px;
|
||||
--headline: 40px;
|
||||
--top-headline: 56px;
|
||||
--border-color: #eee;
|
||||
--text-light: #5e5e72;
|
||||
--alert-red: #ff4f5e;
|
||||
--purple: #9059ff;
|
||||
--violet1: #c689ff;
|
||||
--violet2: #ab71ff;
|
||||
--violet3: #9059ff;
|
||||
--violet4: #7542e5;
|
||||
--violet5: #592acb;
|
||||
--violet6: #45278d;
|
||||
--purple1: #f565ff;
|
||||
--purple5: #722291;
|
||||
--purple6: #332869;
|
||||
--purple7: #393473;
|
||||
--pink4: #e31587;
|
||||
--blue1: #0df;
|
||||
--blue2: #0090ed;
|
||||
--blue3: #0060df;
|
||||
--blue4: #0250bb;
|
||||
--blue5: #073072;
|
||||
--orange1: #ffa266;
|
||||
--green1: #54ffbd;
|
||||
--orange5: #ff7139;
|
||||
--orange6: #e25920;
|
||||
--bg-light: #f9f7fd;
|
||||
--paragraph-font-size: 16px;
|
||||
--grey90a3: rgb(12 12 13 / 0.3);
|
||||
--grey1: #f9f9fa;
|
||||
--grey2: #ededf0;
|
||||
--grey3: #cdcdd4;
|
||||
--grey6: #5e5e72;
|
||||
--grey8: #42425a;
|
||||
--grey9: #202340;
|
||||
--monitor-gradient: linear-gradient(-90deg, #ff9100 0%, #f10366 50%, #6173ff 100%);
|
||||
--vpn-purple: #c5c8fb;
|
||||
}
|
|
@ -1,181 +0,0 @@
|
|||
.all-breaches-main {
|
||||
background: url('../../img/svg/ab-bg.svg') left top / 100% auto;
|
||||
}
|
||||
|
||||
span.x-close-bg,
|
||||
svg.x-close {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hide-breaches {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fuzzy-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
max-width: 320px;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button.fuzzy-find-show-breaches {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
margin: auto;
|
||||
min-width: 1.25rem;
|
||||
min-height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.show-all-breaches.btn-violet-primary {
|
||||
margin: var(--padding) auto calc(var(--padding) * 3) auto;
|
||||
}
|
||||
|
||||
#no-results-blurb {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
margin: 48px auto auto;
|
||||
color: var(--purple7);
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
#no-results-blurb.show {
|
||||
display: inline-block;
|
||||
opacity: 1;
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
button.fuzzy-find-show-breaches.show {
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.x-close-bg {
|
||||
background-color: var(--grey3);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.x-close-bg svg path {
|
||||
fill: var(--grey1);
|
||||
}
|
||||
|
||||
.all-breaches-headline {
|
||||
color: var(--ink);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.fuzzy-form {
|
||||
max-width: 320px;
|
||||
background-color: rgb(255 255 255 / 0.05);
|
||||
border: 1px solid rgb(255 255 255 / 0.1);
|
||||
position: relative;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.fuzzy-find-input {
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
border: 1px solid var(--grey90a3);
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
text-indent: 36px;
|
||||
}
|
||||
|
||||
.fuzzy-find-input,
|
||||
.fuzzy-find-submit {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
input#fuzzy-find-input::placeholder,
|
||||
input#fuzzy-find-input {
|
||||
font-size: 16px;
|
||||
color: #474747;
|
||||
}
|
||||
|
||||
.fuzzy-find-submit {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
color: var(--grey2);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fuzzy-find-input:focus,
|
||||
.fuzzy-find-input:active,
|
||||
.fuzzy-find-submit:active,
|
||||
.fuzzy-find-submit:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.breaches-loader {
|
||||
position: absolute;
|
||||
z-index: 101;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.ab.breach-card {
|
||||
max-width: 31%;
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.all-breaches {
|
||||
padding: var(--padding);
|
||||
flex-flow: row wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
.ab.breach-card.three-up {
|
||||
flex: 1 1 45%;
|
||||
max-width: 45%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.all-breaches {
|
||||
padding: var(--padding);
|
||||
}
|
||||
|
||||
.ab.breach-card.three-up {
|
||||
max-width: 100%;
|
||||
margin: 0.5rem auto;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
|
@ -1,124 +0,0 @@
|
|||
.article-list-subhead {
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
padding-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.article {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.article-list {
|
||||
margin: 1.75rem 0;
|
||||
}
|
||||
|
||||
li.article {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.article-link {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
color: rgb(255 255 255 / 1);
|
||||
display: inline-block;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.article-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.article-link .arrow-head-right path {
|
||||
fill: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
.article-list-headline {
|
||||
margin-bottom: var(--padding);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.article-list-headline,
|
||||
.article-list-subhead {
|
||||
color: var(--grey1);
|
||||
}
|
||||
|
||||
.security-tips-link {
|
||||
margin-left: 3.25rem;
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
li.logo-title-wrapper.flx.article {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.take-back-control-banner {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.article-list-headline-wrapper {
|
||||
padding: var(--padding);
|
||||
}
|
||||
|
||||
.article-list,
|
||||
.article-list-headline-wrapper {
|
||||
max-width: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: auto;
|
||||
padding: var(--padding);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1100px) {
|
||||
.col-8.article-section-list-wrapper {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.latest-breach-wrapper {
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.article-list-content-wrap {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.articles {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.icon-inline-wrapper {
|
||||
margin-right: 1.15rem;
|
||||
}
|
||||
|
||||
.take-back-control-banner {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.article-list-headline {
|
||||
font-size: var(--headline);
|
||||
}
|
||||
|
||||
.security-tips-link {
|
||||
margin-left: 2.25rem;
|
||||
}
|
||||
|
||||
li.article {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.article-list-subhead {
|
||||
padding-right: 10%;
|
||||
}
|
||||
|
||||
li.logo-title-wrapper.flx.article {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
|
@ -1,150 +0,0 @@
|
|||
.breach-card {
|
||||
--logo-dmns: 24px;
|
||||
--logo-margin: 20px;
|
||||
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin: 0.5rem;
|
||||
color: var(--ink);
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
.breach-key {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
color: #5b5b5b;
|
||||
}
|
||||
|
||||
.breach-value {
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breach-key,
|
||||
.breach-value {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.breach-title {
|
||||
padding-top: 2px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-family: Metropolis, sans-serif;
|
||||
}
|
||||
|
||||
.breach-info-wrapper {
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.breach-logo {
|
||||
max-height: var(--logo-dmns);
|
||||
max-width: var(--logo-dmns);
|
||||
}
|
||||
|
||||
.breach-logo-wrapper { /* reduce jumping on lazyload */
|
||||
content: '';
|
||||
display: block;
|
||||
min-height: var(--logo-dmns);
|
||||
min-width: var(--logo-dmns);
|
||||
max-height: var(--logo-dmns);
|
||||
max-width: var(--logo-dmns);
|
||||
margin-right: var(--logo-margin);
|
||||
}
|
||||
|
||||
.breach-card-link-wrap {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.breach-title,
|
||||
.breach-value,
|
||||
.breach-key {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.two-up {
|
||||
flex: 1 1 45%;
|
||||
margin: var(--padding);
|
||||
max-width: 46%; /* prevent stretching in scan results */
|
||||
}
|
||||
|
||||
.scan-res-breaches .two-up {
|
||||
flex: 1 1 47%;
|
||||
margin: var(--padding) 8px;
|
||||
max-width: 47%;
|
||||
}
|
||||
|
||||
.three-up {
|
||||
flex: 1 1 30%;
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.new-breach-card {
|
||||
border: 2px solid var(--alert-red);
|
||||
}
|
||||
|
||||
.resolved-breach-card {
|
||||
border: 2px solid #20c3a2;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.resolved-breach-card span.breach-title {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.resolved-breach-indicator {
|
||||
background-color: #20c3a2;
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.breach-card-status {
|
||||
display: flex;
|
||||
margin: 0 0 0 auto;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.breach-status-message {
|
||||
margin: 0 0 auto;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.new-breach-message {
|
||||
background-color: var(--alert-red);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
.three-up {
|
||||
max-width: 48%;
|
||||
flex: 1 1 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.breach-card {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.email-card.active .breach-card.ec.two-up {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.two-up,
|
||||
.scan-res-breaches .two-up,
|
||||
.three-up {
|
||||
max-width: 100%;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.breach-card {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
}
|
|
@ -1,542 +0,0 @@
|
|||
main.breach-detail {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
color: var(--grey1);
|
||||
}
|
||||
|
||||
.bg-split {
|
||||
background-image: linear-gradient(rgb(255 255 255 / 0) 50%, #fff 0);
|
||||
}
|
||||
|
||||
.breach-type:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.delayed-reporting {
|
||||
margin-top: 18px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.detail-section.bg-split {
|
||||
padding: 0 0 20px;
|
||||
}
|
||||
|
||||
.bg-split .mw-700 {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.breach-detail-headline {
|
||||
margin: auto auto 8px;
|
||||
color: var(--ink);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.breach-detail-logo-wrapper {
|
||||
margin-bottom: var(--padding);
|
||||
}
|
||||
|
||||
.breach-detail-logo.breach-logo {
|
||||
max-width: 10rem;
|
||||
max-height: 10rem;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: flex;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.overview,
|
||||
.overview p {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.overview p {
|
||||
margin: 20px 0 0;
|
||||
}
|
||||
|
||||
.additional-data-types {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.additional-data-types-wrapper .source-info {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.breach-type {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--grey6);
|
||||
}
|
||||
|
||||
.priority-data-classes-list {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lower-priority-data-types {
|
||||
margin-top: 0;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.priority-data-type {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.data-type {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.data-type,
|
||||
.data-action {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* recommendations */
|
||||
|
||||
.rec-img {
|
||||
min-width: 52px;
|
||||
height: 48px;
|
||||
margin: 0 auto;
|
||||
background-position: top center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.rec-pw-1 {
|
||||
background-image: url('../../img/recommendation-icons/change-password.svg');
|
||||
}
|
||||
|
||||
.rec-pw-2 {
|
||||
background-image: url('../../img/recommendation-icons/update-passwords.svg');
|
||||
}
|
||||
|
||||
.rec-pw-3 {
|
||||
background-image: url('../../img/recommendation-icons/store-safe-place.svg');
|
||||
}
|
||||
|
||||
.rec-pw-4 {
|
||||
background-image: url('../../img/recommendation-icons/set-2FA.svg');
|
||||
}
|
||||
|
||||
.rec-ssn {
|
||||
background-image: url('../../img/recommendation-icons/review-credit.svg');
|
||||
}
|
||||
|
||||
.rec-bank-acc {
|
||||
background-image: url('../../img/recommendation-icons/monitor-bank.svg');
|
||||
}
|
||||
|
||||
.rec-cc {
|
||||
background-image: url('../../img/recommendation-icons/monitor-credit-cards.svg');
|
||||
}
|
||||
|
||||
.rec-ip-non-us {
|
||||
background-image: url('../../img/recommendation-icons/use-mask-location-service.svg');
|
||||
}
|
||||
|
||||
.rec-ip-us {
|
||||
background-image: url('../../img/recommendation-icons/use-mask-IP-service.svg');
|
||||
}
|
||||
|
||||
.rec-hist-pw {
|
||||
background-image: url('../../img/recommendation-icons/change-password.svg');
|
||||
}
|
||||
|
||||
.rec-sec-qa {
|
||||
background-image: url('../../img/recommendation-icons/unique-answers.svg');
|
||||
}
|
||||
|
||||
.rec-phone-num {
|
||||
background-image: url('../../img/recommendation-icons/avoid-sharing-phone.svg');
|
||||
}
|
||||
|
||||
.rec-dob {
|
||||
background-image: url('../../img/recommendation-icons/strengthen-pin-security.svg');
|
||||
}
|
||||
|
||||
.rec-pins {
|
||||
background-image: url('../../img/recommendation-icons/strengthen-pin-security.svg');
|
||||
}
|
||||
|
||||
.rec-address {
|
||||
background-image: url('../../img/recommendation-icons/avoid-address.svg');
|
||||
}
|
||||
|
||||
.rec-gen-1 {
|
||||
background-image: url('../../img/recommendation-icons/unique-strong-pwds.svg');
|
||||
}
|
||||
|
||||
.rec-gen-2 {
|
||||
background-image: url('../../img/recommendation-icons/store-safe-place.svg');
|
||||
}
|
||||
|
||||
.rec-gen-3 {
|
||||
background-image: url('../../img/recommendation-icons/avoid-personal-info.svg');
|
||||
}
|
||||
|
||||
.rec-gen-4 {
|
||||
background-image: url('../../img/recommendation-icons/update-regularly.svg');
|
||||
}
|
||||
|
||||
.rec-email {
|
||||
background-image: url('../../img/recommendation-icons/masked-email.svg');
|
||||
}
|
||||
|
||||
.overflow-recs {
|
||||
visibility: visible;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
button.fade-out {
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
font-size: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.affected-email {
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block !important;
|
||||
margin: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.affected-email-notification {
|
||||
border-radius: 2px;
|
||||
background-color: #ffffd1;
|
||||
color: #89201f;
|
||||
font-size: 14px;
|
||||
margin: 60px auto;
|
||||
}
|
||||
|
||||
.affected-email-message {
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.resolve-breaches-wrapper {
|
||||
background-color: #ffffd1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.affected-email-message::before {
|
||||
content: '';
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
display: inline-block;
|
||||
background-image: url('../../img/svg/notification.svg');
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
margin-right: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.blue-link.what-to-do-next {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.resolve-headline {
|
||||
padding: 24px 24px 12px;
|
||||
color: var(--purple7);
|
||||
font-size: 24px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.resolution-message {
|
||||
color: var(--grey8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.affected-emails-list-wrapper {
|
||||
padding: 24px 32px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.affected-emails-list-wrapper::before {
|
||||
background: var(--monitor-gradient);
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.resolution-message span {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.affected-emails-list {
|
||||
max-width: 588px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.affected-email-list-item {
|
||||
padding: 30px 40px;
|
||||
border-bottom: 1px solid rgb(0 0 0 / 0.2);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.affected-email-list-item:last-of-type {
|
||||
border-bottom: 1px solid rgb(0 0 0 / 0);
|
||||
}
|
||||
|
||||
.resolve-undo {
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.resolve-button {
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
transition: all 0.3s ease !important;
|
||||
max-width: 200px !important;
|
||||
}
|
||||
|
||||
.resolve-button::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
content: '';
|
||||
display: block;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 30px;
|
||||
opacity: 0;
|
||||
transition: all 0.5s ease !important;
|
||||
}
|
||||
|
||||
.resolve-button.btn-blue-primary::after {
|
||||
background-image: url('../../img/svg/new-loader.svg');
|
||||
}
|
||||
|
||||
.resolve-button.btn-ghost::after {
|
||||
background-image: url('../../img/svg/loader-dark.svg');
|
||||
}
|
||||
|
||||
.resolve-button.btn-ghost.loading {
|
||||
color: rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.resolve-button.btn-ghost.loading span {
|
||||
color: rgb(0 96 223 / 0.102);
|
||||
}
|
||||
|
||||
.resolve-button.btn-blue-primary.loading {
|
||||
color: rgb(255 255 255 / 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.resolve-button.loading::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.breach-resolution-modal {
|
||||
padding: var(--padding);
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgb(255 255 255 / 0);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.breach-resolution-modal.modal-loading {
|
||||
background-color: rgb(255 255 255 / 0.1);
|
||||
display: flex;
|
||||
visibility: visible;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.breach-resolution-modal.modal-open {
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.confirmation-modal-wrapper {
|
||||
position: relative;
|
||||
padding: 60px var(--padding);
|
||||
border-radius: 8px;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-open .confirmation-modal-wrapper {
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-width: 350px;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.modal-headline {
|
||||
color: var(--purple7);
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.overlay-resolved-first-breach::before {
|
||||
background-image: url('../../img/svg/resolution-overlays/resolved-first-breach.svg');
|
||||
}
|
||||
|
||||
.overlay-take-that-hackers::before {
|
||||
background-image: url('../../img/svg/resolution-overlays/take-that-hackers.svg');
|
||||
}
|
||||
|
||||
.overlay-another-breach-resolved::before {
|
||||
background-image: url('../../img/svg/resolution-overlays/another-breach-resolved.svg');
|
||||
}
|
||||
|
||||
.overlay-marked-as-resolved::before {
|
||||
background-image: url('../../img/svg/resolution-overlays/marked-as-resolved.svg');
|
||||
}
|
||||
|
||||
.modal-headline::before {
|
||||
height: 200px;
|
||||
width: auto;
|
||||
margin: 0 auto 20px;
|
||||
background-position: center center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.modal-progress-message {
|
||||
line-height: 1.5;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
.go-to-dash,
|
||||
.modal-content .progress-header {
|
||||
margin: 0 auto 32px;
|
||||
}
|
||||
|
||||
.blue-link.return-to-breach-details {
|
||||
margin: 0 auto;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgb(2 80 187 / 0);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.blue-link.return-to-breach-details:hover,
|
||||
.blue-link.return-to-breach-details:focus {
|
||||
text-decoration: none;
|
||||
border-bottom-color: var(--blue4);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.close-modal-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border: none;
|
||||
background-image: url('../../img/x-close-gray.svg');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.bg-split .mw-700 {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.modal-headline::before {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.confirmation-modal-wrapper {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affected-email-notification {
|
||||
margin: 120px auto 60px;
|
||||
}
|
||||
|
||||
.affected-email-list-item {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.affected-email {
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
white-space: wrap;
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.modal-headline::before {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.breach-recommendations {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.rec-img {
|
||||
height: 52px;
|
||||
width: 100%;
|
||||
margin: 0 auto 20px;
|
||||
background-position: bottom center;
|
||||
}
|
||||
|
||||
.rec-pw-1,
|
||||
.rec-pw-2,
|
||||
.rec-gen-4,
|
||||
.rec-hist-pw,
|
||||
.rec-ip-non-us,
|
||||
.rec-ip-us,
|
||||
.rec-sec-qa {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.modal-content .progress-header {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.affected-email-list-item {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
|
@ -1,193 +0,0 @@
|
|||
/* stylelint-disable-next-line selector-class-pattern */
|
||||
.monitoredEmails {
|
||||
background-color: var(--violet3);
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-class-pattern */
|
||||
.numBreaches {
|
||||
background-color: var(--violet4);
|
||||
}
|
||||
|
||||
.passwords {
|
||||
background-color: var(--violet5);
|
||||
}
|
||||
|
||||
.breach-stat-number,
|
||||
.stat-headline {
|
||||
color: rgb(255 255 255 / 1);
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.breach-stat-number {
|
||||
font-size: 56px;
|
||||
line-height: 1.14;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.breach-stat-row {
|
||||
padding: 32px var(--padding);
|
||||
max-width: 31.25%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-row-title-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stat-headline {
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 1.33;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dash-stats {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-bottom: calc(var(--padding) * 4);
|
||||
}
|
||||
|
||||
.resolved-breach-indicator,
|
||||
.progress-header::before {
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
background-color: #20c3a2;
|
||||
background-image: url('../../img/svg/white-check.svg');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
content: '';
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.progress-header::before {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.progress-bar-wrapper {
|
||||
margin-bottom: calc(var(--padding) * 4);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
-webkit-appearance: none;
|
||||
background: var(--monitor-gradient);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
direction: rtl;
|
||||
padding: 0;
|
||||
border: none;
|
||||
box-shadow: 0 0 0 1px var(--grey3);
|
||||
}
|
||||
|
||||
/* removes black border on edge */
|
||||
.progress-bar::-ms-fill {
|
||||
border-color: currentcolor;
|
||||
}
|
||||
|
||||
.progress-status-message {
|
||||
color: var(--purple7);
|
||||
margin: auto;
|
||||
max-width: 640px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-message-subhead {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-message-body {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.breach-resolution-img {
|
||||
height: 115px;
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
margin-right: 20px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.breach-resolution-intro {
|
||||
background-image: url('../../img/svg/breach-resolution-intro.svg');
|
||||
}
|
||||
|
||||
.breach-resolution-complete {
|
||||
background-image: url('../../img/svg/breach-resolution-complete.svg');
|
||||
}
|
||||
|
||||
progress[value]::-webkit-progress-bar {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.progress-bar::-moz-progress-bar {
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
progress.progress-bar::-webkit-progress-value {
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
box-shadow: 30px -9px 0 9px white;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.percent-complete {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.dash-stats {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.breach-stat-row {
|
||||
max-width: 100%;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-row-title-wrap {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-headline {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.breach-stat-number {
|
||||
width: auto;
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.img-placeholder {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-status-message {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 550px) {
|
||||
.percent-complete {
|
||||
display: none;
|
||||
}
|
||||
}
|
|
@ -1,191 +0,0 @@
|
|||
.welcome-back {
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
color: var(--grey8);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.dashboard.clear-header {
|
||||
padding-top: calc(var(--header-height) + 80px);
|
||||
position: relative;
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
h2.pref-headline {
|
||||
font-size: 32px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h3.pref-section-headline {
|
||||
margin: 60px 0 32px;
|
||||
}
|
||||
|
||||
h3.pref-section-headline.remove {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h2.pref-headline.breach-summary {
|
||||
margin-top: calc(var(--padding) * 5);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dashboard-summary {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-summary::before {
|
||||
content: '';
|
||||
background: #eee url('../../img/svg/scan-res-bg.svg') top center / cover;
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: calc(100% + var(--header-height));
|
||||
top: calc(-1 * var(--header-height));
|
||||
left: calc(50% - 50vw);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.pref {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.pref .email-add {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pref-remove {
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.preferences {
|
||||
background-size: auto;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.email-pref {
|
||||
font-size: 14px;
|
||||
margin: 40px 0 12px;
|
||||
font-weight: 600;
|
||||
color: #686869;
|
||||
}
|
||||
|
||||
.email-pref.fxa-primary-email {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.email-verification-required {
|
||||
color: var(--alert-red);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.email-verification-required::before {
|
||||
content: '';
|
||||
background-image: url('../../img/svg/data-compromised.svg');
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100%;
|
||||
margin-right: 8px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.trash-can {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.link-header-wrapper {
|
||||
margin: 80px 0 60px;
|
||||
}
|
||||
|
||||
.email-cards {
|
||||
width: 100%;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
span.dashboard-email-sent {
|
||||
background-color: var(--green1);
|
||||
color: #084036;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: calc(var(--padding) + 0.5rem) auto 0.5rem auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
p.confirm-submit {
|
||||
color: #686869;
|
||||
font-size: 14px;
|
||||
margin: 0 auto;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
span.email-confirmed {
|
||||
font-size: 18px;
|
||||
margin: 0 auto 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-add-email {
|
||||
padding: var(--padding) var(--padding) calc(var(--padding) * 3) var(--padding);
|
||||
}
|
||||
|
||||
.pref input.email-add-submit {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dash-take-back-wrapper {
|
||||
padding: 60px 0;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.pref {
|
||||
padding: 0 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.email-cards {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.link-header-wrapper {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.email-cards .e-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* .dashboard.clear-header {
|
||||
padding-top: 156px;
|
||||
} */
|
||||
|
||||
.pref .email-add {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
h2.pref-headline {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.welcome-back {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pref {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
}
|
|
@ -1,446 +0,0 @@
|
|||
.email-card.preferences .e-info {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
flex-direction: column;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.e-info-content {
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preferences .e-info-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.email-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.email-card.breaches-dash {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.email-card.breaches-dash:last-of-type,
|
||||
.breaches-dash.zero-breaches,
|
||||
.breaches-dash.zero-unresolved-breaches {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.email-card.preferences {
|
||||
padding: 20px;
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
border: 1px solid #e8e8e8;
|
||||
margin: 0 0 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.email-card.primary-email-card.preferences {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.e-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-flow: row wrap;
|
||||
border-bottom: 2px solid #e8e8e8;
|
||||
padding-bottom: 12px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preferences .e-info {
|
||||
align-items: flex-start;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.preferences .e-num-breaches {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--grey8);
|
||||
}
|
||||
|
||||
.preferences .e-address {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.preferences .e-address::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.e-address {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--purple6);
|
||||
max-width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.e-address span.light {
|
||||
font-weight: 300;
|
||||
color: var(--grey6);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.e-num-breaches {
|
||||
font-size: 20px;
|
||||
display: block;
|
||||
color: var(--purple6);
|
||||
}
|
||||
|
||||
.e-address::after {
|
||||
content: '-';
|
||||
margin: auto 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.breach-title-wrapper.ec {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* email breach lists */
|
||||
|
||||
.e-breach-list {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-flow: row wrap;
|
||||
visibility: hidden;
|
||||
margin: auto;
|
||||
max-width: 780px;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.e-breach-list .breach-card {
|
||||
max-height: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.active .e-breach-list .breach-card {
|
||||
max-height: 10000px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.active .show-additional-breaches.hide .breach-card {
|
||||
display: none;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.show-remaining-breaches-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.show-remaining-breaches.btn-violet-secondary {
|
||||
margin: 16px auto auto;
|
||||
}
|
||||
|
||||
.show-remaining-breaches.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.email-card.active .e-breach-list {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
max-height: 1000000px;
|
||||
padding-top: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.email-card.zero-unresolved-breaches .e-breach-list,
|
||||
.email-card.active .hide-additional-breaches.hide .breach-card,
|
||||
.email-card.inactive .hide-additional-breaches.hide .breach-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.breach-card.resolved-breach-card {
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.email-card.zero-unresolved-breaches.show-resolved-breach-cards.active .e-breach-list,
|
||||
.show-resolved-breach-cards .breach-card.resolved-breach-card {
|
||||
visibility: visible;
|
||||
opacity: 0.6;
|
||||
display: flex !important;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.hide-resolved-message,
|
||||
.show-resolved-breach-cards span.show-resolved-message {
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.show-resolved-breach-cards span.hide-resolved-message {
|
||||
display: flex;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
button.toggle-resolved-breaches {
|
||||
margin: auto 0 auto auto;
|
||||
border: 0;
|
||||
font-size: 14px;
|
||||
color: #42435a;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.e-toggle-info-wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.svg-wrap {
|
||||
border-width: 0;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem 0 0;
|
||||
pointer-events: all;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* preferences buttons/options */
|
||||
.remove-email .x-close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.remove-email .x-close path {
|
||||
fill: var(--ink);
|
||||
}
|
||||
|
||||
.button-resend-wrapper {
|
||||
margin-top: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.change-primary-email,
|
||||
.remove-fxm,
|
||||
.resend-email {
|
||||
pointer-events: all;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.resend-email,
|
||||
.change-primary-email {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.resend-email {
|
||||
min-height: 0;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
border-width: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.sending {
|
||||
margin-left: 16px;
|
||||
font-size: 12px;
|
||||
background: #3fe1b0;
|
||||
color: rgb(255 255 255 / 1);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.change-primary-email:hover,
|
||||
.change-primary-email:focus,
|
||||
.toggle.svg-wrap:focus,
|
||||
.toggle.svg-wrap:hover,
|
||||
.resend-email:hover,
|
||||
.resend-email:focus {
|
||||
outline: none;
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
box-shadow: none;
|
||||
text-align: left;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
form.remove-email:hover svg path {
|
||||
fill: #cf434f;
|
||||
transition: fill 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
height: 100%;
|
||||
transition: all 0.1s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.show-email-breaches.toggle {
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
svg.toggle-down {
|
||||
border-radius: 50%;
|
||||
margin: auto;
|
||||
right: 4px;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.email-card .toggle,
|
||||
svg.toggle-down {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
svg.toggle-down path {
|
||||
fill: var(--grey6);
|
||||
}
|
||||
|
||||
.email-card.active .toggle-down {
|
||||
transform: rotate(180deg);
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
form.remove-email {
|
||||
pointer-events: all;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
form.remove-email,
|
||||
input.remove-email-submit {
|
||||
border: none !important;
|
||||
border-radius: 50% !important;
|
||||
height: 1.5rem !important;
|
||||
width: 1.5rem !important;
|
||||
position: relative;
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
form.remove-email svg path {
|
||||
fill: var(--alert-red);
|
||||
transition: fill 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
input.remove-email-submit {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1100px) {
|
||||
.e-address {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.e-num-breaches {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.e-info-content {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.email-card {
|
||||
padding: 20px;
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.email-card.breaches-dash:last-of-type,
|
||||
.email-card.breaches-dash {
|
||||
margin: 8px 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dash-attribution {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.e-info {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.breaches-dash:not(.zero-breaches) .e-address {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.e-num-breaches {
|
||||
margin-top: 4px;
|
||||
color: var(--grey8);
|
||||
}
|
||||
|
||||
.breach-card.resolved-breach-card {
|
||||
display: flex;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.toggle-resolved-breaches,
|
||||
.ec.breach-card,
|
||||
.active .show-additional-breaches.hide .ec.breach-card,
|
||||
.active .show-remaining-breaches.hide,
|
||||
.e-address::after,
|
||||
.show-remaining-breaches {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.email-card.zero-unresolved-breaches .e-breach-list,
|
||||
.email-card.zero-unresolved-breaches.active .e-breach-list,
|
||||
.active .show-remaining-breaches {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.toggle.show-email-breaches {
|
||||
pointer-events: all;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ec.breach-card {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.e-address,
|
||||
.radio-label {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.email-card.new-breaches {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.e-num-breaches,
|
||||
.resend-email,
|
||||
.change-primary-email {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.button-resend-wrapper,
|
||||
.e-num-breaches {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.change-primary-email {
|
||||
width: 100%;
|
||||
margin: var(--padding) 0 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.resend-email:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
|
@ -1,207 +0,0 @@
|
|||
.feature-tip-group {
|
||||
min-width: 96%;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gradient-container {
|
||||
padding-left: var(--padding);
|
||||
padding-right: var(--padding);
|
||||
}
|
||||
|
||||
.gradient-inset {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 12px 0 rgb(28 28 29 / 0.4);
|
||||
padding: calc(var(--padding) * 2) 0;
|
||||
background-color: rgb(255 255 255 / 0.95);
|
||||
}
|
||||
|
||||
.feature-tip-content-wrapper {
|
||||
max-width: 320px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.feature-tip-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feat-img {
|
||||
height: 4rem;
|
||||
width: 8rem;
|
||||
margin: var(--padding) auto;
|
||||
transition: all 0.2s ease;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.facebook-notes .feature-tip-content-wrapper:hover .feat-img,
|
||||
.breach-detail .feature-tip-content-wrapper:hover .feat-img {
|
||||
transform: scale(1.1);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.feat-img.advice {
|
||||
background-image: url('../../img/svg/pictogram-advice.svg');
|
||||
}
|
||||
|
||||
.feat-img.email {
|
||||
background-image: url('../../img/svg/pictogram-email.svg');
|
||||
}
|
||||
|
||||
.feat-img.alert {
|
||||
background-image: url('../../img/svg/pictogram-alert.svg');
|
||||
}
|
||||
|
||||
.feature-tip-headline {
|
||||
margin: auto auto var(--padding) auto;
|
||||
font-size: 32px;
|
||||
line-height: 1.13;
|
||||
color: #393473;
|
||||
}
|
||||
|
||||
.feature-tip-link,
|
||||
.feature-subhead {
|
||||
line-height: 1.5;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.feature-tip-link {
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
color: var(--ink);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.feature-subhead {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
color: #42425a;
|
||||
}
|
||||
|
||||
.cta2 {
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.facebook-notes .feature-tip-group,
|
||||
.breach-detail .feature-tip-group {
|
||||
margin-top: 40px;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.facebook-notes .feature-tip-content-wrapper,
|
||||
.breach-detail .feature-tip-content-wrapper {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
margin: 0 auto 40px 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.facebook-notes .feature-tip-content,
|
||||
.breach-detail .feature-tip-content {
|
||||
text-align: left;
|
||||
margin-left: 28px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.facebook-notes .feat-img,
|
||||
.breach-detail .feat-img {
|
||||
margin: 0;
|
||||
height: 56px;
|
||||
max-width: 56px;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.facebook-notes .feature-title,
|
||||
.breach-detail .feature-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.facebook-notes .feature-subhead,
|
||||
.breach-detail .feature-subhead {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.gradient-container .feature-button .feature-tip-content {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1100px) {
|
||||
.feature-tip-headline {
|
||||
max-width: 600px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.feature-tip-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feature-tip-content-wrapper {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
max-width: 600px;
|
||||
margin: var(--padding) auto;
|
||||
}
|
||||
|
||||
.feature-tip-content {
|
||||
text-align: left;
|
||||
max-width: 350px;
|
||||
margin-left: var(--padding);
|
||||
}
|
||||
|
||||
.feat-img {
|
||||
width: 7rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.feature-tip-content-wrapper {
|
||||
margin-bottom: var(--padding);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feature-tip-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feature-tip-content {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.feat-img {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.facebook-notes .feature-tip-content,
|
||||
.breach-detail .feature-tip-content {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recommendation-cta {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.facebook-notes .feature-tip-content-wrapper,
|
||||
.breach-detail .feature-tip-content-wrapper {
|
||||
margin: 0 auto 60px;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.overflow-recs .feature-tip-content-wrapper:last-of-type {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
.how-fxm-headline {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.how-fxm-works {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.about-fxm {
|
||||
color: white;
|
||||
background: var(--ink) url('../../img/landing/background-noodle-right.svg') no-repeat top right / auto 100%;
|
||||
}
|
||||
|
||||
.fx-monitor-svg {
|
||||
background-image: url('../../img/svg/fx-monitor.svg');
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom left;
|
||||
background-size: contain;
|
||||
width: 3rem;
|
||||
height: 6rem;
|
||||
margin-bottom: var(--padding);
|
||||
}
|
||||
|
||||
.fxm-card-wrap {
|
||||
flex: 1;
|
||||
padding: var(--padding);
|
||||
margin: 0 auto var(--padding) auto;
|
||||
}
|
||||
|
||||
.about-cta.btn-transparent.btn-small {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1100px) {
|
||||
.about-row {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.about-row .content-wrap {
|
||||
flex: 1 1 66.6667%;
|
||||
max-width: 66.6667%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.how-fxm-works {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.about-row .content-wrap {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
footer {
|
||||
width: 100%;
|
||||
margin: auto auto 0;
|
||||
background-color: var(--ink-dark);
|
||||
padding-top: 32px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: rgb(255 255 255 / 1);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer-link-wrapper {
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
.footer-link-wrapper.moz-link {
|
||||
margin: auto auto auto 0;
|
||||
}
|
||||
|
||||
svg.mozilla-logo {
|
||||
min-width: 124px;
|
||||
max-width: 124px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 990px) {
|
||||
.footer-link-wrapper {
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 860px) {
|
||||
footer ul.row-full-width {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer-link-wrapper,
|
||||
.footer-link {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.mozilla-logo-wrapper {
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
|
@ -1,360 +0,0 @@
|
|||
/* FORMS */
|
||||
|
||||
form,
|
||||
.form-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0 1000px rgb(255 255 255 / 0) inset;
|
||||
}
|
||||
|
||||
.form-group.loading-data .loader {
|
||||
display: block;
|
||||
opacity: 0.58;
|
||||
width: 25px;
|
||||
height: auto;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.form-desc {
|
||||
max-width: 260px;
|
||||
z-index: 1;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 12px auto;
|
||||
}
|
||||
|
||||
input[type='submit'],
|
||||
input[type='email'] {
|
||||
height: 48px;
|
||||
width: 100%;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.go-to-dash:focus,
|
||||
.close-modal-btn:focus,
|
||||
input[type='text']:focus,
|
||||
input[type='radio']:focus ~ .checkmark,
|
||||
input:focus,
|
||||
.radio-button-group:focus {
|
||||
border: 1px solid rgb(40 97 205);
|
||||
box-shadow: 0 0 0 1px rgb(63 97 217), 0 0 0 4px rgb(63 97 217 / 0.3);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.email-scan {
|
||||
flex-direction: column;
|
||||
max-width: 320px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.input-group,
|
||||
.input-group-button {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.form-group .error-message {
|
||||
display: block;
|
||||
background: rgb(215 0 34 / 1);
|
||||
border-radius: 3px;
|
||||
color: rgb(255 255 255 / 1);
|
||||
padding: 5px 12px;
|
||||
position: absolute;
|
||||
bottom: -22px;
|
||||
left: -1px;
|
||||
z-index: 1;
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-group .error-message::before {
|
||||
opacity: 0;
|
||||
background: rgb(215 0 34 / 1);
|
||||
top: -7px;
|
||||
content: '';
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
text-indent: -999px;
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
white-space: nowrap;
|
||||
width: 16px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.form-group.invalid .error-message,
|
||||
.form-group.invalid .error-message::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.form-group.invalid input[type='email'] {
|
||||
border: 1px solid var(--red50);
|
||||
box-shadow: 0 0 0 1px rgb(255 0 55 / 0.514), 0 0 0 4px rgb(255 0 55 / 0.3);
|
||||
}
|
||||
|
||||
.loader {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
margin: auto;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
.form-group.loading-data .button,
|
||||
.form-group.loading-data input::placeholder,
|
||||
.form-group.loading-data input {
|
||||
color: rgb(0 0 0 / 0) !important;
|
||||
}
|
||||
|
||||
input[type='email'] {
|
||||
font-size: 16px;
|
||||
text-indent: var(--padding);
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--grey90a3);
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
input[type='submit'] {
|
||||
font-size: 16px;
|
||||
border-width: 2px;
|
||||
background-color: var(--blue3);
|
||||
color: rgb(255 255 255 / 1);
|
||||
font-weight: 600;
|
||||
border-color: var(--blue3);
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
input[type='submit']:hover {
|
||||
background-color: var(--blue4);
|
||||
border-color: var(--blue4);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
input[type='submit']:active {
|
||||
background-color: var(--blue5);
|
||||
border-color: var(--blue5);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
form.invalid input[type='submit']:focus {
|
||||
border: 2px solid var(--red50);
|
||||
box-shadow: 0 0 0 1px rgb(255 0 55 / 0.514), 0 0 0 4px rgb(255 0 55 / 0.3);
|
||||
}
|
||||
|
||||
input.email-add {
|
||||
color: var(--ink);
|
||||
border-color: var(--grey90a3);
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
input.unsub {
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* radio buttons */
|
||||
|
||||
input[type='radio'] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border-radius: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
margin: auto;
|
||||
border: 1px solid var(--grey90a3);
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.radio-container:hover .checkmark::after {
|
||||
opacity: 1;
|
||||
background-color: rgb(12 12 13 / 0.04);
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
input[type='radio'] ~ .checkmark::after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
display: block;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
opacity: 0;
|
||||
flex: 1;
|
||||
border-radius: 100%;
|
||||
background-color: rgb(245 245 245 / 0);
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input[type='radio']:checked ~ .checkmark::after {
|
||||
opacity: 1;
|
||||
background-color: var(--violet3);
|
||||
transition: all 0.4s ease;
|
||||
}
|
||||
|
||||
input[type='radio']:checked:hover ~ .checkmark::after {
|
||||
opacity: 1;
|
||||
background-color: var(--violet3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input[type='radio']:focus ~ .checkmark {
|
||||
border: 1px solid var(--grey90a3);
|
||||
box-shadow: 0 0 0 1px rgb(12 12 13 / 0), 0 0 0 3px rgb(12 12 13 / 0.05);
|
||||
}
|
||||
|
||||
.add-new-email-form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.add-new-email-form .input-group {
|
||||
width: 100%;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.add-new-email-form .input-group-button {
|
||||
width: auto;
|
||||
max-width: auto;
|
||||
}
|
||||
|
||||
.add-new-email-form input::placeholder {
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
.dashboard-add-email .add-new-email-form {
|
||||
flex-direction: column;
|
||||
max-width: 320px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.radio-container input[type='radio'] {
|
||||
width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.radio-container {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.radio-container:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
padding-left: 32px;
|
||||
font-weight: 400;
|
||||
color: var(--grey8);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.create-fxa-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.create-fxa-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
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: rgb(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;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
button.internal-link,
|
||||
input.transparent-button,
|
||||
input[type='submit'],
|
||||
input::placeholder {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.add-new-email-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
|
@ -1,388 +0,0 @@
|
|||
firefox-apps {
|
||||
--background-color: rgb(249 249 250);
|
||||
--bento-hover-bg-color: rgb(97 90 115);
|
||||
--bento-click-bg-color: rgb(142 137 154);
|
||||
--bento-button-height: 25px;
|
||||
--z-index: 10000;
|
||||
--app-icon-height: 16px;
|
||||
--text-color: rgb(32 18 58);
|
||||
--bento-padding: 24px;
|
||||
|
||||
display: flex;
|
||||
margin: 0 32px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
z-index: var(--z-index);
|
||||
font-size: 12px;
|
||||
width: var(--bento-button-height);
|
||||
min-height: var(--bento-button-height);
|
||||
color: var(--text-color);
|
||||
background: url('../../img/fx-bento-sprites.png');
|
||||
background-position-x: -215.5px;
|
||||
background-position-x: -224px;
|
||||
background-size: auto var(--bento-button-height);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 4px rgb(255 255 255 / 0);
|
||||
}
|
||||
|
||||
firefox-apps.fx-bento-open,
|
||||
firefox-apps:hover {
|
||||
box-shadow: 0 0 0 4px var(--bento-hover-bg-color);
|
||||
background-color: var(--bento-hover-bg-color);
|
||||
}
|
||||
|
||||
firefox-apps:focus,
|
||||
firefox-apps:focus-within {
|
||||
box-shadow: 0 0 0 4px var(--bento-hover-bg-color);
|
||||
background-color: var(--bento-hover-bg-color);
|
||||
}
|
||||
|
||||
firefox-apps:active {
|
||||
box-shadow: 0 0 0 4px var(--bento-click-bg-color);
|
||||
background-color: var(--bento-click-bg-color);
|
||||
}
|
||||
|
||||
.fx-bento-hide-vpn .moz-vpn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fx-bento-hide-overflow {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.fx-bento-content-wrapper {
|
||||
position: absolute;
|
||||
top: calc(var(--bento-button-height) + 10px);
|
||||
min-width: 260px;
|
||||
z-index: calc(var(--z-index) + 1);
|
||||
box-shadow: 0 7px 12px -3px rgb(28 28 29 / 0.502);
|
||||
right: -18px;
|
||||
background: var(--background-color);
|
||||
border-radius: 8px;
|
||||
transform: translateY(0);
|
||||
display: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.fx-bento-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--bento-padding) 0 0 0;
|
||||
}
|
||||
|
||||
.fx-bento-enable-scrolling {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.active .fx-bento-content-wrapper {
|
||||
display: block;
|
||||
height: auto;
|
||||
transform: translateY(12px);
|
||||
animation: fxBentoAppear ease 0.3s;
|
||||
-webkit-animation: fxbentoappear ease 0.3s;
|
||||
-moz-animation: fxbentoappear ease 0.3s;
|
||||
}
|
||||
|
||||
.active .fx-bento-content-wrapper::after {
|
||||
display: block;
|
||||
content: '';
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: 23px;
|
||||
margin: auto;
|
||||
transform: rotate(45deg);
|
||||
border-top-left-radius: 1px;
|
||||
background-color: rgb(249 249 250);
|
||||
border-top: 1px solid transparent;
|
||||
border-left: 1px solid transparent;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.fx-bento-logo {
|
||||
background: url('../../img/fx-bento-sprites.png');
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
margin: auto auto 8px;
|
||||
background-position-x: -90px;
|
||||
}
|
||||
|
||||
.fx-bento-headline {
|
||||
font-size: 16px;
|
||||
font-family: Metropolis, sans-serif;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
max-width: 270px;
|
||||
display: block;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.fx-bento-bottom-link {
|
||||
color: rgb(0 96 223);
|
||||
text-decoration: underline;
|
||||
padding: 10px var(--bento-padding);
|
||||
margin: 14px auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a.fx-bento-app-link {
|
||||
background-color: rgb(255 255 255 / 0);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fx-bento-app-link-span {
|
||||
display: block;
|
||||
padding: 10px var(--app-icon-height) 10px calc(var(--app-icon-height) + 32px);
|
||||
color: var(--text-color);
|
||||
position: relative;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
max-width: 410px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fx-bento-app-link-span::before {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: var(--bento-padding);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
height: var(--app-icon-height);
|
||||
min-height: var(--app-icon-height);
|
||||
width: var(--app-icon-height);
|
||||
background: url('../../img/fx-bento-sprites.png');
|
||||
background-size: auto var(--app-icon-height);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.fx-bento-app-link-span.pocket::before {
|
||||
background-position-x: -18px;
|
||||
}
|
||||
|
||||
.fx-bento-app-link-span.fx-monitor::before {
|
||||
background-position-x: -88px;
|
||||
}
|
||||
|
||||
.fx-bento-app-link-span.fx-mobile::before {
|
||||
background-position-x: -104px;
|
||||
}
|
||||
|
||||
.fx-bento-app-link-span.moz-vpn::before {
|
||||
background: url('../../img/svg/logos/vpn-logo.svg') !important;
|
||||
background-position: center !important;
|
||||
background-size: var(--app-icon-height) !important;
|
||||
background-repeat: no-repeat !important;
|
||||
}
|
||||
|
||||
.fx-bento-link:hover,
|
||||
.fx-bento-link:focus {
|
||||
background-color: rgb(230 230 230);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.fx-bento-button {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.fx-bento-button:hover,
|
||||
.fx-bento-button:focus,
|
||||
.fx-bento-button:active {
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.fx-bento-button:hover::-moz-focus-inner,
|
||||
.fx-bento-button:focus::-moz-focus-inner {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
button.fx-bento-mobile-close {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: var(--app-icon-height);
|
||||
top: var(--app-icon-height);
|
||||
height: var(--bento-padding);
|
||||
min-height: var(--bento-padding);
|
||||
width: var(--bento-padding);
|
||||
max-width: var(--bento-padding);
|
||||
min-width: var(--bento-padding);
|
||||
background-color: rgb(55 54 111);
|
||||
background-image: url('../../img/fx-bento-sprites.png');
|
||||
background-size: auto 20px;
|
||||
background-repeat: no-repeat;
|
||||
background-position-x: -150.5px;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 50%;
|
||||
background-position-y: 2px;
|
||||
}
|
||||
|
||||
.fx-bento-app-link:first-of-type {
|
||||
margin-top: var(--app-icon-height);
|
||||
}
|
||||
|
||||
.fx-bento-fade-out {
|
||||
opacity: 0;
|
||||
transform: translateY(0) !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
a.fx-bento-link,
|
||||
a.fx-bento-link:hover,
|
||||
a.fx-bento-link:focus,
|
||||
.fx-bento-button,
|
||||
.fx-bento-button:hover,
|
||||
.fx-bento-button:focus,
|
||||
.fx-bento-fade-out,
|
||||
firefox-apps,
|
||||
firefox-apps:hover,
|
||||
firefox-apps:focus-within {
|
||||
transition: 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
body.hide-bento firefox-apps {
|
||||
background: none !important;
|
||||
visibility: hidden !important;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
body.hide-bento firefox-apps > button {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
firefox-apps {
|
||||
margin: 0 var(--bento-padding);
|
||||
}
|
||||
|
||||
.fx-bento-button {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
firefox-apps {
|
||||
--app-icon-height: var(--bento-padding);
|
||||
--mobile-clickable-close-area: 12vh;
|
||||
|
||||
position: inherit;
|
||||
margin: 0 var(--app-icon-height);
|
||||
}
|
||||
|
||||
button.fx-bento-mobile-close {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fx-bento-hide-overflow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: 100vh;
|
||||
overflow-y: scroll;
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
.fx-bento-mobile-close::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: var(--mobile-clickable-close-area);
|
||||
}
|
||||
|
||||
.fx-bento-content-wrapper {
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
border-radius: 0;
|
||||
transform: translateY(0) !important;
|
||||
}
|
||||
|
||||
.fx-bento-logo {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
min-height: 48px;
|
||||
margin-top: var(--mobile-clickable-close-area);
|
||||
background-position-x: -108px;
|
||||
}
|
||||
|
||||
.fx-bento-headline {
|
||||
font-size: 20px;
|
||||
line-height: var(--bento-padding);
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.fx-bento-bottom-link {
|
||||
margin-bottom: auto;
|
||||
margin-top: var(--bento-padding);
|
||||
}
|
||||
|
||||
.fx-bento-bottom-link,
|
||||
.fx-bento-app-link {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.active .fx-bento-content-wrapper {
|
||||
transform: translateY(0) !important;
|
||||
animation: none !important;
|
||||
-webkit-animation: none !important;
|
||||
}
|
||||
|
||||
.active .fx-bento-content-wrapper::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a.fx-bento-app-link {
|
||||
line-height: 23px;
|
||||
}
|
||||
|
||||
.fx-bento-app-link-span {
|
||||
max-width: 342px;
|
||||
padding-left: calc(var(--app-icon-height) + 40px);
|
||||
}
|
||||
|
||||
.fx-bento-app-link-span.pocket::before {
|
||||
background-position-x: -26px;
|
||||
}
|
||||
|
||||
.fx-bento-app-link-span.fx-monitor::before {
|
||||
background-position-x: -132px;
|
||||
}
|
||||
|
||||
.fx-bento-app-link-span.fx-mobile::before {
|
||||
background-position-x: -157px;
|
||||
margin-left: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fxbentoappear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
}
|
|
@ -1,532 +0,0 @@
|
|||
header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.desktop-menu:hover .active-link::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.active-link:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.recruitment-banner,
|
||||
.csat-banner {
|
||||
background-color: var(--violet1);
|
||||
padding: 12px 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recruitment-banner a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.csat-banner p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.csat-banner label {
|
||||
margin: 6px 3px 0;
|
||||
color: #56198f;
|
||||
border-color: #7639af;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.5s ease-out;
|
||||
}
|
||||
|
||||
.csat-banner[disabled] label,
|
||||
.csat-banner[hidden] label {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.csat-banner label.selected {
|
||||
opacity: 1;
|
||||
background-color: #d89eff;
|
||||
}
|
||||
|
||||
.csat-banner label input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.csat-banner [name='csat-close-btn'] {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.csat-banner button img {
|
||||
pointer-events: none;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.nps-bookend {
|
||||
display: inline-block;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
#navigation-wrapper {
|
||||
background-color: var(--ink-dark);
|
||||
box-shadow: 0 0 5px 0 #20123a1c;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.show-shadow #navigation-wrapper {
|
||||
box-shadow: 0 5px 10px -5px black;
|
||||
}
|
||||
|
||||
.show-nav-bars {
|
||||
transform: translateY(0);
|
||||
-webkit-transform: translateY(0);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: all;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hide-nav-bars {
|
||||
transform: translateY(-20px);
|
||||
-webkit-transform: translateY(-20px);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sign-in.btn-white {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fxm-branding {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fx-monitor-logo-wrapper,
|
||||
.bento-sign-up {
|
||||
flex: 1 1 30%;
|
||||
}
|
||||
|
||||
/* Fx-Monitor logo */
|
||||
.sprite.fx-monitor-logo {
|
||||
background: url('../../img/fx-bento-sprites.png') -220px 0;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.fx-monitor-logotype {
|
||||
background: url('../../img/svg/fx-monitor-logotype.svg');
|
||||
width: 100%;
|
||||
max-width: 212px;
|
||||
margin-left: 8px;
|
||||
background-position: left;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* navigation */
|
||||
nav {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
background-color: rgb(255 255 255 / 0);
|
||||
padding-right: calc(16px + 1vw);
|
||||
padding-left: calc(16px + 1vw);
|
||||
justify-content: center;
|
||||
color: rgb(255 255 255 / 1);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav-link span {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.active-link {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-link span::after {
|
||||
height: 2px;
|
||||
background: var(--monitor-gradient);
|
||||
opacity: 0;
|
||||
display: block;
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 0;
|
||||
margin: auto;
|
||||
border-radius: 5px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.active-link span::after,
|
||||
.active-link-underline span::after {
|
||||
width: 100%;
|
||||
opacity: 1 !important;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.drop-down-menu,
|
||||
.mobile-menu .active-link-underline::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.signed-in-as-wrap {
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.signed-in-as-wrap::after {
|
||||
content: '';
|
||||
height: 1.5px;
|
||||
background: var(--monitor-gradient);
|
||||
width: 100%;
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.signed-in-as {
|
||||
padding: 20px;
|
||||
color: rgb(32 18 58);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-user-email {
|
||||
display: block;
|
||||
color: rgb(32 18 58);
|
||||
font-weight: 600;
|
||||
padding-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* signed-in user avatar */
|
||||
.avatar-wrapper {
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 1px #fff0, 0 0 0 3px #4f42ff00;
|
||||
border: 2px solid rgb(255 255 255 / 1);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.avatar-wrapper:hover {
|
||||
box-shadow: 0 0 0 2px rgb(63 96 217 / 0.8), 0 0 0 6px rgb(63 97 217 / 0.2);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.avatar-wrapper:focus,
|
||||
.avatar-wrapper:focus-within {
|
||||
border: 2px solid rgb(255 255 255 / 1);
|
||||
box-shadow: 0 0 0 2px rgb(63 97 217), 0 0 0 4px rgb(63 97 217 / 0.3);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fxa-menu-wrapper {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
img.avatar,
|
||||
.fxa-menu-wrapper,
|
||||
.fxa-menu {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.avatar-wrapper,
|
||||
.avatar {
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
/* signed-in user menu */
|
||||
.fxa-menu {
|
||||
padding-top: 3.25rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -4px;
|
||||
z-index: 2;
|
||||
pointer-events: none !important;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.fxa-menu-links {
|
||||
background-color: rgb(249 249 250);
|
||||
box-shadow: 0 7px 12px -3px rgb(28 28 29 / 0.502);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
padding-bottom: 8px;
|
||||
visibility: hidden;
|
||||
border-radius: 8px;
|
||||
transform: translateY(0);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.fxa-menu-links::before {
|
||||
border-top-left-radius: 1px;
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: 20px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
background-color: rgb(249 249 250);
|
||||
opacity: 1;
|
||||
margin: auto;
|
||||
z-index: 2;
|
||||
transform: rotate(45deg);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.menu-open .fxa-menu-links::before {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.menu-open .fxa-menu-links,
|
||||
.avatar-wrapper:hover .fxa-menu-links {
|
||||
display: flex;
|
||||
visibility: visible;
|
||||
transform: translateY(8px);
|
||||
animation: fxaMenuAppear ease 0.3s;
|
||||
-webkit-animation: fxamenuappear ease 0.3s;
|
||||
-moz-animation: fxamenuappear ease 0.3s;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.menu-open .fxa-menu-links {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.fxa-menu-link {
|
||||
min-width: 280px;
|
||||
padding: 10px 20px;
|
||||
color: rgb(32 18 58);
|
||||
font-size: 12px;
|
||||
background-color: rgb(255 255 255 / 0);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.fxa-menu-link:focus,
|
||||
.fxa-menu-link:active {
|
||||
background-color: rgb(238 238 238 / 0.502);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.fxa-menu-link:hover {
|
||||
background-color: rgb(155 155 165 / 0.15);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1200px) {
|
||||
.bento-sign-up {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.desktop-menu {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.bento-sign-up {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.desktop-menu {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.fx-monitor-logotype {
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
.desktop-menu .nav-link,
|
||||
.active-link span::after,
|
||||
.active-link-underline span::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
visibility: visible;
|
||||
background-color: var(--ink);
|
||||
box-shadow: 0 2px 2px -1px #0202024d;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
flex-flow: column wrap;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-menu a.nav-link {
|
||||
color: rgb(255 255 255 / 0.9);
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mobile-menu .nav-link:hover {
|
||||
background-color: var(--ink);
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
margin: auto;
|
||||
border-radius: 0;
|
||||
flex-flow: column wrap;
|
||||
visibility: hidden;
|
||||
padding: 0 1.75rem;
|
||||
max-height: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--ink-light);
|
||||
z-index: 0;
|
||||
color: rgb(255 255 255 / 0);
|
||||
-webkit-user-select: none;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-open .mobile-menu {
|
||||
top: 3rem;
|
||||
max-height: 1000px;
|
||||
visibility: visible;
|
||||
color: rgb(255 255 255 / 1);
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-link.drop-down-menu,
|
||||
.mobile-menu-open .nav-link {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-link.drop-down-menu {
|
||||
-webkit-user-select: none;
|
||||
pointer-events: none;
|
||||
padding: 1rem 2.75rem;
|
||||
background-color: var(--ink-light);
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
border-bottom: 1px solid var(--ink-light);
|
||||
transition: border-bottom 0.15s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-open .drop-down-menu {
|
||||
border-bottom: 1px solid var(--ink);
|
||||
transition: border-bottom 0.15s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-open svg.toggle-down {
|
||||
transform: rotate(180deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.drop-down-menu svg.toggle-down {
|
||||
opacity: 0.9;
|
||||
right: 2.75rem;
|
||||
}
|
||||
|
||||
.drop-down-menu .toggle-down path {
|
||||
fill: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding-left: 2.75rem;
|
||||
padding-right: 2.75rem;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
header,
|
||||
header.show-shadow {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#navigation-wrapper {
|
||||
background-color: var(--ink-dark);
|
||||
}
|
||||
|
||||
.drop-down-menu svg.toggle-down {
|
||||
right: 2rem;
|
||||
}
|
||||
|
||||
.hide-nav-bars .mobile-nav {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.show-nav-bars .mobile-nav {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.fxa-menu-link,
|
||||
.nav-user-email {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.fx-monitor-logo-wrapper {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.nav-link.drop-down-menu,
|
||||
.mobile-menu-open .nav-link {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-link.drop-down-menu {
|
||||
padding: 16px 25px;
|
||||
}
|
||||
|
||||
.fxa-menu-link {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
.fx-monitor-logotype {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fxamenuappear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
.latest-breach-info,
|
||||
.latest-breach {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.latest-breach-info {
|
||||
margin-top: -32px;
|
||||
}
|
||||
|
||||
.breach-card.latest-breach {
|
||||
box-shadow: 0 12px 12px -3px #1c1c1d80;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border: 0;
|
||||
padding: 20px 40px 24px;
|
||||
}
|
||||
|
||||
.breach-card.latest-breach:hover {
|
||||
transform: scale(1); /* prevent lower half of card from scaling up */
|
||||
}
|
||||
|
||||
.latest-breach .breach-logo-wrapper {
|
||||
max-width: 48px;
|
||||
margin: 0 20px 0 0;
|
||||
}
|
||||
|
||||
.latest-breach .breach-logo {
|
||||
max-height: 48px;
|
||||
max-width: 48px;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.latest-breach .breach-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.lb-info .breach-key,
|
||||
.lb-info .breach-value {
|
||||
display: inline !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.lb-info {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.latest-breach-headline,
|
||||
.latest-breach {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.latest-breach-headline {
|
||||
color: var(--purple6);
|
||||
background-color: rgb(255 255 255 / 0.95);
|
||||
border-top-right-radius: 8px;
|
||||
border-top-left-radius: 8px;
|
||||
font-weight: 600;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px 12px;
|
||||
box-shadow: 0 7px 12px 0 #1c1c1d80;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.latest-breach-info {
|
||||
padding: 0 calc(var(--padding) + 0.75rem);
|
||||
}
|
||||
|
||||
.breach-card.latest-breach {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.latest-breach .breach-logo-wrapper {
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.latest-breach .breach-logo {
|
||||
max-width: 36px;
|
||||
max-height: 36px;
|
||||
width: 36px;
|
||||
}
|
||||
}
|
|
@ -1,710 +0,0 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.show-mobile {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f9f7fd;
|
||||
scroll-behavior: smooth;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 15px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.fxa-signup-gradient {
|
||||
background: var(--fxa-sign-up-gradient);
|
||||
}
|
||||
|
||||
.take-back-control-gradient {
|
||||
background: var(--take-back-control-gradient);
|
||||
}
|
||||
|
||||
.fx-monitor {
|
||||
color: var(--grey1);
|
||||
}
|
||||
|
||||
p,
|
||||
.paragraph {
|
||||
font-size: var(--paragraph-font-size);
|
||||
}
|
||||
|
||||
.medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.demi {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.overflow-break {
|
||||
max-width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.desc {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.ff-met {
|
||||
font-family: Metropolis, sans-serif;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: Metropolis, sans-serif;
|
||||
}
|
||||
|
||||
h3,
|
||||
.nav-link,
|
||||
button,
|
||||
input[type='submit'] {
|
||||
font-family: Metropolis, sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
.section-headline {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.section-headline {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.small-section-headline {
|
||||
font-size: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.top-headline {
|
||||
font-size: var(--top-headline);
|
||||
font-weight: 700;
|
||||
line-height: 1.14;
|
||||
}
|
||||
|
||||
.mw-360 {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.mw-500 {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.mw-550 {
|
||||
max-width: 550px;
|
||||
}
|
||||
|
||||
.mw-860 {
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
padding-left: 36px;
|
||||
padding-right: 36px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-size: var(--headline);
|
||||
margin: 0 auto 0.55rem 0;
|
||||
}
|
||||
|
||||
.subhead {
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
max-width: 600px;
|
||||
padding-right: 5%;
|
||||
font-size: 16px;
|
||||
margin: 0 auto var(--padding) 0;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button.text-link {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.blue-link {
|
||||
color: var(--blue3);
|
||||
font-family: Metropolis, sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-link:hover,
|
||||
.blue-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--blue4);
|
||||
}
|
||||
|
||||
.blue-link:active {
|
||||
color: var(--blue5);
|
||||
}
|
||||
|
||||
div:focus,
|
||||
input:focus {
|
||||
border: 2px solid #4f42ff14;
|
||||
box-shadow: 0 0 0 1px #4f42ff6e, 0 0 0 4px #4f42ff1f;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
a:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.btn-white {
|
||||
border-color: rgb(255 255 255 / 1);
|
||||
color: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
.btn-white:hover,
|
||||
.btn-white:focus {
|
||||
color: #cfcfd8;
|
||||
border-color: #cfcfd8;
|
||||
}
|
||||
|
||||
.btn-white:active {
|
||||
color: #9f9fad;
|
||||
border-color: #9f9fad;
|
||||
}
|
||||
|
||||
.btn-red {
|
||||
border-color: var(--alert-red);
|
||||
color: var(--alert-red);
|
||||
}
|
||||
|
||||
.btn-red:hover {
|
||||
border-color: #cf434f;
|
||||
color: #cf434f;
|
||||
}
|
||||
|
||||
.btn-violet-secondary {
|
||||
color: var(--violet4);
|
||||
border-color: var(--violet4);
|
||||
}
|
||||
|
||||
.btn-violet-secondary:hover,
|
||||
.btn-violet-secondary:focus {
|
||||
color: var(--violet5);
|
||||
border-color: var(--violet5);
|
||||
}
|
||||
|
||||
.btn-violet-secondary:active {
|
||||
color: var(--violet6);
|
||||
border-color: var(--violet6);
|
||||
}
|
||||
|
||||
.btn-blue-primary:active,
|
||||
.btn-transparent:active,
|
||||
.btn-violet-primary:active,
|
||||
input[type='submit']:active {
|
||||
box-shadow:
|
||||
0 2px 8px 0 rgb(14 13 26 / 0.12),
|
||||
0 3px 1px -2px rgb(7 48 114 / 0.12),
|
||||
0 2px 4px 0 rgb(34 0 51 / 0.04),
|
||||
0 0 0 1px rgb(32 18 58 / 0.04);
|
||||
}
|
||||
|
||||
button.btn-ghost {
|
||||
border: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
min-height: 48px;
|
||||
align-self: center;
|
||||
color: var(--purple7);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.btn-blue-primary,
|
||||
button.btn-violet-primary {
|
||||
border-radius: 8px;
|
||||
min-height: 48px;
|
||||
font-size: 16px;
|
||||
max-width: 320px;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
color: rgb(255 255 255 / 1);
|
||||
padding-right: var(--padding);
|
||||
padding-left: var(--padding);
|
||||
}
|
||||
|
||||
.btn-blue-primary {
|
||||
border-radius: 4px;
|
||||
background-color: #0060df;
|
||||
border: 1px solid #0060df;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.btn-blue-primary:hover {
|
||||
background-color: var(--blue4);
|
||||
border-color: var(--blue4);
|
||||
}
|
||||
|
||||
.btn-violet-primary {
|
||||
background-color: var(--violet4);
|
||||
border: 1px solid var(--violet4);
|
||||
}
|
||||
|
||||
.btn-violet-primary:hover {
|
||||
background-color: var(--violet5);
|
||||
border-color: var(--violet5);
|
||||
}
|
||||
|
||||
.btn-violet-primary:active {
|
||||
background-color: var(--violet6);
|
||||
border-color: var(--violet6);
|
||||
}
|
||||
|
||||
.btn-transparent {
|
||||
display: inline-flex;
|
||||
align-content: center;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
font-family: Metropolis, sans-serif;
|
||||
font-weight: 600;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
a.btn-transparent:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.btn-big {
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
min-height: 40px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
a:hover,
|
||||
button:hover,
|
||||
a:focus,
|
||||
button:focus,
|
||||
a:active,
|
||||
button:active {
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
ul li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
padding-top: calc(var(--padding) * 2);
|
||||
padding-bottom: calc(var(--padding) * 2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.row {
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
max-width: var(--max-width);
|
||||
}
|
||||
|
||||
.row-full-width {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 20px 36px;
|
||||
}
|
||||
|
||||
.row,
|
||||
.row-full-width {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.container,
|
||||
.row-full-width,
|
||||
.row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container,
|
||||
.row-full-width,
|
||||
.row,
|
||||
.col-6,
|
||||
.col-12,
|
||||
.flx,
|
||||
.flx-cntr,
|
||||
.flx-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cntr {
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flx-cntr {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flx-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flx-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flx-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.flx-auto {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.space-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.margin-zero {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.col-6 {
|
||||
flex: 1 1 50%;
|
||||
max-width: 50%;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.col-8 {
|
||||
flex: 1 1 66.6667%;
|
||||
min-width: 66.6667%;
|
||||
max-width: 66.6667%;
|
||||
}
|
||||
|
||||
.mw-700 {
|
||||
padding: 0 24px;
|
||||
max-width: 744px;
|
||||
width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.col-9 {
|
||||
flex: 1 1 75%;
|
||||
min-width: 75%;
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.col-12 {
|
||||
flex: 1 1 calc(100% - var(--padding));
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
padding: var(--padding);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.col-6,
|
||||
.col-8,
|
||||
.col-9 {
|
||||
padding: var(--padding);
|
||||
flex-direction: column;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.padding-top-zero {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.no-vertical-padding {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.col {
|
||||
flex-direction: column;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.txt-light {
|
||||
color: var(--grey8);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.txt-cntr {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.txt-purple7 {
|
||||
color: var(--purple7);
|
||||
}
|
||||
|
||||
.jst-cntr {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
.sr {
|
||||
background-image: url('../../img/svg/scan-res-bg.svg');
|
||||
background-position: top center;
|
||||
background-attachment: fixed;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bg-light {
|
||||
background-color: #f9f7fd;
|
||||
}
|
||||
|
||||
.bg-dark {
|
||||
background-color: var(--ink-dark);
|
||||
color: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
.drop-shadow {
|
||||
box-shadow: 0 0 1px 1px #607d8b08, 1px 1px 10px #4341571a;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.drop-shadow:hover {
|
||||
box-shadow: 0 0 1px 1px #607d8b12, 1px 1px 12px #4341574d;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.source-info {
|
||||
display: block;
|
||||
max-width: 320px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.source-info a,
|
||||
.source-info {
|
||||
color: var(--text-light);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.source-info a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-info a:hover {
|
||||
color: var(--grey8);
|
||||
}
|
||||
|
||||
.source-info a:active {
|
||||
color: var(--grey9);
|
||||
}
|
||||
|
||||
/* SPRITES */
|
||||
div.sprite {
|
||||
background-repeat: no-repeat !important;
|
||||
background-size: cover !important;
|
||||
}
|
||||
|
||||
.title-wrapper {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
width: 1.5rem;
|
||||
min-height: 10px;
|
||||
margin-right: 0.5rem;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.arrow-head-right {
|
||||
margin-left: 4px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.show {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: opacity 0.1s ease, visibility 0.1s ease 0.2s, max-height 0.1s ease 0.2s, padding 0.1s ease 0.2s;
|
||||
}
|
||||
|
||||
.hide {
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.1s ease, visibility 0.1s ease 0.2s, max-height 0.1s ease 0.2s, padding 0.1s ease 0.2s;
|
||||
}
|
||||
|
||||
.hide > * {
|
||||
padding: 0;
|
||||
max-height: 0;
|
||||
visibility: hidden;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
.clear-header {
|
||||
padding-top: calc(var(--header-height) + 80px);
|
||||
}
|
||||
|
||||
.toggle-parent.active .toggle svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toggle-parent.inactive .toggle-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.footer-link:hover {
|
||||
color: #cfcfd8;
|
||||
}
|
||||
|
||||
.nav-link:active,
|
||||
.footer-link:active {
|
||||
color: #9f9fad;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1200px) {
|
||||
body {
|
||||
--max-width: 1000px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
body {
|
||||
--max-width: 600px;
|
||||
--top-headline: 43px;
|
||||
--headline: 36px;
|
||||
}
|
||||
|
||||
.hide-mobile {
|
||||
display: none !important;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.show-mobile {
|
||||
display: flex;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mw-700 {
|
||||
max-width: var(--max-width);
|
||||
}
|
||||
|
||||
.col-6 {
|
||||
max-width: 100%;
|
||||
padding: 0 var(--padding);
|
||||
}
|
||||
|
||||
.col-8,
|
||||
.col-9 {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.mw-860 {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row-9 {
|
||||
max-width: 75%;
|
||||
width: 75%;
|
||||
display: flex;
|
||||
padding: 0 var(--padding);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.col-6,
|
||||
.col-12 {
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row-full-width {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.mw-860 {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.arrow-head-right {
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
body {
|
||||
--top-headline: 36px;
|
||||
--headline: 32px;
|
||||
}
|
||||
|
||||
.col-6 {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
body.bento-open {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.subhead {
|
||||
padding-right: 3%;
|
||||
}
|
||||
}
|
|
@ -1,361 +0,0 @@
|
|||
.monitor-homepage {
|
||||
min-height: 90vh;
|
||||
background-color: var(--ink-dark);
|
||||
position: relative;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.featured-breach h2.landing-headline {
|
||||
font-size: 40px;
|
||||
max-width: 600px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.monitor-landing {
|
||||
background-image: url('../../img/landing/background-noodle-top2.svg');
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% auto;
|
||||
background-position: bottom center;
|
||||
padding: 2rem 0.75rem;
|
||||
}
|
||||
|
||||
.landing-bottom-bar {
|
||||
background-image: url('../../img/landing/background-noodle-middle.svg'), linear-gradient(224deg, #2150c8, #712291);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% auto;
|
||||
background-position: top center;
|
||||
position: relative;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.landing-bottom-bar .take-back-control-banner {
|
||||
margin: 88px auto 40px;
|
||||
}
|
||||
|
||||
/* sensitive featured breach */
|
||||
|
||||
.landing-bottom-bar.sensitive-breach,
|
||||
.gradient-container.sensitive-breach,
|
||||
.row.sensitive-breach {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.sb-top-wrapper.row {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: var(--max-width);
|
||||
z-index: 1;
|
||||
margin-top: -32px;
|
||||
}
|
||||
|
||||
.sb-top {
|
||||
background-color: rgb(255 255 255 / 0.95);
|
||||
border-top-right-radius: 8px;
|
||||
border-top-left-radius: 8px;
|
||||
min-height: 4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sensitive-breach .gradient-inset {
|
||||
border-top-right-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
.landing-sensitive-info {
|
||||
color: var(--grey1);
|
||||
text-align: left;
|
||||
max-width: 800px;
|
||||
max-width: var(--max-width);
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
.sb-callout-body {
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
color: var(--grey2);
|
||||
}
|
||||
|
||||
.sensitive-breach .feat-headline-wrapper {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
h3.sb-callout {
|
||||
font-size: 24px;
|
||||
margin: 3rem auto 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sb-callout,
|
||||
.sb-callout-body {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.landing-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing-content .form-desc {
|
||||
color: var(--grey2);
|
||||
margin-bottom: calc(var(--padding) * 3);
|
||||
}
|
||||
|
||||
.landing-subhead {
|
||||
padding: 0;
|
||||
margin: 0 auto calc(var(--padding) * 2) auto;
|
||||
max-width: 560px;
|
||||
font-size: 24px;
|
||||
line-height: 1.17;
|
||||
}
|
||||
|
||||
.landing-headline,
|
||||
.landing-subhead {
|
||||
color: var(--grey1);
|
||||
}
|
||||
|
||||
.landing-headline {
|
||||
margin: auto auto 22px;
|
||||
max-width: 750px;
|
||||
}
|
||||
|
||||
.landing-content form {
|
||||
max-width: 320px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.landing-headline .bold {
|
||||
color: var(--violet2);
|
||||
}
|
||||
|
||||
[data-ad-unit] {
|
||||
width: min(960px, calc(100% - 60px));
|
||||
margin: 80px auto;
|
||||
text-align: center;
|
||||
color: black;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
[data-ad-unit] > * + * {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
[data-ad-unit].dark {
|
||||
color: white;
|
||||
}
|
||||
|
||||
[data-ad-unit].light {
|
||||
color: black;
|
||||
}
|
||||
|
||||
[data-ad-unit] h2 {
|
||||
color: inherit;
|
||||
font-size: 32px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-ad-unit] video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
[data-ad-unit='3'] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
padding: 30px;
|
||||
background-color: #6e008b;
|
||||
}
|
||||
|
||||
[data-ad-unit='3'] > article {
|
||||
flex: 0 1 360px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
[data-ad-unit='3'] > article h2 {
|
||||
font-size: 70px;
|
||||
line-height: 1;
|
||||
color: white;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-ad-unit='3'] > article h2::first-line {
|
||||
font-size: 100px;
|
||||
}
|
||||
|
||||
[data-ad-unit='3'] > article p {
|
||||
color: white;
|
||||
}
|
||||
|
||||
[data-ad-unit='3'] > figure {
|
||||
flex: 1 1 360px;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
[data-ad-unit='3'] > figure img {
|
||||
object-fit: contain;
|
||||
height: 100%;
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
[data-ad-unit='3'] > figure i {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 50%;
|
||||
padding: 12px;
|
||||
font-size: min(2vw, 13px);
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
color: black;
|
||||
background-color: #c688ff;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-ad-unit='3'] > figure i:nth-of-type(1) {
|
||||
top: 10px;
|
||||
box-shadow: 0 0 24px #6e008b;
|
||||
width: clamp(15%, 140px, 30%);
|
||||
}
|
||||
|
||||
[data-ad-unit='3'] > figure i:nth-of-type(2) {
|
||||
bottom: 20px;
|
||||
box-shadow: 0 0 12px #6e008b;
|
||||
width: clamp(15%, 120px, 25%);
|
||||
}
|
||||
|
||||
[data-ad-unit='3'] > figure i:nth-of-type(3) {
|
||||
right: 0;
|
||||
top: 30px;
|
||||
box-shadow: 0 0 6px #6e008b;
|
||||
width: clamp(15%, 140px, 35%);
|
||||
}
|
||||
|
||||
[data-ad-unit='4'],
|
||||
[data-ad-unit='6'] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
padding: 30px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
[data-ad-unit='4'] > *,
|
||||
[data-ad-unit='6'] > * {
|
||||
flex: 1 1 325px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
[data-ad-unit='4'] article,
|
||||
[data-ad-unit='6'] article {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-ad-unit='4'] figure img,
|
||||
[data-ad-unit='6'] figure img {
|
||||
width: min(100%, 400px);
|
||||
}
|
||||
|
||||
[data-ad-unit='5'] {
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
[data-ad-unit='5'] section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
[data-ad-unit='5'] section > * {
|
||||
flex-grow: 1;
|
||||
flex-basis: calc((700px - 100%) * 999);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-ad-unit='5'] figure img {
|
||||
max-width: 100%;
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
.ad-unit-cta {
|
||||
font-weight: bold;
|
||||
color: black;
|
||||
background-color: #c688ff;
|
||||
border: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.landing-content {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.monitor-homepage.featured-breach {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.featured-breach h2.landing-headline,
|
||||
h2.landing-headline,
|
||||
.landing-subhead {
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
.landing-subhead {
|
||||
font-size: 20px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.landing-content form {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[data-ad-unit='4'] article,
|
||||
[data-ad-unit='6'] article {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.monitor-homepage {
|
||||
min-height: 86vh;
|
||||
}
|
||||
|
||||
.landing-bottom-bar .take-back-control-banner {
|
||||
margin: 68px auto 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.landing-content {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.landing-content form {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.landing-subhead {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.landing-subhead,
|
||||
.landing-headline {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.featured-breach h2.landing-headline {
|
||||
text-align: center;
|
||||
font-size: var(--top-headline);
|
||||
}
|
||||
}
|
|
@ -1,171 +0,0 @@
|
|||
.product-promo {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
padding: 32px 42px;
|
||||
color: rgb(255 255 255 / 1);
|
||||
text-align: left;
|
||||
background-position: 104%;
|
||||
background-size: auto 104%;
|
||||
background-repeat: no-repeat;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.promo-copy {
|
||||
padding-right: 13%;
|
||||
}
|
||||
|
||||
.promo-monitor {
|
||||
background-color: #393473;
|
||||
background-image: url('../../img/svg/promos/promo-bg-monitor.svg');
|
||||
}
|
||||
|
||||
.promo-mobile {
|
||||
background-color: #123474;
|
||||
background-image: url('../../img/svg/promos/promo-bg-mobile.svg');
|
||||
}
|
||||
|
||||
/* QR Code */
|
||||
.product-promo.promo-mobile::after {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
background-image: url('../../img/svg/promos/mobile-qr-gradient.svg');
|
||||
content: '';
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
position: absolute;
|
||||
right: 42px;
|
||||
bottom: 32px;
|
||||
background-size: 90%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
.promo-lockwise {
|
||||
background-image: url('../../img/svg/promos/promo-bg-lockwise.svg');
|
||||
}
|
||||
|
||||
.promo-fpn {
|
||||
background-image: url('../../img/svg/promos/promo-bg-fpn.svg');
|
||||
}
|
||||
|
||||
.promo-ecosystem {
|
||||
background-image: url('../../img/svg/promos/promo-bg-ecosystem.svg');
|
||||
}
|
||||
|
||||
.promo-lockwise,
|
||||
.promo-fpn,
|
||||
.promo-ecosystem {
|
||||
background-color: #1e1338;
|
||||
}
|
||||
|
||||
.promo-monitor > .promo-content::before {
|
||||
background-image: url('../../img/svg/fxa-tout-yellow-env.svg');
|
||||
}
|
||||
|
||||
.promo-mobile > .promo-content::before {
|
||||
background-image: url('../../img/svg/logos/fx-logo.svg');
|
||||
}
|
||||
|
||||
.promo-lockwise > .promo-content::before {
|
||||
background-image: url('../../img/svg/logos/fx-lockwise.svg');
|
||||
}
|
||||
|
||||
.promo-fpn > .promo-content::before {
|
||||
background-image: url('../../img/svg/logos/fpn-logo.svg');
|
||||
}
|
||||
|
||||
.promo-ecosystem > .promo-content::before {
|
||||
background-image: url('../../img/svg/logos/fx-master-logo.svg');
|
||||
}
|
||||
|
||||
.promo-content::before {
|
||||
margin-right: 36px;
|
||||
min-width: 66px;
|
||||
min-height: 66px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: top center;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.promo-headline {
|
||||
font-size: 24px;
|
||||
line-height: 1.17;
|
||||
margin: 0 auto 8px 0;
|
||||
}
|
||||
|
||||
.promo-body {
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.promo-cta {
|
||||
border-color: rgb(255 255 255 / 1);
|
||||
border-radius: 8px;
|
||||
min-height: 40px;
|
||||
color: rgb(255 255 255 / 1);
|
||||
font-size: 16px;
|
||||
margin: auto auto auto 0;
|
||||
padding: 12px 32px;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.promo-cta:hover {
|
||||
background-color: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
.promo-wrapper {
|
||||
margin-top: 44px;
|
||||
margin-bottom: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.product-promo.drop-shadow.flx.al-cntr.jst-cntr.promo-mobile::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.promo-wrapper {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.product-promo {
|
||||
background-image: none !important;
|
||||
padding: 42px;
|
||||
}
|
||||
|
||||
.promo-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.promo-body {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.promo-copy {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.promo-content::before {
|
||||
margin: auto auto 16px;
|
||||
}
|
||||
|
||||
.promo-headline {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.product-promo {
|
||||
padding: 42px 24px;
|
||||
}
|
||||
|
||||
.promo-headline {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
|
@ -1,166 +0,0 @@
|
|||
.mw-860.found-breaches {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
flex-direction: row;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.headline-col {
|
||||
text-align: center;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.scanned-email-address {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
font-family: Metropolis, sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-info-wrap {
|
||||
margin-top: 16px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.scan-res-subhead,
|
||||
.scanned-email-address,
|
||||
.scan-results-headline {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scan-results-headline {
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.found-breaches,
|
||||
.show-additional-breaches {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.show-additional-breaches-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row.scan-results,
|
||||
.user-found-breaches {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.scan-res-subhead {
|
||||
margin: var(--padding) auto auto auto;
|
||||
}
|
||||
|
||||
.scanned-email-address .bold,
|
||||
.scan-results-headline span.bold,
|
||||
.scan-res-subhead span.bold {
|
||||
color: var(--violet3);
|
||||
}
|
||||
|
||||
.temp-marketing-img {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.temp-marketing-hl {
|
||||
color: #20123a;
|
||||
font-size: 24px;
|
||||
line-height: 1.13;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.temp-marketing-p {
|
||||
color: var(--grey8);
|
||||
line-height: 1.5;
|
||||
margin: 0 auto 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.temp-marketing-callout {
|
||||
margin: 20px auto 40px;
|
||||
}
|
||||
|
||||
.temp-marketing-callout span {
|
||||
color: var(--purple7);
|
||||
}
|
||||
|
||||
.temp-marketing-btn-blue {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
max-width: 320px;
|
||||
font-weight: 600;
|
||||
background-color: var(--blue3);
|
||||
border: none;
|
||||
width: 100%;
|
||||
color: rgb(255 255 255 / 1);
|
||||
padding-right: var(--padding);
|
||||
padding-left: var(--padding);
|
||||
min-height: 48px;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.temp-marketing-btn-blue.button-top {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.temp-marketing-btn-blue:hover {
|
||||
background-color: var(--blue4);
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.scanned-email-address,
|
||||
.scan-results-headline,
|
||||
.scan-res-subhead {
|
||||
text-align: left;
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.headline-col {
|
||||
text-align: left;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.scanned-email-address {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.found-breaches.scan-res-breaches {
|
||||
padding: 0 var(--padding);
|
||||
}
|
||||
}
|
||||
|
||||
.facebook-notes {
|
||||
text-align: left;
|
||||
margin-bottom: 100px;
|
||||
}
|
||||
|
||||
.facebook-notes .section-headline {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.facebook-notes.temp-marketing-callout span a {
|
||||
color: var(--blue3);
|
||||
}
|
||||
|
||||
.facebook-notes .feature-tip-content-wrapper:last-of-type {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.facebook-notes a.recommendation {
|
||||
margin-left: 80px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.facebook-notes a.recommendation {
|
||||
margin-left: 0;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
|
@ -1,625 +0,0 @@
|
|||
.security-tips.clear-header {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.articles .col-9 {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.security-tip-list-item {
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.75rem;
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
|
||||
.articles {
|
||||
color: var(--grey8);
|
||||
}
|
||||
|
||||
.security-tips {
|
||||
color: var(--grey1);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.security-tip-list {
|
||||
margin-top: var(--padding);
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
|
||||
.st-intro-wrap {
|
||||
padding: 0 var(--padding);
|
||||
border-left: 0.5rem solid var(--grey2);
|
||||
}
|
||||
|
||||
.st-intro {
|
||||
font-size: 1.2rem;
|
||||
font-style: italic;
|
||||
line-height: 220%;
|
||||
}
|
||||
|
||||
.security-tip-link {
|
||||
display: inline;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--grey1);
|
||||
align-items: center;
|
||||
font-family: Metropolis, sans-serif;
|
||||
}
|
||||
|
||||
.security-tip-link-subhead {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
font-size: 1rem;
|
||||
max-width: 400px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.article-icon {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-bottom-width: 4px;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom-style: solid;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.security-tip-subhead {
|
||||
max-width: 230px;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 6rem;
|
||||
display: block;
|
||||
font-family: Metropolis, sans-serif;
|
||||
}
|
||||
|
||||
.ico svg.icon-inline path {
|
||||
fill: var(--grey8);
|
||||
}
|
||||
|
||||
body {
|
||||
--how-hackers-work: var(--orange6);
|
||||
--stronger-passwords: var(--blue2);
|
||||
--steps-to-protect: var(--purple1);
|
||||
--five-myths: var(--violet1);
|
||||
--next-steps: #e31587;
|
||||
--after-breach: #3fe1b0;
|
||||
}
|
||||
|
||||
.how-hackers-work {
|
||||
border-bottom-color: var(--how-hackers-work);
|
||||
color: var(--how-hackers-work);
|
||||
}
|
||||
|
||||
.strong-passwords {
|
||||
border-bottom-color: var(--stronger-passwords);
|
||||
color: var(--stronger-passwords);
|
||||
}
|
||||
|
||||
.after-breach {
|
||||
border-bottom-color: var(--after-breach);
|
||||
color: var(--after-breach);
|
||||
}
|
||||
|
||||
.steps-to-protect {
|
||||
border-bottom-color: var(--steps-to-protect);
|
||||
color: var(--steps-to-protect);
|
||||
}
|
||||
|
||||
.five-myths {
|
||||
border-bottom-color: var(--five-myths);
|
||||
color: var(--five-myths);
|
||||
}
|
||||
|
||||
.next-steps {
|
||||
border-bottom-color: var(--next-steps);
|
||||
color: var(--next-steps);
|
||||
}
|
||||
|
||||
.security-tip-link svg path {
|
||||
fill: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
.how-hackers-work-list {
|
||||
margin-top: calc(var(--padding) * 2);
|
||||
}
|
||||
|
||||
.myth {
|
||||
color: var(--violet3);
|
||||
display: block;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.article-icon .icon-inline {
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.article-wrapper {
|
||||
padding-top: 12rem;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.article-wrapper,
|
||||
.st-intro-wrap {
|
||||
width: 75%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.article-headline {
|
||||
color: var(--ink);
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.article-list-item,
|
||||
.article-paragraph {
|
||||
font-size: 1.1rem;
|
||||
line-height: 200%;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
a.st-copy-link {
|
||||
color: var(--blue3);
|
||||
}
|
||||
|
||||
.article-paragraph .bold {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
a.st-copy-link:hover,
|
||||
a.st-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.st-list-header {
|
||||
margin-top: 2rem;
|
||||
color: var(--ink);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.article-list-item {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.article-list-item::before {
|
||||
content: '\2022';
|
||||
display: inline-block;
|
||||
margin-right: 1rem;
|
||||
font-size: 1.5rem;
|
||||
color: var(--ink);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.article-subhead {
|
||||
font-size: 1.5rem;
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h3.article-subhead,
|
||||
.toggle-subhead {
|
||||
margin: 2.5rem auto 0;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.steps-to-protect::before {
|
||||
background-color: var(--blue3);
|
||||
}
|
||||
|
||||
.icon-inline-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
|
||||
.icon-inline {
|
||||
min-width: 1.5rem;
|
||||
max-width: 1.5rem;
|
||||
max-height: 1.5rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.st-headline {
|
||||
margin: 0;
|
||||
line-height: 200%;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.st-list,
|
||||
.st-subhead {
|
||||
margin: 0;
|
||||
padding: var(--padding) calc(30px + 2rem) 0;
|
||||
}
|
||||
|
||||
.st-list-item {
|
||||
margin: 0.75rem 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.st-list-item::before {
|
||||
content: '\2022';
|
||||
display: inline-block;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
a.st-link {
|
||||
color: var(--blue3);
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.st-icon {
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.st-headline .bold {
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.st-call-out {
|
||||
margin: calc(var(--padding) * 2) auto;
|
||||
border-radius: 0.7rem;
|
||||
background: var(--monitor-gradient);
|
||||
padding: 2px;
|
||||
box-shadow: 0 0 10px 1px rgb(205 205 212 / 0.78);
|
||||
}
|
||||
|
||||
.inset {
|
||||
background-color: var(--bg-light);
|
||||
border-radius: 0.575rem;
|
||||
padding: calc(var(--padding) * 2);
|
||||
}
|
||||
|
||||
/* password dos and don't list */
|
||||
|
||||
.pw-tip-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pw-tip-item.flx:nth-child(odd) {
|
||||
background-color: rgb(32 18 58 / 0.031);
|
||||
}
|
||||
|
||||
.pw-do-hl,
|
||||
.pw-do {
|
||||
border-right: 1px solid var(--grey2);
|
||||
}
|
||||
|
||||
.pw-tip-headlines {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pw-tip-headlines::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
display: block;
|
||||
content: '';
|
||||
background: var(--monitor-gradient);
|
||||
}
|
||||
|
||||
.pw-tip-headlines,
|
||||
.do-dont-hl {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.do-dont-hl {
|
||||
padding: 2rem 1rem;
|
||||
flex: 1 1 50%;
|
||||
}
|
||||
|
||||
.do-dont-hl h4 {
|
||||
align-items: center;
|
||||
font-size: 1.5rem;
|
||||
position: relative;
|
||||
color: var(--blue5);
|
||||
}
|
||||
|
||||
.icon-dont::before {
|
||||
background-image: url('../../img/svg/x-close-red.svg');
|
||||
}
|
||||
|
||||
.icon-do::before {
|
||||
background-image: url('../../img/svg/green-check.svg');
|
||||
}
|
||||
|
||||
.icon-do::before,
|
||||
.icon-dont::before {
|
||||
left: -2.5rem;
|
||||
content: '';
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-size: contain;
|
||||
background-position: center center;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.do-dont-wrap {
|
||||
padding: var(--padding);
|
||||
flex: 1 1 50%;
|
||||
}
|
||||
|
||||
.do-dont {
|
||||
width: 80%;
|
||||
line-height: 150%;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.password-tip-list {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pw-tip-table {
|
||||
border-radius: 0.7rem;
|
||||
box-shadow: 0 0 10px 1px rgb(205 205 212 / 0.78);
|
||||
}
|
||||
|
||||
.password-tip-list-headline {
|
||||
padding: calc(var(--padding) * 2) var(--padding);
|
||||
margin-bottom: 0;
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.do-list-item,
|
||||
.dont-list-item {
|
||||
padding: var(--padding);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.article-toggle {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
width: 5rem;
|
||||
min-width: 100%;
|
||||
pointer-events: none;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.article-toggle:hover {
|
||||
background-color: var(--grey2);
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.article-toggle svg.toggle-down {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drop-cap {
|
||||
font-size: 40px;
|
||||
color: var(--orange6);
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
float: left;
|
||||
padding-top: 5px;
|
||||
margin-bottom: -10px;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 950px) {
|
||||
.security-tip-list-item .article-icon,
|
||||
.security-tip-link-subhead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.security-tip-list-item {
|
||||
margin-bottom: 1.25rem;
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.pw-tip-headlines::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.do-dont-wrap {
|
||||
flex: 1 1 100% !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.pw-tip-table {
|
||||
background-color: rgb(0 0 0 / 0);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pw-tip-item {
|
||||
flex-direction: column;
|
||||
margin-bottom: 3rem;
|
||||
border-radius: 0.7rem;
|
||||
overflow: hidden;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.security-tips-headline {
|
||||
font-size: var(--headline);
|
||||
}
|
||||
|
||||
.security-tip-link {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.article-wrapper,
|
||||
.st-intro-wrap {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.do-dont-hl-mobile {
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--blue5);
|
||||
}
|
||||
|
||||
.do-dont-hl-mobile::before {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
left: -2.15rem;
|
||||
}
|
||||
|
||||
.do-dont-hl {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.do-dont {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pw-do {
|
||||
background-color: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
.pw-dont {
|
||||
position: relative;
|
||||
background-color: rgb(32 18 58 / 0.05);
|
||||
}
|
||||
|
||||
.pw-dont::before {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background-color: white;
|
||||
display: block;
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -0.75rem;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.st-intro,
|
||||
.article-list-item,
|
||||
.article-paragraph {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.st-intro-wrap {
|
||||
padding: 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.article-headline {
|
||||
font-size: var(--headline);
|
||||
}
|
||||
|
||||
.drop-cap {
|
||||
font-size: 28px;
|
||||
padding-right: 1px;
|
||||
margin-bottom: -6px;
|
||||
}
|
||||
|
||||
.article-list-item::before {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.article-list-item {
|
||||
line-height: 180%;
|
||||
}
|
||||
|
||||
.st-toggle {
|
||||
margin-bottom: var(--padding);
|
||||
}
|
||||
|
||||
.toggle.article-toggle {
|
||||
pointer-events: all;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.st-toggle-wrapper {
|
||||
border-bottom: 1px solid var(--grey3);
|
||||
border-radius: 0;
|
||||
padding: var(--padding) 0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.st-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.st-next-steps .st-toggle-wrapper {
|
||||
border: 1px solid var(--grey8);
|
||||
border-radius: 0.25rem;
|
||||
padding: var(--padding);
|
||||
}
|
||||
|
||||
.article-toggle svg.toggle-down {
|
||||
right: var(--padding);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.toggle-subhead {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
padding-right: calc(var(--padding) * 3);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.myth {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon-inline {
|
||||
min-width: 1.15rem;
|
||||
max-width: 1.15rem;
|
||||
max-height: 1.15rem;
|
||||
}
|
||||
|
||||
.icon-inline-wrapper {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.security-tip-list-item {
|
||||
padding-right: 13%;
|
||||
}
|
||||
|
||||
.logo-title-wrapper .arrow-head-right {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.do-dont-wrap {
|
||||
padding: calc(var(--padding) * 2) var(--padding);
|
||||
}
|
||||
|
||||
.st-subhead {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.st-call-out {
|
||||
margin: calc(var(--padding) * 3) auto;
|
||||
}
|
||||
}
|
|
@ -1,148 +0,0 @@
|
|||
.sign-up-banner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
justify-content: space-between;
|
||||
color: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
.breach-detail-sign-up {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px -2px #1c1c1d80;
|
||||
}
|
||||
|
||||
.sign-up-feature-list {
|
||||
max-width: 500px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fxa-signup-banner {
|
||||
padding-left: var(--padding);
|
||||
padding-right: var(--padding);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.feature-button {
|
||||
margin-top: calc(var(--padding) * 2);
|
||||
}
|
||||
|
||||
.feature-button .feature-tip-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sign-up-feature::before {
|
||||
content: '\2022';
|
||||
display: inline-block;
|
||||
height: 1rem;
|
||||
color: rgb(255 255 255 / 1);
|
||||
flex: 1;
|
||||
margin-right: 0.5rem;
|
||||
width: 1rem;
|
||||
min-width: 1rem;
|
||||
max-width: 1rem;
|
||||
}
|
||||
|
||||
.sign-up-cta::before {
|
||||
width: 76px;
|
||||
height: 50px;
|
||||
content: '';
|
||||
display: block;
|
||||
background-image: url('../../img/svg/fxa-tout-yellow-env.svg');
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom center;
|
||||
background-size: contain;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sign-up-cta p,
|
||||
.sign-up-cta .text-link {
|
||||
color: rgb(255 255 255 / 1);
|
||||
}
|
||||
|
||||
.have-an-account path {
|
||||
fill: var(--blue3);
|
||||
}
|
||||
|
||||
.browser-not-required {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
margin: 12px auto;
|
||||
color: rgb(91 91 102 / 1);
|
||||
line-height: 1.4;
|
||||
max-width: 288px;
|
||||
}
|
||||
|
||||
p.have-an-account button.text-link,
|
||||
p.have-an-account {
|
||||
font-size: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
p.have-an-account {
|
||||
display: inline-block;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.have-an-account button.text-link {
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.sign-up-headline {
|
||||
font-size: 24px;
|
||||
line-height: 1.17;
|
||||
color: rgb(255 255 255 / 1);
|
||||
max-width: 320px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.sign-up-feature {
|
||||
line-height: 1.5;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sign-up-banner .feature-tip-group {
|
||||
margin-bottom: calc(var(--padding) * 2);
|
||||
}
|
||||
|
||||
.sign-up-cta .browser-not-required {
|
||||
color: rgb(255 255 255 / 0.8);
|
||||
}
|
||||
|
||||
.sign-up-cta {
|
||||
max-width: 300px;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.col-12.feat-headline-wrapper {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.sign-up-banner {
|
||||
flex-direction: column;
|
||||
padding: calc(var(--padding) + var(--padding)) var(--padding);
|
||||
}
|
||||
|
||||
h2.sign-up-headline {
|
||||
text-align: center;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
|
||||
.sign-up-cta,
|
||||
.sign-up-feature-list {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.sign-up-cta {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
.subpage {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.subpage.email-confirmed {
|
||||
background-image: url('../../img/svg/scan-res-bg.svg');
|
||||
background-position: center top;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.subpage .content-box {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
|
@ -1,257 +0,0 @@
|
|||
.vpn-banner {
|
||||
--step-list-w: 560px;
|
||||
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* body header, main, and footer transform based on vpn-banner height */
|
||||
.vpn-banner ~ header,
|
||||
.vpn-banner ~ main,
|
||||
.vpn-banner ~ footer {
|
||||
transform: translateY(var(--vpn-banner-height, 0));
|
||||
}
|
||||
|
||||
/* By waiting for the async data-protected attribute, we avoid initial animation on page load */
|
||||
.vpn-banner[data-protected] ~ header,
|
||||
.vpn-banner[data-protected] ~ main,
|
||||
.vpn-banner[data-protected] ~ footer {
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.vpn-banner label,
|
||||
.vpn-banner li {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vpn-banner em,
|
||||
.vpn-banner output {
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.vpn-banner em {
|
||||
color: #e22850;
|
||||
}
|
||||
|
||||
.vpn-banner[data-protected='true'] em {
|
||||
color: #1cc4a0;
|
||||
}
|
||||
|
||||
.vpn-banner .vpn-banner-top::after,
|
||||
.vpn-banner:not([data-protected]) .vpn-banner-top::after {
|
||||
/* black screen while loading protection data */
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: black;
|
||||
transition: opacity 0.3s ease-out, visibility 0s 0.3s;
|
||||
}
|
||||
|
||||
.vpn-banner[data-protected] .vpn-banner-top::after {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.vpn-banner[data-protected='true'] .protected-txt:first-child,
|
||||
.vpn-banner[data-protected='false'] .protected-txt:last-child {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vpn-banner[data-protected='false'] .protected-txt:first-child,
|
||||
.vpn-banner[data-protected='true'] .protected-txt:last-child,
|
||||
.vpn-banner[data-protected='true'] .vpn-banner-cta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*** TOP PANEL ***/
|
||||
.vpn-banner-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
background-color: black;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 -1px 0 #444;
|
||||
}
|
||||
|
||||
.vpn-banner-top label {
|
||||
padding: 0 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vpn-banner-top .short-location::after {
|
||||
content: '•';
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.vpn-banner-chevron {
|
||||
min-width: 76px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vpn-banner-chevron::before {
|
||||
content: attr(data-expand);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.vpn-banner-chevron::after {
|
||||
content: url('../../img/svg/chevron.svg');
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 3px;
|
||||
transition: transform 0.15s;
|
||||
transform-origin: 50% 53%;
|
||||
}
|
||||
|
||||
[data-expanded] .vpn-banner-chevron::before {
|
||||
content: attr(data-close);
|
||||
}
|
||||
|
||||
[data-expanded] .vpn-banner-chevron::after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/*** BOTTOM PANEL ***/
|
||||
.vpn-banner-bottom {
|
||||
position: relative;
|
||||
display: none;
|
||||
max-width: var(--max-width);
|
||||
min-height: 250px;
|
||||
color: black;
|
||||
margin: auto;
|
||||
padding: 32px 16px 16px;
|
||||
counter-reset: step;
|
||||
animation: vpn-banner-expand 0.3s ease-in both;
|
||||
}
|
||||
|
||||
[data-expanded] .vpn-banner-bottom {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.vpn-banner-bottom::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: calc(50% - 50vw);
|
||||
background: white url('../../img/svg/clouds.svg') no-repeat 90% 75%;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.vpn-banner-title {
|
||||
font-family: Metropolis, sans-serif;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vpn-banner-title .subheading {
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
font-size: 18px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.vpn-banner-lists {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* TODO: <ul> and <li> inherit custom flex styles from main. Should separate this and refine for reusability */
|
||||
.vpn-banner-lists .status-list {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.vpn-banner-lists .status-list em,
|
||||
.vpn-banner-lists .status-list output {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.vpn-banner-lists .step-list {
|
||||
flex: 0 1 var(--step-list-w);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 16px 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.vpn-banner-lists .step-list li {
|
||||
flex-basis: calc(var(--step-list-w) / 3);
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
.vpn-banner-lists .step-list li::before {
|
||||
display: inline-block;
|
||||
content: counter(step) '.';
|
||||
counter-increment: step;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
width: 36px;
|
||||
margin-left: -36px;
|
||||
border: 2px solid black;
|
||||
box-shadow: inset 4px 4px 0 white;
|
||||
background-color: #ededf0;
|
||||
font-family: Metropolis, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.vpn-banner-lists .step-list figure {
|
||||
display: inline-block;
|
||||
width: 110px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vpn-banner-cta {
|
||||
justify-self: start;
|
||||
margin: 16px 0;
|
||||
padding: 12px 24px;
|
||||
border: 2px solid black;
|
||||
box-shadow: 6px 6px 0 #a883f8;
|
||||
font: bold 14px/1 Metropolis, sans-serif;
|
||||
font-weight: bold;
|
||||
color: black;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.vpn-banner-close {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: transparent;
|
||||
border-top: 2px solid #321c64;
|
||||
border-left: 2px solid #321c64;
|
||||
transform: rotate(45deg);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.vpn-banner-close::after {
|
||||
content: '';
|
||||
display: block;
|
||||
border-top: 1px solid #592acb;
|
||||
border-left: 1px solid #592acb;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
@keyframes vpn-banner-expand {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
Двоичные данные
public/favicon.ico
До Ширина: | Высота: | Размер: 6.9 KiB |
Двоичные данные
public/fonts/Inter/Inter-Bold.woff2
Двоичные данные
public/fonts/Inter/Inter-BoldItalic.woff2
Двоичные данные
public/fonts/Inter/Inter-Italic.woff2
Двоичные данные
public/fonts/Inter/Inter-Regular.woff2
Двоичные данные
public/fonts/Metropolis/Metropolis-SemiBold.woff2
Двоичные данные
public/img/ad-units/ad-unit-3-video-games.png
До Ширина: | Высота: | Размер: 13 KiB |
Двоичные данные
public/img/ad-units/ad-unit-4-alone-time.png
До Ширина: | Высота: | Размер: 9.2 KiB |
Двоичные данные
public/img/ad-units/ad-unit-5-breakfast.png
До Ширина: | Высота: | Размер: 4.4 KiB |
Двоичные данные
public/img/ad-units/ad-unit-5-shopping.png
До Ширина: | Высота: | Размер: 3.8 KiB |
Двоичные данные
public/img/ad-units/ad-unit-5-walking.png
До Ширина: | Высота: | Размер: 3.7 KiB |
Двоичные данные
public/img/ad-units/ad-unit-6-working-from-home.png
До Ширина: | Высота: | Размер: 11 KiB |
|
@ -1 +0,0 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.293 8.293a1 1 0 0 1 1.414 0L12 14.586l6.293-6.293a1 1 0 1 1 1.414 1.414l-7 7a1 1 0 0 1-1.414 0l-7-7a1 1 0 0 1 0-1.414z" fill="#ffffff" fill-opacity=".8"></path></svg>
|
До Ширина: | Высота: | Размер: 314 B |
Двоичные данные
public/img/email_images/fxm-lg-drk.png
До Ширина: | Высота: | Размер: 7.0 KiB |