Merge branch 'main' into mntor-2700-send-email

This commit is contained in:
Florian Zia 2024-06-07 19:43:44 +02:00 коммит произвёл GitHub
Родитель 20228a6e30 4e4f2edb5b
Коммит 5708f3328a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
43 изменённых файлов: 1930 добавлений и 1078 удалений

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

@ -119,6 +119,12 @@ E2E_TEST_BASE_URL=
E2E_TEST_ACCOUNT_EMAIL=
E2E_TEST_ACCOUNT_PASSWORD=
E2E_TEST_ACCOUNT_EMAIL_ZERO_BREACHES=
E2E_TEST_ACCOUNT_EMAIL_EXPOSURES_STARTED=
E2E_TEST_PAYPAL_LOGIN =
E2E_TEST_PAYPAL_PASSWORD =
# Monitor Premium features
# Link to start user on the subscription process. PREMIUM_ENABLED must be set to `true`.
FXA_SUBSCRIPTIONS_URL=https://accounts.stage.mozaws.net/subscriptions

4
.github/workflows/e2e_cron.yml поставляемый
Просмотреть файл

@ -55,6 +55,10 @@ jobs:
E2E_TEST_BASE_URL: ${{ secrets.E2E_TEST_BASE_URL }}
E2E_TEST_ACCOUNT_EMAIL: ${{ secrets.E2E_TEST_ACCOUNT_EMAIL }}
E2E_TEST_ACCOUNT_PASSWORD: ${{ secrets.E2E_TEST_ACCOUNT_PASSWORD }}
E2E_TEST_ACCOUNT_EMAIL_ZERO_BREACHES: ${{ secrets.E2E_TEST_ACCOUNT_EMAIL_ZERO_BREACHES }}
E2E_TEST_ACCOUNT_EMAIL_EXPOSURES_STARTED: ${{ secrets.E2E_TEST_ACCOUNT_EMAIL_EXPOSURES_STARTED }}
E2E_TEST_PAYPAL_LOGIN: ${{ secrets.E2E_TEST_PAYPAL_LOGIN }}
E2E_TEST_PAYPAL_PASSWORD: ${{ secrets.E2E_TEST_PAYPAL_PASSWORD }}
ADMINS: ${{ secrets.ADMINS }}
FXA_ENABLED: true
OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}

4
.github/workflows/e2e_pr.yml поставляемый
Просмотреть файл

@ -70,7 +70,11 @@ jobs:
E2E_TEST_ENV: ${{ inputs.environment != null && inputs.environment || 'local' }}
E2E_TEST_BASE_URL: ${{ secrets.E2E_TEST_BASE_URL }}
E2E_TEST_ACCOUNT_EMAIL: ${{ secrets.E2E_TEST_ACCOUNT_EMAIL }}
E2E_TEST_ACCOUNT_EMAIL_ZERO_BREACHES: ${{ secrets.E2E_TEST_ACCOUNT_EMAIL_ZERO_BREACHES }}
E2E_TEST_ACCOUNT_EMAIL_EXPOSURES_STARTED: ${{ secrets.E2E_TEST_ACCOUNT_EMAIL_EXPOSURES_STARTED }}
E2E_TEST_ACCOUNT_PASSWORD: ${{ secrets.E2E_TEST_ACCOUNT_PASSWORD }}
E2E_TEST_PAYPAL_LOGIN: ${{ secrets.E2E_TEST_PAYPAL_LOGIN }}
E2E_TEST_PAYPAL_PASSWORD: ${{ secrets.E2E_TEST_PAYPAL_PASSWORD }}
ADMINS: ${{ secrets.ADMINS }}
FXA_ENABLED: true
OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}

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

@ -21,6 +21,34 @@ features:
value: { "enabled": false }
welcome-scan-optional-info:
description: Show additional optional inputs to provide middle name and name suffix for broker scan
variables:
enabled:
description: If the feature is enabled
type: Boolean
default: false
defaults:
- channel: local
value: { "enabled": true }
- channel: staging
value: { "enabled": false }
- channel: production
value: { "enabled": false }
automatic-removal-csat-survey:
description: Show the CSAT survey for Plus users that have automatically removed broker results
variables:
enabled:
description: If the feature is enabled
type: Boolean
default: false
defaults:
- channel: local
value: { "enabled": true }
- channel: staging
value: { "enabled": true }
- channel: production
value: { "enabled": false }
last-scan-date-csat-survey:
description: Show the CSAT survey for users that are enrolled in the `last-scan-date` experiment
variables:
enabled:
description: If the feature is enabled

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

@ -47,19 +47,13 @@ settings-email-number-of-breaches-info =
*[other] Optræder i { $breachCount } kendte datalæk
}
## Deactivate account
settings-deactivate-account-title = Deaktiver konto
settings-deactivate-account-info-2 = Du kan deaktivere { -product-short-name } ved at slette din { -brand-mozilla-account }.
settings-fxa-link-label-3 = Gå til indstillingerne for { -brand-mozilla-account }
## Delete Monitor account
settings-delete-monitor-free-account-title = Slet { -brand-monitor }-konto
settings-delete-monitor-free-account-description = Dette vil permanent slette din { -brand-monitor }-konto og slå alle advarsler fra.
settings-delete-monitor-free-account-cta-label = Slet konto
settings-delete-monitor-free-account-dialog-title = Din { -brand-monitor }-konto vil blive slettet permanent
settings-delete-monitor-free-account-dialog-lead = Alle dine kontooplysninger for { -brand-monitor } vil blive slettet, og vi holder ikke længere øje med nye datalæk. Denne handling sletter ikke din { -brand-mozilla }-konto.
settings-delete-monitor-free-account-dialog-lead-v2 = Alle dine kontooplysninger for { -brand-monitor } vil blive slettet, og vi holder ikke længere øje med nye datalæk. Denne handling sletter ikke din { -brand-mozilla-account }.
settings-delete-monitor-free-account-dialog-cta-label = Slet konto
settings-delete-monitor-free-account-dialog-cancel-button-label = Jag har skiftet mening - gå tilbage
settings-delete-monitor-account-confirmation-toast-label-2 = Din { -brand-monitor }-konto er nu slettet.

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

@ -146,7 +146,7 @@ security-recommendation-email-summary =
[one] Ne ñanduti veve kundaharape ojehecha { $num_breaches } mbaekuaarã ñemboguápe:
*[other] Ne ñanduti veve kundaharape ojehecha { $num_breaches } mbaekuaarã ñemboguápe:
}
security-recommendation-email-description = Rombyasyeterei, ndaikatumoãi emyatyrõ ko apañuãi. Hákatu ekuekuaa eñemoã hag̃ua.
security-recommendation-email-description = Rombyasyeterei, ndaikatumoãi emyatyrõ ko apañuái. Hákatu ekuekuaa eñemoã hag̃ua.
security-recommendation-email-step-one = Ani eikutu juajuha ñanduti veve eikuaỹva omboúvagui; eimoãramo ouha ejeroviahágui, ehenói eikuaa porã hag̃ua
security-recommendation-email-step-two = Emaẽke <link_to_info>phishing jehode</link_to_info> rehe
security-recommendation-email-step-three = Emongurusu ñanduti veve ikatúva spam ha emboyke imbouhára

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

@ -1,4 +1,3 @@
# 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/.
@ -11,7 +10,6 @@ rec-ssn =
Eguereko mbohapy marandui ñemurã reigua arýpe léipe heiháicha.
ejerure ha ehechajey nombyaíri ne ñemurã.
Eheka mbaete, virujeporu térã kuatiaatã ñemurã eikuaaỹva.
# Recommendation subhead
rec-pw-1-subhead = Emoambue ne ñeẽñemi
# Link title
@ -20,7 +18,6 @@ rec-pw-1-2 =
Ejapo ko ñeẽñemígui haeño ha iñambuéva oimeraẽva umi eiporúvagui.
Mbae iporãva hae emojopyru mokõi térã hetave ñeẽ ojueheguaỹva
emoheñói hag̃ua peteĩ ñeẽsyry ha emoinge papapy ha taãngai.
# Recommendation subhead
rec-pw-2-subhead = Embohekopyahu ambue tembiapo ñepyrũ ko ñeẽñemímente
# Link title
@ -28,7 +25,6 @@ rec-pw-2-cta-fx = Ehecha tembiapo ñepyrũ { -brand-name }-pe
rec-pw-2 =
Eiporujeývo ñeẽñemi ombohetakuaa mbaekuaarã ñembyai. Kóva oikóvo
ñeẽñemi eiporukuaa, umi mbaevai apoha ikatu oiporu oike hag̃ua ambue mbaetépe.
# Recommendation subhead
rec-pw-3-subhead = Eiporu ñeẽñemi ñangarekoha eraha hag̃ua ne ñeẽñemi opa hendápe
# Link title
@ -39,7 +35,6 @@ rec-pw-3-fx =
rec-pw-3-non-fx =
Eiporu { -brand-lockwise } ehapykueho hag̃ua opaite ne
ñeẽñemi ha eike tekorosãme ne pumbyry térã tableta rupive.
# Recommendation subhead
rec-pw-4-subhead = Emboheko pe ñemoneĩ mokõi factor rehegua (2FA)
# Link title
@ -48,19 +43,16 @@ rec-pw-4 =
Heta ñanduti renda omeẽse 2FA tekorosãverãramo. Kóva oikotevẽ
ambue marandu omoñepyrũ hag̃ua tembiapo ne mbaetépe, peteĩ
ayvu haeñóvaramo og̃uahẽ ñeẽmondo haipyre rupi.
# Recommendation subhead
rec-bank-acc-subhead = Ehechameme banco pegua kuatia
rec-bank-acc =
Ehecha banco pegua kuatia tembiapo vai térã oikomemeỹva
Emombeu ne báncope ehecháramo mbae eikuaaporãỹva
# Recommendation subhead
rec-cc-subhead = Ehechameme kuatiaatã ñemuha kuatia
rec-cc =
Emaẽ tapiáke nde kuatiaatã ñemurã. Ikatu hína
ejerure kuatiaatã ñemurã pyahu ipapapy pyahúva meẽhárape.
# Recommendation subhead
rec-email-mask-subhead = Eiporu peteĩ ñanduti veve rovaraãnga
rec-email-cta = Eiporu { -brand-relay }
@ -68,58 +60,48 @@ rec-email =
Emeẽvo ne ñanduti veve haetéva remoĩma umi mbaevaiapoha ha tapykuehoha
ojuhúvo ne ñeẽñemi térã ndejuhúvo ñandutípe. Ko { -brand-relay } rembiapo
hae omokañývo ne ñanduti veve emondojey aja ñandutiveve ne g̃uahẽhame.
# Recommendation subhead
rec-ip-subhead-2 = Eiporu VPN emoñemi hag̃ua nde IP kundaharape
# Recommendation subhead
rec-moz-vpn-cta = Eñeẽ { -brand-mozilla-vpn }
rec-moz-vpn-update-2 =
Ne kundaharape ñandutigua (IP kundaharape) ohechauka ne rendaite
ha ñanduti meẽha. Pe mbaeporu { -brand-mozilla-vpn } rehegua
omoypytũ nde IP kundaharape oñomi hag̃ua ne rendaite.
rec-hist-pw-subhead = Aníke eiporujoa ñeẽñemi
# Link title
rec-hist-pw-cta-fx = Ehecha tembiapo ñepyrũ { -brand-name }
rec-hist-pw = Eiporu ñeẽñemi haete ha hekorosãva peteĩteĩva mbaetépe g̃uarã. Pe ñeẽñemi oreko ijehe mbaekuaarã ñembyai, tekotevẽ embohekopyahu tembiapo ñepyrũ año.
# Recommendation subhead
rec-sec-qa-subhead = Emoheñói mbohovái porandu hekorosãva rehegua
rec-sec-qa =
Heta ñanduti renda oporandu peteĩchaite. Peteĩ mbohovái ojehecháramo, pe
marandu ojekuaa. Emoheñói mbohovái ipuku ha jereguáva ha eñongatu tenda hekorosãvape.
# Recommendation subhead
rec-phone-num-subhead = Aníke emoherakuã ne pumbyry papapy
rec-phone-num =
Ani emeẽ ne pumbyry papapy eñemboheraguapývo mbaete
ipyahúvape térã mbaeporurãme. Nereikotevẽiramo pumbyry papapy, anínte emoinge.
# Recommendation subhead
rec-dob-subhead = Aníke eiporu nde jehegua PINs-pe
rec-dob =
Ndahasýi rupi nereñoiha ára ijuhu,
ani eiporu ñeẽñemi ha PINs. Umi
oikuaáva ne aramboty arange ikatu avei oike ne PIN-pe.
# Recommendation subhead
rec-pins-subhead = Emohekorosãvéke ne PINs
rec-pins = Peteĩ PIN hekorosãva ndorekói marandu ndejehegua, ne arareñói térã kundaharape. Orekovaerã papapy nde añoite eikuaáva ha ndaikatuiva ojekuaarei.
# Recommendation subhead
rec-address-subhead = Aníke eiporu kundaharape ñeẽñemíme
rec-address =
Eiporúrõ kundaharape térã tape eiko hague omokangy ne
ñeẽñemi. Mbaekuaarã ndahasýiva ijuhu oimehápe, péva rupi koã
ñeẽñemi ndahasýi ijekuaa.
# Recommendation subhead
rec-gen-1-subhead = Eiporu ñeẽñemi haeño ha hekorosãva peteĩteĩva mbaetépe g̃uarã
# Link title
rec-gen-1-cta = Mbaéicha emoheñóita ñeẽñemi hekorosãva
rec-gen-1 = Ñeẽñemi eiporujeýrõ ombyaikuaa opaite ne mbaete. Kóva heise pe ñeẽñemi ojehechakuaa, umi mbaevaiapoha oreko mbaeñemi heta mbaete rehegua.
# Recommendation subhead
rec-gen-2-subhead = Embyaty ñeẽñemi tenda hekorosãvape
# Link title
@ -127,7 +109,6 @@ rec-gen-2-cta = Mombeuguau ñeẽñemi ñangarekohára rehegua
rec-gen-2 =
Eñongatu ne mbaekuaarã rembiapo ñepyrũ tenda hekorosãvape eikekuaahápe neañomi, peteĩ
ñeẽñemi ñangarekoha. Kóva nepytyvõta ehapykueho hag̃ua ne ñeẽñeminguéra.
# Recommendation subhead
rec-gen-3-subhead = Ehecháke mávapepa ikatu embohasa marandu ndejehegua
# Link title
@ -135,9 +116,8 @@ rec-gen-3-cta = Emoñeẽve tekorosã rehegua
rec-gen-3 =
Ani emeẽ mbaekuaarã ndejehegua natekotevẽirõ. Ojejerurérõ
emoingévo térã emeẽ ñanduti veve kundaharape, código postal térã pumbyry papapy, ikatu embotove.
# Recommendation subhead
rec-gen-4-subhead = Embohekopyahu meme software ha tembiporui
rec-gen-4 =
Ñembohekopyahu tembiporui, kundahára ha apopyvusu okuéva ne pumbyry haevéva ojapo
mbaeokágui hekorosãva. Koã ñembohekopyahu omoiporã jejavy, software ñembyaikuaa ha tekorosã apañuãi.
mbaeokágui hekorosãva. Koã ñembohekopyahu omoiporã jejavy, software ñembyaikuaa ha tekorosã apañuái.

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

@ -6,7 +6,11 @@
### Dialog window that allows a user to add a new email address to be monitored
add-email-add-another-heading = Legg til ei ny e-postadresse
add-email-address-input-label = E-postadresse
add-email-send-verification-button = Send stadfestingslenke
# $email is the newly added email address. $settings-href is the URL for the Settings page. HTML tags should not be translated, e.g. `<a>`
# This string will be deprecated when the new Plus plan is live.
add-email-verify-the-link = Stadfest lenka som vart sendt til { $email } for å leggje henne til i { -brand-fx-monitor }. Handsam alle e-postadresser i <a { $settings-href }>Innstillingar</a>.
# Variables:
# $email (string) - An email address submitted by the user for monitoring, e.g. `example@example.com`
add-email-verify-the-link-2 = Stadfest lenka som vart sendt til <b>{ $email }</b> for å leggje henne til i { -brand-mozilla-monitor }.

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

@ -24,8 +24,9 @@ exposure-chart-legend-heading-nr = Antal
exposure-chart-legend-value-nr = { $nr }×
exposure-chart-returning-user-upgrade-prompt-cta = Start ei gratis skanning
modal-cta-ok = OK
modal-open-alt = Opne
modal-close-alt = Lat att
open-modal-alt = Opne modal
close-modal-alt = Lat att modal
open-tooltip-alt = Opne verktøytips
progress-card-heres-what-we-fixed-headline-all = Du har løyst følgjande
progress-card-manually-fixed-headline = Manuelt løyst
dashboard-tab-label-action-needed = Handling påkravd

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

@ -5,12 +5,15 @@
public-nav-name = { -brand-mozilla-monitor }
landing-all-hero-title = Finn ut kvar din informasjon er eksponert — og ta han tilbake
landing-all-hero-emailform-input-placeholder = dittnamn@doeme.no
landing-all-hero-emailform-input-label = Skriv inn e-postadressa di for å søkje etter datalekkasje-eksponeringar
landing-all-hero-emailform-submit-label = Få gratis skanning
# This is a label underneath a big number "14" - it's an image that demos Monitor.
landing-all-hero-image-chart-label = eksponeringar
# Value Proposition
landing-all-value-prop-fix-exposures = Vi hjelper deg med å løyse eksponeringane dine
landing-all-value-prop-info-at-risk = Kva for type informasjon kan vere i fare?
# Quote

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

@ -9,6 +9,8 @@ settings-page-title = { -product-short-name }-innstillingar
## Breach alert preferences
settings-alert-email-preferences-title = E-postinnstillingar
settings-alert-email-preferences-subtitle = Fortel oss kva for e-postar du ønskjer å få.
settings-alert-preferences-option-one = Send alle åtvaringar om datalekkasjar til den ramma e-postadressa
settings-alert-preferences-option-two = Send alle åtvaringar om datalekkasjar til den primære e-postadressa
settings-alert-preferences-allow-monthly-monitor-report-subtitle = Ei månadleg oppdatering av nye eksponeringar, kva som er fiksa og kva som krev di merksemd.
@ -26,15 +28,11 @@ settings-remove-email-button-label = Fjern
# $emailAddress (string) - The email address to remove, e.g. `billnye@example.com`
settings-remove-email-button-tooltip = Slutt å overvake { $emailAddress }
## Deactivate account
settings-deactivate-account-title = Deaktiver kontoen
settings-fxa-link-label-3 = Gå til { -brand-mozilla-account }-innstillingane
## Delete Monitor account
settings-delete-monitor-free-account-title = Slett { -brand-monitor }-kontoen
settings-delete-monitor-free-account-cta-label = Slett kontoen
settings-delete-monitor-free-account-dialog-cta-label = Slett kontoen
settings-delete-monitor-free-account-dialog-cancel-button-label = Gløym det, ta meg tillbake
settings-delete-monitor-account-confirmation-toast-label-2 = { -brand-monitor }-kontoen din er no sletta.
settings-delete-monitor-account-confirmation-toast-dismiss-label = Ignorer

1325
package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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

@ -51,8 +51,8 @@
"npm": "10.8.0"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.577.0",
"@aws-sdk/lib-storage": "^3.578.0",
"@aws-sdk/client-s3": "^3.588.0",
"@aws-sdk/lib-storage": "^3.588.0",
"@fluent/bundle": "^0.18.0",
"@fluent/langneg": "^0.7.0",
"@fluent/react": "^0.15.2",
@ -65,7 +65,7 @@
"@sentry/nextjs": "^8.2.1",
"@sentry/node": "^8.0.0",
"@sentry/utils": "^8.0.0",
"@stripe/stripe-js": "^3.0.6",
"@stripe/stripe-js": "^3.4.1",
"@types/jsdom": "^21.1.5",
"@types/node": "^20.12.12",
"@types/react": "^18.2.79",
@ -94,13 +94,13 @@
"devDependencies": {
"@faker-js/faker": "^8.4.1",
"@playwright/test": "^1.43.1",
"@storybook/addon-a11y": "^8.1.3",
"@storybook/addon-actions": "^8.1.3",
"@storybook/addon-essentials": "^8.1.3",
"@storybook/addon-interactions": "^8.1.3",
"@storybook/addon-links": "^8.1.3",
"@storybook/addon-a11y": "^8.1.5",
"@storybook/addon-actions": "^8.1.5",
"@storybook/addon-essentials": "^8.1.5",
"@storybook/addon-interactions": "^8.1.5",
"@storybook/addon-links": "^8.1.5",
"@storybook/blocks": "^8.0.0",
"@storybook/nextjs": "^8.1.3",
"@storybook/nextjs": "^8.1.5",
"@storybook/react": "^8.0.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^15.0.7",
@ -133,16 +133,16 @@
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0",
"jest-fail-on-console": "^3.3.0",
"lint-staged": "^15.2.2",
"lint-staged": "^15.2.5",
"mjml-browser": "^4.15.3",
"prettier": "3.2.5",
"react-intersection-observer": "^9.10.2",
"sass": "^1.77.2",
"storybook": "^8.1.3",
"sass": "^1.77.4",
"storybook": "^8.1.5",
"stylelint": "^16.6.1",
"stylelint-config-recommended-scss": "^14.0.0",
"stylelint-scss": "^6.3.0",
"ts-jest": "^29.1.2",
"ts-jest": "^29.1.4",
"tsx": "^4.10.5",
"typescript": "^5.4.5",
"yaml": "^2.4.2"

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

@ -17,7 +17,11 @@ import { SubscriberBreach } from "../../../../../../../utils/subscriberBreaches"
import { LatestOnerepScanData } from "../../../../../../../db/tables/onerep_scans";
import { CountryCodeProvider } from "../../../../../../../contextProviders/country-code";
import { SessionProvider } from "../../../../../../../contextProviders/session";
import { defaultExperimentData } from "../../../../../../../telemetry/generated/nimbus/experiments";
import {
ExperimentData,
defaultExperimentData,
} from "../../../../../../../telemetry/generated/nimbus/experiments";
import { FeatureFlagName } from "../../../../../../../db/tables/featureFlags";
const brokerOptions = {
"no-scan": "No scan started",
@ -47,6 +51,8 @@ type DashboardWrapperProps = (
elapsedTimeInDaysSinceInitialScan?: number;
totalNumberOfPerformedScans?: number;
activeTab?: TabType;
enabledFeatureFlags?: FeatureFlagName[];
experimentData?: ExperimentData;
};
const DashboardWrapper = (props: DashboardWrapperProps) => {
const mockedResolvedBreach: SubscriberBreach = createRandomBreach({
@ -184,7 +190,6 @@ const DashboardWrapper = (props: DashboardWrapperProps) => {
userScanData={scanData}
isEligibleForPremium={props.countryCode === "us"}
isEligibleForFreeScan={props.countryCode === "us" && !scanData.scan}
enabledFeatureFlags={["CsatSurvey"]}
monthlySubscriptionUrl=""
yearlySubscriptionUrl=""
fxaSettingsUrl=""
@ -199,12 +204,15 @@ const DashboardWrapper = (props: DashboardWrapperProps) => {
elapsedTimeInDaysSinceInitialScan={
props.elapsedTimeInDaysSinceInitialScan
}
experimentData={{
...defaultExperimentData,
"last-scan-date": {
enabled: true,
},
}}
enabledFeatureFlags={props.enabledFeatureFlags ?? []}
experimentData={
props.experimentData ?? {
...defaultExperimentData,
"last-scan-date": {
enabled: true,
},
}
}
activeTab={props.activeTab ?? "action-needed"}
/>
</Shell>

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

@ -15,6 +15,7 @@ import {
import { userEvent } from "@testing-library/user-event";
import { composeStory } from "@storybook/react";
import { axe } from "jest-axe";
import { Cookies } from "react-cookie";
import Meta, {
DashboardNonUsNoBreaches,
DashboardNonUsUnresolvedBreaches,
@ -51,6 +52,8 @@ import Meta, {
DashboardUsPremiumManuallyResolvedScansNoBreaches,
} from "./Dashboard.stories";
import { useTelemetry } from "../../../../../../hooks/useTelemetry";
import { deleteAllCookies } from "../../../../../../functions/client/deleteAllCookies";
import { defaultExperimentData } from "../../../../../../../telemetry/generated/nimbus/experiments";
jest.mock("next/navigation", () => ({
useRouter: jest.fn(),
@ -72,6 +75,11 @@ jest.mock(
},
);
afterEach(() => {
// Make the CSAT banner show up again.
deleteAllCookies();
});
describe("axe accessibility test suite", () => {
it("passes the axe accessibility test suite for DashboardNonUsNoBreaches", async () => {
const ComposedDashboard = composeStory(DashboardNonUsNoBreaches, Meta);
@ -3348,12 +3356,22 @@ it("send telemetry when users click on exposure chart free scan", async () => {
});
describe("CSAT survey banner", () => {
it("does not display the CSAT survey banner on the dashboard tab “action needed” to Plus users", () => {
it("does not display the “automatic removal” CSAT survey banner on the dashboard tab “action needed” to Plus users", () => {
const ComposedDashboard = composeStory(
DashboardUsPremiumResolvedScanNoBreaches,
Meta,
);
render(<ComposedDashboard elapsedTimeInDaysSinceInitialScan={1} />);
render(
<ComposedDashboard
elapsedTimeInDaysSinceInitialScan={1}
experimentData={{
...defaultExperimentData,
"automatic-removal-csat-survey": {
enabled: true,
},
}}
/>,
);
const answerButton = screen.queryByRole("button", {
name: "Neutral",
@ -3361,13 +3379,23 @@ describe("CSAT survey banner", () => {
expect(answerButton).not.toBeInTheDocument();
});
it("does not display the CSAT survey banner to users who do not have automatic data removal enabled", async () => {
it("does not display the “automatic removal” CSAT survey banner to users who do not have automatic data removal enabled", async () => {
const user = userEvent.setup();
const ComposedDashboard = composeStory(
DashboardUsPremiumEmptyScanNoBreaches,
Meta,
);
render(<ComposedDashboard elapsedTimeInDaysSinceInitialScan={1} />);
render(
<ComposedDashboard
elapsedTimeInDaysSinceInitialScan={1}
experimentData={{
...defaultExperimentData,
"automatic-removal-csat-survey": {
enabled: true,
},
}}
/>,
);
const fixedTab = screen.getByText("Fixed");
await user.click(fixedTab);
@ -3378,13 +3406,23 @@ describe("CSAT survey banner", () => {
expect(answerButton).not.toBeInTheDocument();
});
it("displays the CSAT survey banner to Plus users, after more than 90 days since their initial scan", async () => {
it("displays the “automatic removal” CSAT survey banner to Plus users, after more than 90 days since their initial scan", async () => {
const user = userEvent.setup();
const ComposedDashboard = composeStory(
DashboardUsPremiumResolvedScanNoBreaches,
Meta,
);
render(<ComposedDashboard elapsedTimeInDaysSinceInitialScan={91} />);
render(
<ComposedDashboard
elapsedTimeInDaysSinceInitialScan={91}
experimentData={{
...defaultExperimentData,
"automatic-removal-csat-survey": {
enabled: true,
},
}}
/>,
);
const fixedTab = screen.getByText("Fixed");
await user.click(fixedTab);
@ -3395,13 +3433,23 @@ describe("CSAT survey banner", () => {
expect(answerButton).toBeInTheDocument();
});
it("displays the initial CSAT survey banner only to Plus users with automatically fixed data brokers", async () => {
it("displays the initial “automatic removal” CSAT survey banner only to Plus users with automatically fixed data brokers", async () => {
const user = userEvent.setup();
const ComposedDashboard = composeStory(
DashboardUsPremiumResolvedScanNoBreaches,
Meta,
);
render(<ComposedDashboard elapsedTimeInDaysSinceInitialScan={1} />);
render(
<ComposedDashboard
elapsedTimeInDaysSinceInitialScan={1}
experimentData={{
...defaultExperimentData,
"automatic-removal-csat-survey": {
enabled: true,
},
}}
/>,
);
const answerButtonOne = screen.queryByRole("button", {
name: "Neutral",
@ -3417,13 +3465,23 @@ describe("CSAT survey banner", () => {
expect(answerButtonTwo).toBeInTheDocument();
});
it("displays the 6-months CSAT survey banner on the dashboard tab “fixed” only", async () => {
it("displays the 6-months “automatic removal” CSAT survey banner on the dashboard tab “fixed” only", async () => {
const user = userEvent.setup();
const ComposedDashboard = composeStory(
DashboardUsPremiumResolvedScanNoBreaches,
Meta,
);
render(<ComposedDashboard elapsedTimeInDaysSinceInitialScan={180} />);
render(
<ComposedDashboard
elapsedTimeInDaysSinceInitialScan={180}
experimentData={{
...defaultExperimentData,
"automatic-removal-csat-survey": {
enabled: true,
},
}}
/>,
);
const answerButtonOne = screen.queryByRole("button", {
name: "Satisfied",
@ -3439,13 +3497,23 @@ describe("CSAT survey banner", () => {
expect(answerButtonTwo).toBeInTheDocument();
});
it("displays the follow-up CSAT survey banner link", async () => {
it("displays the follow-up “automatic removal” CSAT survey banner link", async () => {
const user = userEvent.setup();
const ComposedDashboard = composeStory(
DashboardUsPremiumResolvedScanNoBreaches,
Meta,
);
render(<ComposedDashboard elapsedTimeInDaysSinceInitialScan={185} />);
render(
<ComposedDashboard
elapsedTimeInDaysSinceInitialScan={185}
experimentData={{
...defaultExperimentData,
"automatic-removal-csat-survey": {
enabled: true,
},
}}
/>,
);
const fixedTab = screen.getByText("Fixed");
await user.click(fixedTab);
@ -3461,4 +3529,74 @@ describe("CSAT survey banner", () => {
);
expect(feedbackLink).toBeInTheDocument();
});
it("displays the “latest scan date” CSAT survey banner over the “automatic removal” CSAT survey if a user is targeted by both", async () => {
const mockedRecord = useTelemetry();
const user = userEvent.setup();
const ComposedDashboard = composeStory(
DashboardUsPremiumResolvedScanNoBreaches,
Meta,
);
render(
<ComposedDashboard
elapsedTimeInDaysSinceInitialScan={1}
experimentData={{
...defaultExperimentData,
"automatic-removal-csat-survey": {
enabled: true,
},
"last-scan-date": {
enabled: true,
},
"last-scan-date-csat-survey": {
enabled: true,
},
}}
/>,
);
const fixedTab = screen.getByText("Fixed");
await user.click(fixedTab);
const answerButton = screen.getByRole("button", {
name: "Very satisfied",
});
await user.click(answerButton);
expect(mockedRecord).toHaveBeenLastCalledWith(
"button",
"click",
expect.objectContaining({
button_id: "csat_survey_latest_scan_date_plus-user_very-satisfied",
}),
);
});
it("confirms that the “automatic removal” CSAT survey banner has been skipped in favour of the “latest scan date” CSAT survey if a user is targeted by both", () => {
const ComposedDashboard = composeStory(
DashboardUsPremiumResolvedScanNoBreaches,
Meta,
);
render(
<ComposedDashboard
activeTab="fixed"
elapsedTimeInDaysSinceInitialScan={1}
experimentData={{
...defaultExperimentData,
"automatic-removal-csat-survey": {
enabled: true,
},
"last-scan-date": {
enabled: true,
},
"last-scan-date-csat-survey": {
enabled: true,
},
}}
/>,
);
const cookies = new Cookies(null, { path: "/" });
expect(cookies.get("csat_survey_initial_dismissed")).toBeDefined();
});
});

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

@ -41,7 +41,7 @@ import ScanProgressIllustration from "./images/scan-illustration.svg";
import { CountryCodeContext } from "../../../../../../../contextProviders/country-code";
import { FeatureFlagName } from "../../../../../../../db/tables/featureFlags";
import { getNextGuidedStep } from "../../../../../../functions/server/getRelevantGuidedSteps";
import { CsatSurvey } from "../../../../../../components/client/CsatSurvey";
import { CsatSurvey } from "../../../../../../components/client/csat_survey/CsatSurvey";
import { WaitlistDialog } from "../../../../../../components/client/SubscriberWaitlistDialog";
import { useOverlayTriggerState } from "react-stately";
import { useOverlayTrigger } from "react-aria";
@ -415,12 +415,6 @@ export const View = (props: Props) => {
);
};
const showCsatSurvey =
hasPremium(props.user) &&
props.enabledFeatureFlags.includes("CsatSurvey") &&
activeTab === "fixed" &&
typeof props.elapsedTimeInDaysSinceInitialScan !== "undefined";
return (
<div className={styles.wrapper}>
<Toolbar
@ -446,17 +440,18 @@ export const View = (props: Props) => {
selectedKey={activeTab}
/>
</Toolbar>
{showCsatSurvey &&
typeof props.elapsedTimeInDaysSinceInitialScan !== "undefined" && (
<CsatSurvey
elapsedTimeInDaysSinceInitialScan={
props.elapsedTimeInDaysSinceInitialScan
}
hasAutoFixedDataBrokers={
dataSummary.dataBrokerAutoFixedDataPointsNum > 0
}
/>
)}
<CsatSurvey
activeTab={activeTab}
elapsedTimeInDaysSinceInitialScan={
props.elapsedTimeInDaysSinceInitialScan
}
experimentData={props.experimentData}
hasAutoFixedDataBrokers={
dataSummary.dataBrokerAutoFixedDataPointsNum > 0
}
user={props.user}
/>
<div className={styles.dashboardContent}>
<DashboardTopBanner
tabType={activeTab}

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

@ -290,7 +290,7 @@ const Plans = (props: Props) => {
}
return (
<div className={styles.plans}>
<div id="pricing" className={styles.plans}>
<h2 id={headingId} className={styles.planName}>
{props.l10n.getString("landing-premium-plans-heading")}
</h2>

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

@ -114,7 +114,7 @@ export const BreachDetailsView = (props: Props) => {
{typeof breach.Domain === "string" && breach.Domain.length > 0 ? (
<p>
<TelemetryLink
href={breach.Domain}
href={`https://${breach.Domain}`}
eventData={{ link_id: breach.Domain }}
target="_blank"
rel="noopener noreferrer"

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

@ -1,184 +0,0 @@
/* 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/. */
"use client";
import { useState } from "react";
import { Button } from "./Button";
import { CloseBtn, OpenInNew } from "../server/Icons";
import { useL10n } from "../../hooks/l10n";
import { useLocalDismissal } from "../../hooks/useLocalDismissal";
import { useHasRenderedClientSide } from "../../hooks/useHasRenderedClientSide";
import { useTelemetry } from "../../hooks/useTelemetry";
import styles from "./CsatSurvey.module.scss";
const surveyResponses = [
"very-dissatisfied",
"dissatisfied",
"neutral",
"satisfied",
"very-satisfied",
] as const;
type SurveyResponse = (typeof surveyResponses)[number];
type SurveyLinks = Record<SurveyResponse, string>;
type SurveyTypes = "initial" | "3-months" | "6-months" | "12-months";
type Survey = {
id: SurveyTypes;
daysThreshold: number;
options: SurveyLinks;
};
const surveys: Survey[] = [
{
id: "initial",
daysThreshold: 0,
options: {
"very-dissatisfied":
"https://survey.alchemer.com/s3/7714663/69d629a9e8e6",
dissatisfied: "https://survey.alchemer.com/s3/7714663/481c08f43d81",
neutral: "https://survey.alchemer.com/s3/7714663/6a94888b165c",
satisfied: "https://survey.alchemer.com/s3/7714663/996da64e2fbb",
"very-satisfied": "https://survey.alchemer.com/s3/7714663/668f6cb4d250",
},
},
{
id: "3-months",
daysThreshold: 90,
options: {
"very-dissatisfied":
"https://survey.alchemer.com/s3/7718223/9bf87045f7fb",
dissatisfied: "https://survey.alchemer.com/s3/7718223/4ebd39f49be3",
neutral: "https://survey.alchemer.com/s3/7718223/fe77a597f97a",
satisfied: "https://survey.alchemer.com/s3/7718223/fbbb597a762a",
"very-satisfied": "https://survey.alchemer.com/s3/7718223/8f7abc102a9a",
},
},
{
id: "6-months",
daysThreshold: 180,
options: {
"very-dissatisfied":
"https://survey.alchemer.com/s3/7718561/1354e1186d33",
dissatisfied: "https://survey.alchemer.com/s3/7718561/6dfb2e8b6d68",
neutral: "https://survey.alchemer.com/s3/7718561/2ff6ff90e603",
satisfied: "https://survey.alchemer.com/s3/7718561/9393c233103e",
"very-satisfied": "https://survey.alchemer.com/s3/7718561/a443cc84b78a",
},
},
{
id: "12-months",
daysThreshold: 351,
options: {
"very-dissatisfied":
"https://survey.alchemer.com/s3/7718562/c254fe9e3c33",
dissatisfied: "https://survey.alchemer.com/s3/7718562/8d2a7f93852f",
neutral: "https://survey.alchemer.com/s3/7718562/76e17004efd6",
satisfied: "https://survey.alchemer.com/s3/7718562/92b30b6aa491",
"very-satisfied": "https://survey.alchemer.com/s3/7718562/002e20b6b82f",
},
},
] as const;
type Props = {
elapsedTimeInDaysSinceInitialScan: number;
hasAutoFixedDataBrokers: boolean;
};
const getRelevantSurvey = ({
elapsedTimeInDaysSinceInitialScan,
hasAutoFixedDataBrokers,
}: Props): Survey | undefined => {
const relevantSurvey = surveys.findLast(
(survey) => elapsedTimeInDaysSinceInitialScan >= survey.daysThreshold,
);
// Show the initial survey only to users who have automatically fixed
// data broker results.
if (relevantSurvey?.id === "initial" && !hasAutoFixedDataBrokers) {
return;
}
return relevantSurvey;
};
export const CsatSurvey = (props: Props) => {
const l10n = useL10n();
const [answer, setAnswer] = useState<keyof SurveyLinks>();
const recordTelemetry = useTelemetry();
const hasRenderedClientSide = useHasRenderedClientSide();
const survey = getRelevantSurvey(props);
const localDismissal = useLocalDismissal(`survey-csat_${survey?.id}`);
if (
!hasRenderedClientSide ||
typeof survey === "undefined" ||
localDismissal.isDismissed
) {
return null;
}
const { dismiss } = localDismissal;
const submit = (satisfaction: SurveyResponse) => {
setAnswer(satisfaction);
dismiss({ soft: true });
recordTelemetry("button", "click", {
button_id: `csat_survey_${survey.id}_${satisfaction}`,
});
};
return (
<aside className={styles.wrapper}>
{typeof answer !== "undefined" ? (
<div className={styles.prompt}>
<a
href={survey.options[answer]}
onClick={() => dismiss()}
target="_blank"
rel="noopen noreferrer"
>
{l10n.getString("survey-csat-follow-up-link-label")}
<OpenInNew
alt={l10n.getString("open-in-new-tab-alt")}
width="13"
height="13"
/>
</a>
</div>
) : (
<>
<div className={styles.prompt}>
{l10n.getString("survey-csat-question")}
</div>
<ol className={`${styles.answers} noList`}>
{surveyResponses.map((response) => (
<li key={response}>
<Button
className={styles.answer}
variant="primary"
small
onPress={() => submit(response)}
>
{l10n.getString(`survey-csat-answer-${response}`)}
</Button>
</li>
))}
</ol>
</>
)}
<button className={styles.closeButton} onClick={() => dismiss()}>
<CloseBtn
alt={l10n.getString("survey-csat-survey-dismiss-label")}
width="14"
height="14"
/>
</button>
</aside>
);
};

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

@ -6,27 +6,31 @@ import { composeStory } from "@storybook/react";
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { axe } from "jest-axe";
import Meta, { CsatSurveyBanner } from "./stories/CsatBanner.stories";
import { useTelemetry } from "../../hooks/useTelemetry";
import { deleteAllCookies } from "../../functions/client/deleteAllCookies";
import Meta, {
CsatSurveyAutomaticRemoval,
CsatSurveyLatestScanDate,
} from "../stories/CsatSurvey.stories";
import { useTelemetry } from "../../../hooks/useTelemetry";
import { deleteAllCookies } from "../../../functions/client/deleteAllCookies";
import { createUserWithPremiumSubscription } from "../../../../apiMocks/mockData";
jest.mock("../../hooks/useTelemetry");
jest.mock("../../../hooks/useTelemetry");
afterEach(() => {
// Make the CSAT banner show up again.
deleteAllCookies();
});
describe("CSAT survey", () => {
describe("CSAT survey banner: Automatic Removal", () => {
it("passes the axe accessibility test suite for CsatSurveyBanner", async () => {
const ComposedTextComboBox = composeStory(CsatSurveyBanner, Meta);
const { container } = render(<ComposedTextComboBox />);
const ComposedCsatSurvey = composeStory(CsatSurveyAutomaticRemoval, Meta);
const { container } = render(<ComposedCsatSurvey />);
expect(await axe(container)).toHaveNoViolations();
});
it("displays the survey to users with automatic data removal enabled for less than 90 days", () => {
const ComposedCsatSurveyBanner = composeStory(CsatSurveyBanner, Meta);
render(<ComposedCsatSurveyBanner elapsedTimeInDaysSinceInitialScan={89} />);
const ComposedCsatSurvey = composeStory(CsatSurveyAutomaticRemoval, Meta);
render(<ComposedCsatSurvey elapsedTimeInDaysSinceInitialScan={89} />);
const answerButton = screen.getByRole("button", {
name: "Dissatisfied",
@ -37,11 +41,9 @@ describe("CSAT survey", () => {
it.each([90, 180, 351])(
"displays the survey to users with automatic data removal enabled for at least n days",
(dayCount) => {
const ComposedCsatSurveyBanner = composeStory(CsatSurveyBanner, Meta);
const ComposedCsatSurvey = composeStory(CsatSurveyAutomaticRemoval, Meta);
render(
<ComposedCsatSurveyBanner
elapsedTimeInDaysSinceInitialScan={dayCount}
/>,
<ComposedCsatSurvey elapsedTimeInDaysSinceInitialScan={dayCount} />,
);
const answerButton = screen.getByRole("button", {
@ -53,8 +55,8 @@ describe("CSAT survey", () => {
it("shows the correct follow-up feedback link for response “Very dissatisfied”", async () => {
const user = userEvent.setup();
const ComposedCsatSurveyBanner = composeStory(CsatSurveyBanner, Meta);
render(<ComposedCsatSurveyBanner elapsedTimeInDaysSinceInitialScan={91} />);
const ComposedCsatSurvey = composeStory(CsatSurveyAutomaticRemoval, Meta);
render(<ComposedCsatSurvey elapsedTimeInDaysSinceInitialScan={91} />);
const answerButton = screen.getByRole("button", {
name: "Very dissatisfied",
@ -73,10 +75,8 @@ describe("CSAT survey", () => {
it("shows the correct follow-up feedback link for response “Dissatisfied”", async () => {
const user = userEvent.setup();
const ComposedCsatSurveyBanner = composeStory(CsatSurveyBanner, Meta);
render(
<ComposedCsatSurveyBanner elapsedTimeInDaysSinceInitialScan={180} />,
);
const ComposedCsatSurvey = composeStory(CsatSurveyAutomaticRemoval, Meta);
render(<ComposedCsatSurvey elapsedTimeInDaysSinceInitialScan={180} />);
const answerButton = screen.getByRole("button", {
name: "Dissatisfied",
@ -94,10 +94,8 @@ describe("CSAT survey", () => {
it("shows the correct follow-up feedback link for response “Neutral”", async () => {
const user = userEvent.setup();
const ComposedCsatSurveyBanner = composeStory(CsatSurveyBanner, Meta);
render(
<ComposedCsatSurveyBanner elapsedTimeInDaysSinceInitialScan={351} />,
);
const ComposedCsatSurvey = composeStory(CsatSurveyAutomaticRemoval, Meta);
render(<ComposedCsatSurvey elapsedTimeInDaysSinceInitialScan={351} />);
const answerButton = screen.getByRole("button", {
name: "Neutral",
@ -115,8 +113,8 @@ describe("CSAT survey", () => {
it("shows the correct follow-up feedback link for response “Satisfied”", async () => {
const user = userEvent.setup();
const ComposedCsatSurveyBanner = composeStory(CsatSurveyBanner, Meta);
render(<ComposedCsatSurveyBanner elapsedTimeInDaysSinceInitialScan={91} />);
const ComposedCsatSurvey = composeStory(CsatSurveyAutomaticRemoval, Meta);
render(<ComposedCsatSurvey elapsedTimeInDaysSinceInitialScan={91} />);
const answerButton = screen.getByRole("button", {
name: "Satisfied",
@ -135,10 +133,8 @@ describe("CSAT survey", () => {
it("shows the correct follow-up feedback link for response “Very satisfied”", async () => {
const user = userEvent.setup();
const ComposedCsatSurveyBanner = composeStory(CsatSurveyBanner, Meta);
render(
<ComposedCsatSurveyBanner elapsedTimeInDaysSinceInitialScan={180} />,
);
const ComposedCsatSurvey = composeStory(CsatSurveyAutomaticRemoval, Meta);
render(<ComposedCsatSurvey elapsedTimeInDaysSinceInitialScan={180} />);
const answerButton = screen.getByRole("button", {
name: "Very satisfied",
@ -157,10 +153,8 @@ describe("CSAT survey", () => {
it("records telemetry when submitting the survey", async () => {
const mockedRecord = useTelemetry();
const user = userEvent.setup();
const ComposedCsatSurveyBanner = composeStory(CsatSurveyBanner, Meta);
render(
<ComposedCsatSurveyBanner elapsedTimeInDaysSinceInitialScan={180} />,
);
const ComposedCsatSurvey = composeStory(CsatSurveyAutomaticRemoval, Meta);
render(<ComposedCsatSurvey elapsedTimeInDaysSinceInitialScan={180} />);
const answerButton = screen.getByRole("button", {
name: "Very satisfied",
@ -178,8 +172,8 @@ describe("CSAT survey", () => {
it("dismisses the survey by clicking the “close” button", async () => {
const user = userEvent.setup();
const ComposedCsatSurveyBanner = composeStory(CsatSurveyBanner, Meta);
render(<ComposedCsatSurveyBanner />);
const ComposedCsatSurvey = composeStory(CsatSurveyAutomaticRemoval, Meta);
render(<ComposedCsatSurvey />);
const dismissButton = screen.getByRole("button", {
name: "Dismiss",
@ -194,10 +188,8 @@ describe("CSAT survey", () => {
it("dismisses the survey by clicking the follow-up link", async () => {
const user = userEvent.setup();
const ComposedCsatSurveyBanner = composeStory(CsatSurveyBanner, Meta);
render(
<ComposedCsatSurveyBanner elapsedTimeInDaysSinceInitialScan={180} />,
);
const ComposedCsatSurvey = composeStory(CsatSurveyAutomaticRemoval, Meta);
render(<ComposedCsatSurvey elapsedTimeInDaysSinceInitialScan={180} />);
const answerButton = screen.getByRole("button", {
name: "Very satisfied",
@ -215,3 +207,87 @@ describe("CSAT survey", () => {
expect(feedbackLinkTwo).not.toBeInTheDocument();
});
});
describe("CSAT survey banner: Latest scan date", () => {
it("passes the axe accessibility test suite for CsatSurveyBanner", async () => {
const ComposedCsatSurvey = composeStory(CsatSurveyLatestScanDate, Meta);
const { container } = render(<ComposedCsatSurvey />);
expect(await axe(container)).toHaveNoViolations();
});
it("displays the survey to free users on the “action needed” tab", () => {
const ComposedCsatSurvey = composeStory(CsatSurveyLatestScanDate, Meta);
const user = createUserWithPremiumSubscription();
if (user.fxa) {
user.fxa.subscriptions = [];
}
render(<ComposedCsatSurvey activeTab="action-needed" user={user} />);
const answerButton = screen.getByRole("button", {
name: "Satisfied",
});
expect(answerButton).toBeInTheDocument();
});
it("displays the survey to Plus users on the “fixed” tab", () => {
const ComposedCsatSurvey = composeStory(CsatSurveyLatestScanDate, Meta);
render(<ComposedCsatSurvey />);
const answerButton = screen.getByRole("button", {
name: "Dissatisfied",
});
expect(answerButton).toBeInTheDocument();
});
it("dismisses the survey by clicking the “close” button", async () => {
const user = userEvent.setup();
const ComposedCsatSurvey = composeStory(CsatSurveyLatestScanDate, Meta);
render(<ComposedCsatSurvey />);
const dismissButton = screen.getByRole("button", {
name: "Dismiss",
});
await user.click(dismissButton);
const answerButton = screen.queryByRole("button", {
name: "Neutral",
});
expect(answerButton).not.toBeInTheDocument();
});
it("records telemetry when submitting the survey", async () => {
const mockedRecord = useTelemetry();
const user = userEvent.setup();
const ComposedCsatSurvey = composeStory(CsatSurveyLatestScanDate, Meta);
render(<ComposedCsatSurvey />);
const answerButton = screen.getByRole("button", {
name: "Very satisfied",
});
await user.click(answerButton);
expect(mockedRecord).toHaveBeenCalledWith(
"button",
"click",
expect.objectContaining({
button_id: "csat_survey_latest_scan_date_plus-user_very-satisfied",
}),
);
});
it("does not show a follow-up survey after submitting the survery", async () => {
const user = userEvent.setup();
const ComposedCsatSurvey = composeStory(CsatSurveyLatestScanDate, Meta);
render(<ComposedCsatSurvey />);
const answerButtonOne = screen.getByRole("button", {
name: "Very satisfied",
});
await user.click(answerButtonOne);
const feedbackLink = screen.queryByText(
/Your feedback is helpful to us! How can we improve Monitor for you\?/i,
);
expect(feedbackLink).not.toBeInTheDocument();
});
});

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

@ -0,0 +1,81 @@
/* 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/. */
"use client";
import { Session } from "next-auth";
import { Cookies } from "react-cookie";
import { CsatSurveyBanner } from "./CsatSurveyBanner";
import { TabType } from "../../../(proper_react)/(redesign)/(authenticated)/user/(dashboard)/dashboard/View";
import { getAutomaticRemovalCsatSurvey } from "./surveys/automaticRemovalCsatSurvey";
import { getLatestScanDateCsatSurvey } from "./surveys/latestScanDateCsatSurvey";
import { COOKIE_DISMISSAL_MAX_AGE_IN_SECONDS } from "../../../hooks/useLocalDismissal";
import { ExperimentData } from "../../../../telemetry/generated/nimbus/experiments";
export type CsatSurveyProps = {
activeTab: TabType;
user: Session["user"];
experimentData: ExperimentData;
hasAutoFixedDataBrokers: boolean;
elapsedTimeInDaysSinceInitialScan?: number;
};
export const CsatSurvey = (props: CsatSurveyProps) => {
const surveyOptions = {
activeTab: props.activeTab,
experimentData: props.experimentData,
user: props.user,
};
// The order of the surveys here matter: If there are multiple matching
// surveys for the user we dismiss all surveys, but the last one in the list.
const surveys = [
getAutomaticRemovalCsatSurvey({
...surveyOptions,
elapsedTimeInDaysSinceInitialScan:
props.elapsedTimeInDaysSinceInitialScan,
hasAutoFixedDataBrokers: props.hasAutoFixedDataBrokers,
}),
getLatestScanDateCsatSurvey(surveyOptions),
];
// Filters out previously dismissed surveys to make sure `currentSurvey` will
// always be relevant to show for the user.
const cookies = new Cookies(null, { path: "/" });
const filteredSurveys = surveys.filter((survey) => {
if (!survey) {
return;
}
const cookieDismissalId = `${survey.localDismissalId}_dismissed`;
const surveyIsDismissed = cookies.get(cookieDismissalId);
return !surveyIsDismissed;
});
const currentSurvey =
filteredSurveys.length > 0 && filteredSurveys.slice(-1)[0];
if (!currentSurvey) {
return;
}
// Mark all surveys except the current one as automatically dismissed.
filteredSurveys.forEach((survey) => {
if (
survey &&
survey?.localDismissalId !== currentSurvey?.localDismissalId
) {
const cookieDismissalId = `${survey.localDismissalId}_dismissed`;
cookies.set(cookieDismissalId, Date.now().toString(), {
maxAge: COOKIE_DISMISSAL_MAX_AGE_IN_SECONDS,
});
}
});
return (
currentSurvey && (
<CsatSurveyBanner
key={currentSurvey.localDismissalId}
localDismissalId={currentSurvey.localDismissalId}
survey={currentSurvey.survey}
/>
)
);
};

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

@ -1,4 +1,4 @@
@import "../../tokens";
@import "../../../tokens";
.wrapper {
align-items: center;

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

@ -0,0 +1,111 @@
/* 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/. */
"use client";
import { useState } from "react";
import { Button } from "../Button";
import { CloseBtn, OpenInNew } from "../../server/Icons";
import { useL10n } from "../../../hooks/l10n";
import { useLocalDismissal } from "../../../hooks/useLocalDismissal";
import { useHasRenderedClientSide } from "../../../hooks/useHasRenderedClientSide";
import { useTelemetry } from "../../../hooks/useTelemetry";
import styles from "./CsatSurveyBanner.module.scss";
import { Survey } from "./surveys/csatSurvey";
const surveyResponses = [
"very-dissatisfied",
"dissatisfied",
"neutral",
"satisfied",
"very-satisfied",
] as const;
type SurveyResponse = (typeof surveyResponses)[number];
type SurveyLinks = Record<SurveyResponse, string>;
type Props = {
localDismissalId: string;
survey: Survey;
};
export const CsatSurveyBanner = ({ localDismissalId, survey }: Props) => {
const l10n = useL10n();
const [answer, setAnswer] = useState<keyof SurveyLinks>();
const recordTelemetry = useTelemetry();
const hasRenderedClientSide = useHasRenderedClientSide();
const localDismissal = useLocalDismissal(localDismissalId);
if (
!hasRenderedClientSide ||
typeof survey === "undefined" ||
localDismissal.isDismissed
) {
return null;
}
const { dismiss } = localDismissal;
const hasFollowUpSurveyOptions =
"followUpSurveyOptions" in survey &&
typeof survey.followUpSurveyOptions !== "undefined";
const submit = (satisfaction: SurveyResponse) => {
setAnswer(satisfaction);
dismiss({ soft: hasFollowUpSurveyOptions });
recordTelemetry("button", "click", {
button_id: `${localDismissalId}_${satisfaction}`,
});
};
return (
<aside className={styles.wrapper}>
{typeof answer !== "undefined" && hasFollowUpSurveyOptions ? (
<div className={styles.prompt}>
<a
href={(survey.followUpSurveyOptions as SurveyLinks)[answer]}
onClick={() => dismiss()}
target="_blank"
rel="noopen noreferrer"
>
{l10n.getString("survey-csat-follow-up-link-label")}
<OpenInNew
alt={l10n.getString("open-in-new-tab-alt")}
width="13"
height="13"
/>
</a>
</div>
) : (
<>
<div className={styles.prompt}>
{l10n.getString("survey-csat-question")}
</div>
<ol className={`${styles.answers} noList`}>
{surveyResponses.map((response) => (
<li key={response}>
<Button
className={styles.answer}
variant="primary"
small
onPress={() => submit(response)}
>
{l10n.getString(`survey-csat-answer-${response}`)}
</Button>
</li>
))}
</ol>
</>
)}
<button className={styles.closeButton} onClick={() => dismiss()}>
<CloseBtn
alt={l10n.getString("survey-csat-survey-dismiss-label")}
width="14"
height="14"
/>
</button>
</aside>
);
};

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

@ -0,0 +1,113 @@
/* 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/. */
import { TabType } from "../../../../(proper_react)/(redesign)/(authenticated)/user/(dashboard)/dashboard/View";
import {
CsatSurveyProps,
RelevantSurvey,
SurveyData,
SurveyLinks,
UserType,
getRelevantSurveys,
} from "./csatSurvey";
export type AutomaticRemovalVariation = {
id: "initial" | "3-months" | "6-months" | "12-months";
showForUser: UserType[];
showOnTab: TabType[];
daysThreshold: number;
followUpSurveyOptions: SurveyLinks;
};
const surveyData: SurveyData = {
id: "csat_survey",
requiredExperimentIds: ["automatic-removal-csat-survey"],
variations: [
{
id: "initial",
showForUser: ["plus-user"],
showOnTab: ["fixed"],
daysThreshold: 0,
followUpSurveyOptions: {
"very-dissatisfied":
"https://survey.alchemer.com/s3/7714663/69d629a9e8e6",
dissatisfied: "https://survey.alchemer.com/s3/7714663/481c08f43d81",
neutral: "https://survey.alchemer.com/s3/7714663/6a94888b165c",
satisfied: "https://survey.alchemer.com/s3/7714663/996da64e2fbb",
"very-satisfied": "https://survey.alchemer.com/s3/7714663/668f6cb4d250",
},
},
{
id: "3-months",
showForUser: ["plus-user"],
showOnTab: ["fixed"],
daysThreshold: 90,
followUpSurveyOptions: {
"very-dissatisfied":
"https://survey.alchemer.com/s3/7718223/9bf87045f7fb",
dissatisfied: "https://survey.alchemer.com/s3/7718223/4ebd39f49be3",
neutral: "https://survey.alchemer.com/s3/7718223/fe77a597f97a",
satisfied: "https://survey.alchemer.com/s3/7718223/fbbb597a762a",
"very-satisfied": "https://survey.alchemer.com/s3/7718223/8f7abc102a9a",
},
},
{
id: "6-months",
showForUser: ["plus-user"],
showOnTab: ["fixed"],
daysThreshold: 180,
followUpSurveyOptions: {
"very-dissatisfied":
"https://survey.alchemer.com/s3/7718561/1354e1186d33",
dissatisfied: "https://survey.alchemer.com/s3/7718561/6dfb2e8b6d68",
neutral: "https://survey.alchemer.com/s3/7718561/2ff6ff90e603",
satisfied: "https://survey.alchemer.com/s3/7718561/9393c233103e",
"very-satisfied": "https://survey.alchemer.com/s3/7718561/a443cc84b78a",
},
},
{
id: "12-months",
showForUser: ["plus-user"],
showOnTab: ["fixed"],
daysThreshold: 351,
followUpSurveyOptions: {
"very-dissatisfied":
"https://survey.alchemer.com/s3/7718562/c254fe9e3c33",
dissatisfied: "https://survey.alchemer.com/s3/7718562/8d2a7f93852f",
neutral: "https://survey.alchemer.com/s3/7718562/76e17004efd6",
satisfied: "https://survey.alchemer.com/s3/7718562/92b30b6aa491",
"very-satisfied": "https://survey.alchemer.com/s3/7718562/002e20b6b82f",
},
},
],
};
const getAutomaticRemovalCsatSurvey = (
props: CsatSurveyProps & {
elapsedTimeInDaysSinceInitialScan: number | undefined;
hasAutoFixedDataBrokers: boolean;
},
): RelevantSurvey | null => {
const surveys = getRelevantSurveys({ ...surveyData, ...props });
// Find the last survey variation that matches the time since the users
// automatic removal.
const relevantSurvey =
surveys &&
surveys.findLast((surveyVariation) => {
const survey = surveyVariation.survey as AutomaticRemovalVariation;
// Show the initial survey only to users who have automatically fixed
// data broker results.
if (survey?.id === "initial" && !props.hasAutoFixedDataBrokers) {
return;
}
return (
typeof props.elapsedTimeInDaysSinceInitialScan !== "undefined" &&
props.elapsedTimeInDaysSinceInitialScan >= survey.daysThreshold
);
});
return relevantSurvey ?? null;
};
export { getAutomaticRemovalCsatSurvey };

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

@ -0,0 +1,80 @@
/* 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/. */
import { Session } from "next-auth";
import { TabType } from "../../../../(proper_react)/(redesign)/(authenticated)/user/(dashboard)/dashboard/View";
import { hasPremium } from "../../../../functions/universal/user";
import { AutomaticRemovalVariation } from "./automaticRemovalCsatSurvey";
import { ExperimentData } from "../../../../../telemetry/generated/nimbus/experiments";
const surveyResponses = [
"very-dissatisfied",
"dissatisfied",
"neutral",
"satisfied",
"very-satisfied",
] as const;
export type SurveyResponse = (typeof surveyResponses)[number];
export type SurveyType = "csat_survey" | "csat_survey_latest_scan_date";
export type UserType = "free-user" | "plus-user";
export type SurveyLinks = Record<SurveyResponse, string>;
export type SurveyData = {
id: SurveyType;
requiredExperimentIds: (keyof ExperimentData)[];
variations: Survey[];
};
export type LatestScanDateVariation = {
id: UserType;
showForUser: UserType[];
showOnTab: TabType[];
};
export type Survey = AutomaticRemovalVariation | LatestScanDateVariation;
export type CsatSurveyProps = {
activeTab: TabType;
experimentData: ExperimentData;
user: Session["user"];
};
export type RelevantSurvey = {
localDismissalId: string;
survey: Survey;
};
export function getRelevantSurveys({
id,
requiredExperimentIds,
variations,
activeTab,
experimentData,
user,
}: SurveyData & CsatSurveyProps): RelevantSurvey[] | null {
if (
!requiredExperimentIds.every(
(experimentId) => experimentData[experimentId].enabled,
)
) {
return null;
}
const filteredSurveys = variations.filter((surveyVariation) => {
const isRelevantUser = surveyVariation.showForUser.includes(
hasPremium(user) ? "plus-user" : "free-user",
);
const isRelevantTab = surveyVariation.showOnTab.includes(activeTab);
return isRelevantUser && isRelevantTab;
});
return filteredSurveys.map((survey) => ({
localDismissalId: `${id}_${survey.id}`,
survey,
}));
}

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

@ -0,0 +1,38 @@
/* 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/. */
import {
CsatSurveyProps,
RelevantSurvey,
SurveyData,
getRelevantSurveys,
} from "./csatSurvey";
const surveyData: SurveyData = {
id: "csat_survey_latest_scan_date",
requiredExperimentIds: ["last-scan-date", "last-scan-date-csat-survey"],
variations: [
{
id: "free-user",
showForUser: ["free-user"],
showOnTab: ["action-needed"],
},
{
id: "plus-user",
showForUser: ["plus-user"],
showOnTab: ["fixed"],
},
],
};
const getLatestScanDateCsatSurvey = (
props: CsatSurveyProps,
): RelevantSurvey | null => {
const surveys = getRelevantSurveys({ ...surveyData, ...props });
// In case there are multiple matching survey variations for the current user:
// Return the first one.
return surveys && surveys.length > 0 ? surveys[0] : null;
};
export { getLatestScanDateCsatSurvey };

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

@ -1,22 +0,0 @@
/* 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/. */
import type { Meta, StoryObj } from "@storybook/react";
import { CsatSurvey } from "../CsatSurvey";
const meta: Meta<typeof CsatSurvey> = {
title: "CsatSurvey",
component: CsatSurvey,
};
export default meta;
type Story = StoryObj<typeof CsatSurvey>;
export const CsatSurveyBanner: Story = {
name: "CsatSurvey",
args: {
elapsedTimeInDaysSinceInitialScan: 0,
hasAutoFixedDataBrokers: true,
},
};

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

@ -0,0 +1,49 @@
/* 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/. */
import type { Meta, StoryObj } from "@storybook/react";
import { CsatSurvey } from "../csat_survey/CsatSurvey";
import { createUserWithPremiumSubscription } from "../../../../apiMocks/mockData";
import { defaultExperimentData } from "../../../../telemetry/generated/nimbus/experiments";
const meta: Meta<typeof CsatSurvey> = {
title: "CsatSurvey",
component: CsatSurvey,
};
export default meta;
type Story = StoryObj<typeof CsatSurvey>;
export const CsatSurveyAutomaticRemoval: Story = {
name: "AutomaticRemoval",
args: {
activeTab: "fixed",
user: createUserWithPremiumSubscription(),
experimentData: {
...defaultExperimentData,
"automatic-removal-csat-survey": {
enabled: true,
},
},
hasAutoFixedDataBrokers: true,
elapsedTimeInDaysSinceInitialScan: 0,
},
};
export const CsatSurveyLatestScanDate: Story = {
name: "LatestScanDate",
args: {
activeTab: "fixed",
user: createUserWithPremiumSubscription(),
experimentData: {
...defaultExperimentData,
"last-scan-date": {
enabled: true,
},
"last-scan-date-csat-survey": {
enabled: true,
},
},
},
};

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

@ -45,7 +45,7 @@ export async function getExperiments(params: {
context: {
// Nimbus takes a language, rather than a locale, hence the .split:
language: params.locale.split("-")[0],
region: params.countryCode,
region: params.countryCode.toUpperCase(),
},
}),
});

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

@ -3,7 +3,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { act, renderHook } from "@testing-library/react";
import { useLocalDismissal } from "./useLocalDismissal";
import {
COOKIE_DISMISSAL_MAX_AGE_IN_SECONDS,
useLocalDismissal,
} from "./useLocalDismissal";
import { useCookies } from "react-cookie";
jest.mock("react-cookie", () => {
@ -71,7 +74,7 @@ describe("useLocalDismissal", () => {
expect(mockedSetCookie).toHaveBeenCalledWith(
"some_key_dismissed",
expect.any(String),
{ maxAge: 100 * 365 * 24 * 60 * 60 },
{ maxAge: COOKIE_DISMISSAL_MAX_AGE_IN_SECONDS },
);
});
@ -117,7 +120,7 @@ describe("useLocalDismissal", () => {
expect(mockedSetCookie).toHaveBeenCalledWith(
"some_key_dismissed",
expect.any(String),
{ maxAge: 100 * 365 * 24 * 60 * 60 },
{ maxAge: COOKIE_DISMISSAL_MAX_AGE_IN_SECONDS },
);
});

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

@ -5,6 +5,8 @@
import { useState } from "react";
import { useCookies } from "react-cookie";
export const COOKIE_DISMISSAL_MAX_AGE_IN_SECONDS = 100 * 365 * 24 * 60 * 60;
export type DismissOptions = {
/** If true, the dismissal won't take effect right away, but the cookie to store the dismissal _will_ be set. */
soft?: boolean;
@ -47,7 +49,7 @@ export function useLocalDismissal(
// tests.
typeof options.duration === "number"
? options.duration
: 100 * 365 * 24 * 60 * 60;
: COOKIE_DISMISSAL_MAX_AGE_IN_SECONDS;
setCookie(cookieId, Date.now().toString(), {
maxAge: maxAgeInSeconds,
});

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

@ -0,0 +1,23 @@
/* 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/. */
export async function up(knex) {
return knex.schema
.createTable("subscriber_coupons", table => {
table.increments('id').primary()
table.integer("subscriber_id").references("subscribers.id").notNullable().onDelete("CASCADE").onUpdate("CASCADE");
table.string("coupon_code").notNullable()
table.timestamp("created_at").defaultTo(knex.fn.now())
table.unique(["subscriber_id", "coupon_code"])
})
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export async function down(knex) {
return knex.schema
.dropTableIfExists("subscriber_coupons")
}

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

@ -42,7 +42,6 @@ export type FeatureFlagName =
| "RedesignedEmails"
| "UpdatedEmailPreferencesOption"
| "MonthlyActivityEmail"
| "CsatSurvey"
| "CancellationFlow"
| "ConfirmCancellation"
| "FirstDataBrokerRemovalFixedEmail";

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

@ -88,7 +88,9 @@ async function getLatestOnerepScanResults(
"onerep_scans",
"onerep_scan_results.onerep_scan_id",
"onerep_scans.onerep_scan_id",
)) as OnerepScanResultRow[]);
)
.orderBy("link")
.orderBy("onerep_scan_result_id", "desc")) as OnerepScanResultRow[]);
return {
scan: scan ?? null,

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

@ -0,0 +1,52 @@
/* 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/. */
import createDbConnection from "../connect.js";
import { logger } from "../../app/functions/server/logging";
import { SubscriberCouponRow } from "knex/types/tables";
const knex = createDbConnection();
async function checkCouponForSubscriber(
subscriberId: number,
couponCode: string,
) {
logger.info("checkCouponForSubscriber", subscriberId);
return !!(await knex("subscriber_coupons")
.where({
subscriber_id: subscriberId,
coupon_code: couponCode,
})
.first());
}
async function addCouponForSubscriber(
subscriberId: number,
couponCode: string,
) {
logger.info("addCouponForSubscriber", { subscriberId, couponCode });
let res;
try {
res = await knex("subscribers_coupon")
.insert({
subscriber_id: subscriberId,
coupon_code: couponCode,
})
.returning("*");
} catch (e) {
if ((e as Error).message.includes("violates unique constraint")) {
logger.error("could_not_add_coupon", {
subscriberId,
error: (e as Error).message,
});
} else {
logger.error("could_not_add_coupon", { error: JSON.stringify(e) });
}
throw e;
}
return res?.[0] as SubscriberCouponRow;
}
export { checkCouponForSubscriber, addCouponForSubscriber };

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

@ -13,6 +13,7 @@ export class AuthPage {
readonly continueButton: Locator;
readonly ageInputField: Locator;
readonly verifyCodeInputField: Locator;
readonly useDifferentEmailButton: Locator;
constructor(page: Page) {
this.page = page;
@ -22,6 +23,7 @@ export class AuthPage {
this.ageInputField = page.getByLabel("How old are you?");
this.continueButton = page.locator('[type="submit"]').first();
this.verifyCodeInputField = page.locator("div.card input");
this.useDifferentEmailButton = page.locator("#use-different");
}
async continue({ waitForURL = "**/*" }) {
@ -39,16 +41,16 @@ export class AuthPage {
await this.continue({ waitForURL: "**/oauth/**" });
}
async enterPassword() {
await this.passwordInputField.fill(
process.env.E2E_TEST_ACCOUNT_PASSWORD as string,
);
async enterPassword(optionalPassword?: string) {
const password =
optionalPassword || (process.env.E2E_TEST_ACCOUNT_PASSWORD as string);
await this.passwordInputField.fill(password);
await this.continue({ waitForURL: "**/user/**" });
}
async signIn(email: string) {
async signIn(email: string, optionalPassword?: string) {
await this.enterEmail(email);
await this.enterPassword();
await this.enterPassword(optionalPassword);
}
async signUp(email: string, page: Page) {

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

@ -52,7 +52,11 @@ export class DashboardPage {
readonly servicesPocket: Locator;
readonly servicesFirefoxDesktop: Locator;
readonly servicesFirefoxMobile: Locator;
readonly servicesMozilla: Locator;
readonly appsAndServicesMenu: Locator;
readonly profileSettings: Locator;
readonly profileSignOut: Locator;
readonly profileEmail: Locator;
readonly manageProfile: Locator;
readonly helpAndSupport: Locator;
@ -75,6 +79,7 @@ export class DashboardPage {
readonly privacyNoticeFooter: Locator;
readonly githubFooter: Locator;
readonly overviewCard: Locator;
readonly overviewCardSummary: Locator;
readonly overviewCardFindings: Locator;
@ -135,6 +140,13 @@ export class DashboardPage {
this.actionNeededTab = page.getByRole("tab", { name: "Action needed" });
this.fixedTab = page.getByRole("tab", { name: "Fixed" });
this.profileButton = page.getByTitle("Profile").nth(1);
this.profileSettings = page.locator(
'a[title*="Configure"][title*="Mozilla Monitor"]',
);
this.profileSignOut = page.locator(
'button[title*="Sign out of"][title*="Mozilla Monitor"]',
);
this.profileEmail = page
.locator('//li[starts-with(@class, "UserMenu_menuItemWrapper")]/b')
.first();
@ -146,6 +158,7 @@ export class DashboardPage {
this.appsAndServices = page.getByRole("button", {
name: "Mozilla apps and services",
});
this.appsAndServicesMenu = page.locator("div[class*='AppPicker_popup']");
this.servicesVpn = page.getByRole("link", { name: "Mozilla VPN" });
this.servicesRelay = page.getByRole("link", { name: "Firefox Relay" });
this.servicesPocket = page.getByRole("link", { name: "Pocket" });
@ -155,6 +168,7 @@ export class DashboardPage {
this.servicesFirefoxMobile = page.getByRole("link", {
name: "Firefox for Mobile",
});
this.servicesMozilla = page.locator('[data-key="mozilla"] > a');
this.closeAppsAndServices = page.locator(
'//div[starts-with(@class, "Popover_underlay")]',
);
@ -228,6 +242,7 @@ export class DashboardPage {
//upsell button
this.upsellScreenButton = page.getByText(/Lets (keep going|fix it)/);
this.overviewCard = page.locator("[class*='DashboardTopBanner_container']");
this.overviewCardSummary = page.locator(
"[aria-label='Dashboard summary'] > div > p",
);

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

@ -8,11 +8,13 @@ export class DataBrokersPage {
readonly page: Page;
readonly removeThemForMeButton: Locator;
readonly xButton: Locator;
readonly forwardArrowButton: Locator;
constructor(page: Page) {
this.page = page;
this.removeThemForMeButton = page.getByText("Remove them for me");
this.xButton = page.getByLabel("Return to dashboard");
this.forwardArrowButton = page.getByLabel("Go to next step");
}
async open() {

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

@ -2,8 +2,9 @@
* 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/. */
import { Locator, Page, expect } from "@playwright/test";
import { BrowserContext, Locator, Page, expect } from "@playwright/test";
import { removeUnicodeChars } from "../utils/helpers";
import { DashboardPage } from "./dashBoardPage";
export class PurchasePage {
readonly page: Page;
@ -17,6 +18,7 @@ export class PurchasePage {
readonly returnToDashboardButton: Locator;
readonly goToNextStep: Locator;
readonly planDetails: Locator;
readonly paypalButton: Locator;
constructor(page: Page) {
this.page = page;
@ -37,6 +39,7 @@ export class PurchasePage {
this.returnToDashboardButton = page.getByLabel("Return to dashboard");
this.goToNextStep = page.getByLabel("Go to next step");
this.planDetails = page.locator(".plan-details-description");
this.paypalButton = page.getByTitle("PayPal").nth(1);
}
async fillOutStripeCardInfo() {
@ -68,6 +71,42 @@ export class PurchasePage {
await frame.fill(".InputElement[name=postal]", "77777");
}
async fillOutPaypalInfo(context: BrowserContext) {
const emailToUse = process.env.E2E_TEST_PAYPAL_LOGIN as string;
const pwdToUse = process.env.E2E_TEST_PAYPAL_PASSWORD as string;
expect(emailToUse).not.toBeUndefined();
expect(pwdToUse).not.toBeUndefined();
//away from mozilla
const pagePromise = context.waitForEvent("page");
await this.paypalButton.click();
const newPage = await pagePromise;
await newPage.waitForLoadState();
await newPage.waitForURL(/.*paypal\.com.*\/checkout.*/);
const emailPrompt = newPage.locator("#email");
await expect(emailPrompt).toBeVisible();
await emailPrompt.fill(emailToUse);
const nextButton = newPage.locator("#btnNext");
if (await nextButton.isVisible()) {
await newPage.locator("#btnNext").click();
await newPage.waitForSelector("#password", { state: "visible" });
}
const pwdInput = newPage.locator("#password");
await expect(pwdInput).toBeVisible();
await pwdInput.fill(pwdToUse);
await newPage.locator("#btnLogin").click();
await newPage.waitForURL(/^(?!.*checkoutnow).*/);
const saveAndContinue = newPage.locator("#consentButton");
await expect(saveAndContinue).toBeVisible();
await saveAndContinue.click();
//back to mozilla
}
async verifyMonthlyPlanDetails() {
await this.subscriptionHeader.waitFor();
const planDetails = removeUnicodeChars(
@ -86,4 +125,48 @@ export class PurchasePage {
`${process.env.E2E_TEST_ENV === "prod" ? "yearly" : "every 2 months"}`,
);
}
async gotoPurchaseFromDashboard(
dashboardPage: DashboardPage,
yearly: boolean,
) {
// navigate to subscription
await dashboardPage.open();
await dashboardPage.subscribeButton.click();
// verify user purchase choices
await expect(dashboardPage.subscribeDialogCloseButton).toBeVisible();
await expect(dashboardPage.yearlyMonthlyTablist).toBeVisible();
await dashboardPage.yearlyTab.click();
await expect(
dashboardPage.subscribeDialogSelectYearlyPlanLink,
).toBeVisible();
await dashboardPage.monthlyTab.click();
await expect(
dashboardPage.subscribeDialogSelectMonthlyPlanLink,
).toBeVisible();
if (yearly) {
await dashboardPage.yearlyTab.click();
await dashboardPage.subscribeDialogSelectYearlyPlanLink.click();
} else {
await dashboardPage.monthlyTab.click();
await dashboardPage.subscribeDialogSelectMonthlyPlanLink.click();
}
await this.subscriptionHeader.waitFor();
}
async postPaymentPageCheck(dashboardPage: DashboardPage) {
await this.page.getByText("Subscription confirmation").waitFor();
// navigate to confirmation
await this.getStartedButton.click();
await this.goToNextStep.click();
// confirm successful payment
await dashboardPage.plusSubscription.waitFor({
state: "attached",
timeout: 10000,
});
await expect(dashboardPage.plusSubscription).toBeVisible();
}
}

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

@ -5,7 +5,13 @@
import { Locator } from "@playwright/test";
import { test, expect } from "../fixtures/basePage.js";
import { DashboardPage } from "../pages/dashBoardPage.js";
import { checkAuthState, removeUnicodeChars } from "../utils/helpers.js";
import {
checkAuthState,
removeUnicodeChars,
clickOnATagCheckDomain,
escapeRegExp,
forceLoginAs,
} from "../utils/helpers.js";
// bypass login
test.use({ storageState: "./e2e/storageState.json" });
@ -127,6 +133,94 @@ test.describe(`${process.env.E2E_TEST_ENV} - Breaches Dashboard - Headers`, () =
await dashboardPage.popupCloseButton.click();
await expect(dashboardPage.aboutFixedExposuresPopup).toBeHidden();
});
test("Verify that the Apps and Services header options work correctly.", async ({
dashboardPage,
page,
}) => {
// link to testrail
test.info().annotations.push({
type: "testrail",
description:
"https://testrail.stage.mozaws.net/index.php?/cases/view/2463569",
});
await dashboardPage.fixedTab.click();
expect(page.url()).toMatch(/.*dashboard\/fixed\/?/);
await dashboardPage.actionNeededTab.click();
expect(page.url()).toMatch(/.*dashboard\/action-needed\/?/);
//apps and services button check
const clickOnLinkAndGoBack = async (
aTag: Locator,
host: string | RegExp = /.*/,
path: string | RegExp = /.*/,
) => {
await expect(dashboardPage.appsAndServices).toBeVisible();
await dashboardPage.appsAndServices.click();
await expect(dashboardPage.appsAndServicesMenu).toBeVisible();
await clickOnATagCheckDomain(aTag, host, path, page);
};
await clickOnLinkAndGoBack(
dashboardPage.servicesVpn,
"www.mozilla.org",
/.*\/products\/vpn\/?.*/,
);
await clickOnLinkAndGoBack(
dashboardPage.servicesRelay,
"relay.firefox.com",
);
await clickOnLinkAndGoBack(
dashboardPage.servicesPocket,
/getpocket\.com|apps\.apple\.com|app\.adjust\.com/,
/.*(\/pocket-and-firefox\/?).*|.*about.*|.*pocket-stay-informed.*/,
);
await clickOnLinkAndGoBack(
dashboardPage.servicesFirefoxDesktop,
"www.mozilla.org",
/.*\/firefox\/new\/?.*/,
);
await clickOnLinkAndGoBack(
dashboardPage.servicesFirefoxMobile,
"www.mozilla.org",
/.*\/browsers\/mobile\/?.*/,
);
await clickOnLinkAndGoBack(
dashboardPage.servicesMozilla,
"www.mozilla.org",
);
const openProfileMenuItem = async (
what: Locator,
whatUrl: string | RegExp,
) => {
await dashboardPage.open();
await dashboardPage.profileButton.click();
await expect(what).toBeVisible();
if (await what.evaluate((e) => e.hasAttribute("href"))) {
const href = await what.getAttribute("href");
expect(href).not.toBeNull();
await page.goto(href!);
} else {
await what.click();
}
await page.waitForURL(whatUrl);
};
await openProfileMenuItem(
dashboardPage.manageProfile,
/.*accounts.*settings.*/,
);
await openProfileMenuItem(
dashboardPage.profileSettings,
/.*\/user\/settings.*/,
);
const base_url = process.env["E2E_TEST_BASE_URL"];
expect(base_url).toBeTruthy();
await openProfileMenuItem(dashboardPage.profileSignOut, base_url!);
});
});
// fix coming - playwright does not currently have access to the aws headers, skipping for now
@ -450,10 +544,6 @@ test.describe(`${process.env.E2E_TEST_ENV} - Breaches Dashboard - Overview Card`
await automaticRemovePage.open();
await expect(automaticRemovePage.forwardArrowButton).toBeVisible();
const escapeRegExp = (str: string): string => {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
const breachString0 = "high-risk-data-breaches";
const breachString1 = "leaked-passwords";
const breachString2 = "security-recommendations";
@ -535,26 +625,6 @@ test.describe(`${process.env.E2E_TEST_ENV} - Breaches Dashboard - Footer`, () =>
"https://testrail.stage.mozaws.net/index.php?/cases/view/2463570",
});
const clickOnATagCheckDomain = async (
aTag: Locator,
host: string,
path: string | RegExp = /.*/,
) => {
if (typeof path === "string") path = new RegExp(".*" + path + ".*");
host = host.replace(/^(https?:\/\/)/, "");
const href = await aTag.getAttribute("href");
if (href === null) return false;
await page.goto(href);
const currentUrl = new URL(page.url());
const perceivedHost = currentUrl.hostname;
const perceivedPath = currentUrl.pathname;
expect(perceivedHost).toBe(host);
expect(path.test(perceivedPath)).toBeTruthy();
await page.goBack();
};
expect(process.env["E2E_TEST_BASE_URL"]).toBeTruthy();
const baseUrl = process.env["E2E_TEST_BASE_URL"]!;
await dashboardPage.goToDashboard();
@ -563,31 +633,37 @@ test.describe(`${process.env.E2E_TEST_ENV} - Breaches Dashboard - Footer`, () =>
dashboardPage.mozillaLogoFooter,
"www.mozilla.org",
/^(\/en-US\/)?$/,
page,
);
await clickOnATagCheckDomain(
dashboardPage.allBreachesFooter,
baseUrl,
"/breaches",
page,
);
await clickOnATagCheckDomain(
dashboardPage.faqsFooter,
"support.mozilla.org",
/.*\/kb.*\/mozilla-monitor-faq.*/,
page,
);
await clickOnATagCheckDomain(
dashboardPage.termsOfServiceFooter,
"www.mozilla.org",
"/about/legal/terms/subscription-services/",
page,
);
await clickOnATagCheckDomain(
dashboardPage.privacyNoticeFooter,
"www.mozilla.org",
"/privacy/subscription-services/",
page,
);
await clickOnATagCheckDomain(
dashboardPage.githubFooter,
"github.com",
"/mozilla/blurts-server",
page,
);
});
});
@ -642,3 +718,41 @@ test.describe(`${process.env.E2E_TEST_ENV} - Breaches Dashboard - Navigation`, (
);
});
});
test.describe(`${process.env.E2E_TEST_ENV} - Breaches Dashboard - Data Breaches`, () => {
test.beforeEach(async ({ landingPage, page, authPage }) => {
const emailToUse = process.env
.E2E_TEST_ACCOUNT_EMAIL_EXPOSURES_STARTED as string;
const pwdToUse = process.env.E2E_TEST_ACCOUNT_PASSWORD as string;
expect(emailToUse).not.toBeUndefined();
expect(pwdToUse).not.toBeUndefined();
await forceLoginAs(emailToUse, pwdToUse, page, landingPage, authPage);
});
test("Verify that the High risk data breaches step is displayed correctly", async ({
dashboardPage,
dataBrokersPage,
page,
}) => {
test.info().annotations.push({
type: "testrail",
description:
"https://testrail.stage.mozaws.net/index.php?/cases/view/2463592",
});
await expect(dashboardPage.upsellScreenButton).toBeVisible();
await dashboardPage.upsellScreenButton.click();
await page.waitForURL(/.*\/data-broker-profiles\/view-data-brokers\/?/);
await expect(dataBrokersPage.forwardArrowButton).toBeVisible();
await dataBrokersPage.forwardArrowButton.click();
await page.waitForURL(/.*\/high-risk-data-breaches.*/);
const highRiskDataBreachLi = page.locator(
'li:has(div:has-text("High risk data breaches"))',
);
await expect(highRiskDataBreachLi).toBeVisible();
await expect(highRiskDataBreachLi).toHaveAttribute("aria-current", "step");
await expect(
highRiskDataBreachLi.locator("div").getByText("High risk data breaches"),
).toBeVisible();
});
});

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

@ -154,4 +154,50 @@ test.describe(`${process.env.E2E_TEST_ENV} - Breach Scan, Monitor Plus Purchase
});
await expect(dashboardPage.plusSubscription).toBeVisible();
});
test("Verify that the user can purchase the plus subscription with a PayPal account - yearly", async ({
purchasePage,
dashboardPage,
context,
}) => {
test.skip(
process.env.E2E_TEST_ENV === "production",
"payment method test not available in production",
);
// link to testrail case
test.info().annotations.push({
type: "testrail",
description:
"https://testrail.stage.mozaws.net/index.php?/cases/view/2463628",
});
await purchasePage.gotoPurchaseFromDashboard(dashboardPage, true);
// fill out subscription payment
await purchasePage.authorizationCheckbox.check();
await purchasePage.fillOutPaypalInfo(context);
await purchasePage.postPaymentPageCheck(dashboardPage);
});
test("Verify that the user can purchase the plus subscription with a PayPal account - monthly", async ({
purchasePage,
dashboardPage,
context,
}) => {
test.skip(
process.env.E2E_TEST_ENV === "production",
"payment method test not available in production",
);
// link to testrail case
test.info().annotations.push({
type: "testrail",
description:
"https://testrail.stage.mozaws.net/index.php?/cases/view/2463628",
});
await purchasePage.gotoPurchaseFromDashboard(dashboardPage, false);
// fill out subscription payment
await purchasePage.authorizationCheckbox.check();
await purchasePage.fillOutPaypalInfo(context);
await purchasePage.postPaymentPageCheck(dashboardPage);
});
});

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

@ -2,8 +2,10 @@
* 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/. */
import { request, Page } from "@playwright/test";
import { expect, request, Page, Locator, test } from "@playwright/test";
import { InternalServerError } from "../../utils/error.js";
import { LandingPage } from "../pages/landingPage.js";
import { AuthPage } from "../pages/authPage.js";
enum ENV {
local = "local",
@ -160,3 +162,63 @@ export function removeUnicodeChars(text: string): string {
// eslint-disable-next-line no-control-regex
return text.replace(/[^\x00-\x7F]/g, "");
}
export const clickOnATagCheckDomain = async (
aTag: Locator,
host: string | RegExp = /.*/,
path: string | RegExp = /.*/,
page: Page,
) => {
if (typeof host === "string")
host = new RegExp(escapeRegExp(host.replace(/^(https?:\/\/)/, "")));
if (typeof path === "string") path = new RegExp(".*" + path + ".*");
const href = await aTag.getAttribute("href");
if (href === null) return false;
await page.goto(href);
const currentUrl = new URL(page.url());
const perceivedHost = currentUrl.hostname;
const perceivedPath = currentUrl.pathname;
expect(host.test(perceivedHost)).toBeTruthy();
expect(path.test(perceivedPath)).toBeTruthy();
await page.goBack();
};
export const escapeRegExp = (str: string): string => {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
export const forceLoginAs = async (
email: string,
password: string,
page: Page,
landingPage: LandingPage,
authPage: AuthPage,
) => {
test.slow(
true,
"this test runs through the welcome scan flow, increasing timeout to address it",
);
// speed up test by ignoring non necessary requests
await page.route(/(analytics)/, async (route) => {
await route.abort();
});
await page.context().clearCookies();
await landingPage.open();
await landingPage.goToSignIn();
let visible = true;
try {
await expect(authPage.useDifferentEmailButton).toBeVisible();
} catch {
visible = false;
}
if (visible) {
await authPage.useDifferentEmailButton.click();
await page.waitForURL(/^(?!.*signin).*/);
}
await authPage.signIn(email, password);
await page.waitForURL("**/user/dashboard");
await expect(page).toHaveURL(/.*\/user\/dashboard.*/);
};

19
src/knex-tables.d.ts поставляемый
Просмотреть файл

@ -176,6 +176,17 @@ declare module "knex/types/tables" {
"id" | "created_at" | "updated_at"
>;
interface SubscriberCouponRow {
id: number;
subscriber_id: number;
coupon_code: string;
created_at: Date;
}
type SubscriberCouponAutoInsertedColumns = Extract<
keyof SubscriberCouponRow,
"id" | "subscriber_id" | "created_at"
>;
interface BreachRow {
id: number;
name: string;
@ -339,6 +350,14 @@ declare module "knex/types/tables" {
Pick<SubscriberRow, "updated_at">
>;
subscriber_coupons: Knex.CompositeTableType<
SubscriberCouponRow,
// On updates, auto-generated columns cannot be set, and nullable columns are optional:
Omit<SubscriberCouponRow, SubscriberAutoInsertedColumns>,
// On updates, don't allow updating the ID; all other fields are optional:
Partial<Omit<SubscriberCouponRow, "id">>
>;
email_addresses: Knex.CompositeTableType<
EmailAddressRow,
// On updates, auto-generated columns cannot be set, and nullable columns are optional: