diff --git a/locales-pending/premium.ftl b/locales-pending/premium.ftl index f18c74e05a0..62198bb7094 100644 --- a/locales-pending/premium.ftl +++ b/locales-pending/premium.ftl @@ -37,7 +37,19 @@ exposure-chart-legend-heading-type = Exposure exposure-chart-legend-heading-nr = Number # Variables: # $nr (number) - Number of a particular type of exposure found for the user -exposure-chart-legend-value-nr = { $nr }x +exposure-chart-legend-value-nr = { $nr }× +exposure-chart-caption = This chart shows how many times your info is actively exposed. + +modal-active-number-of-exposures-title = About your number of active exposures +# Variables: +# $limit (number) - Number of email addresses included in the plan +modal-active-number-of-exposures-part-one = + { $limit -> + [one] This chart includes the total number of times we found each type of data exposed across all data broker profiles and all data breaches for the { $limit } email address that you are currently monitoring. + *[other] This chart includes the total number of times we found each type of data exposed across all data broker profiles and all data breaches for up to { $limit } email addresses that you are currently monitoring. + } +modal-active-number-of-exposures-part-two = For example, if you have 10 exposures of your phone number, that might mean one phone number is exposed across 10 different sites, or it could mean 2 different phone numbers were exposed across 5 different sites. +modal-active-number-of-exposures-part-three = This chart does not include any exposures that are in-progress of being auto-removed. Once your exposures are fixed, they will be added to your total number of fixed exposures on the Fixed page. # Here's What We Fixed Progress Card diff --git a/locales/en/data-classes.ftl b/locales/en/data-classes.ftl index c9cd58ba846..9df3b0dd9d5 100644 --- a/locales/en/data-classes.ftl +++ b/locales/en/data-classes.ftl @@ -59,6 +59,7 @@ financial-investments = Financial investments financial-transactions = Financial transactions fitness-levels = Fitness levels flights-taken = Flights taken +full-names = Full name genders = Genders geographic-locations = Geographic locations government-issued-ids = Government issued IDs diff --git a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/Dashboard.stories.tsx b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/Dashboard.stories.tsx index 95184c5a8df..10455d751ad 100644 --- a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/Dashboard.stories.tsx +++ b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/Dashboard.stories.tsx @@ -8,9 +8,9 @@ import { View as DashboardEl } from "./View"; import { HibpLikeDbBreach } from "../../../../../../utils/hibp"; import { ScanResult } from "../../../../../functions/server/onerep"; import { Shell } from "../../../Shell"; -import { StateAbbr } from "../../../../../../utils/states"; import { getEnL10nSync } from "../../../../../functions/server/mockL10n"; import { createRandomScan } from "../../../../../../apiMocks/mockData"; +import { DashboardSummary } from "../../../../../functions/server/dashboard"; const meta: Meta = { title: "Pages/Dashboard", @@ -19,33 +19,6 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const _ScanResultMockItem: ScanResult = { - id: 1, - profile_id: 1, - first_name: "John", - last_name: "Doe", - middle_name: "string", - age: `${30}`, - addresses: [ - { - city: "123", - state: "State" as StateAbbr, - street: "Street", - zip: "123456", - }, - ], - phones: [""], - emails: [""], - data_broker: "Familytree.com", - created_at: "11/09/23", - updated_at: "11/09/23", - url: "", - link: "", - relatives: [], - status: "new", - data_broker_id: 0, -}; - const BreachMockItem1: HibpLikeDbBreach = { AddedDate: new Date("2018-11-07T14:48:00.000Z"), BreachDate: "11/09/23", @@ -138,7 +111,57 @@ const breachItemArraySample: HibpLikeDbBreach[] = [ BreachMockItem4, ]; -export const Dashboard: Story = { +const dashboardSummaryNoScan: DashboardSummary = { + dataBreachTotalNum: 0, + dataBrokerTotalNum: 51, + totalExposures: 51, + allExposures: { + emailAddresses: 0, + phoneNumbers: 0, + addresses: 0, + familyMembers: 0, + fullNames: 0, + socialSecurityNumbers: 0, + ipAddresses: 0, + passwords: 0, + creditCardNumbers: 0, + pinNumbers: 0, + securityQuestions: 0, + }, + sanitizedExposures: [ + { "email-addresses": 30 }, + { "phone-numbers": 19 }, + { "social-security-numbers": 2 }, + ], +}; + +const dashboardSummaryWithScan: DashboardSummary = { + dataBreachTotalNum: 88, + dataBrokerTotalNum: 217, + totalExposures: 305, + allExposures: { + emailAddresses: 0, + phoneNumbers: 0, + addresses: 0, + familyMembers: 0, + fullNames: 0, + socialSecurityNumbers: 0, + ipAddresses: 0, + passwords: 0, + creditCardNumbers: 0, + pinNumbers: 0, + securityQuestions: 0, + }, + sanitizedExposures: [ + { "physical-addresses": 90 }, + { "family-members-names": 29 }, + { "full-names": 98 }, + { "phone-numbers": 8 }, + { "other-data-class": 80 }, + ], +}; + +export const DashboardWithScan: Story = { render: () => ( + + ), +}; + +export const DashboardWithoutScan: Story = { + render: () => ( + + ), diff --git a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/Dashboard.test.tsx b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/Dashboard.test.tsx index 4518ec13e30..c8f4c4aa186 100644 --- a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/Dashboard.test.tsx +++ b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/Dashboard.test.tsx @@ -6,7 +6,10 @@ import { it, expect } from "@jest/globals"; import { render } from "@testing-library/react"; import { composeStory } from "@storybook/react"; import { axe } from "jest-axe"; -import Meta, { Dashboard } from "./Dashboard.stories"; +import Meta, { + DashboardWithScan, + DashboardWithoutScan, +} from "./Dashboard.stories"; jest.mock("next/navigation", () => ({ useRouter: jest.fn(), @@ -14,7 +17,13 @@ jest.mock("next/navigation", () => ({ })); it("passes the axe accessibility test suite", async () => { - const ComposedDashboard = composeStory(Dashboard, Meta); + const ComposedDashboard = composeStory(DashboardWithScan, Meta); + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); +}); + +it("passes the axe accessibility test suite", async () => { + const ComposedDashboard = composeStory(DashboardWithoutScan, Meta); const { container } = render(); expect(await axe(container)).toHaveNoViolations(); }); diff --git a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/DashboardTopBanner.module.scss b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/DashboardTopBanner.module.scss index 3cd2b5ea124..34abb809f70 100644 --- a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/DashboardTopBanner.module.scss +++ b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/DashboardTopBanner.module.scss @@ -2,30 +2,59 @@ .container { display: flex; - flex-direction: row; + flex-direction: column; border: 1px solid rgba($color-purple-50, 0.2); border-radius: $border-radius-md; - padding: $layout-lg $spacing-xl; + padding: $layout-xs; background-color: $color-white; background-image: url("./images/dashboard-top-banner-wave.svg"); background-size: cover; background-position: center; + gap: $layout-xs; + align-items: center; - .content { + @media screen and (min-width: $screen-md) { + padding: $layout-lg $layout-xs; + flex-direction: row; + } + + .explainerContentWrapper { + padding: $spacing-sm; display: flex; - flex-direction: column; - gap: $spacing-md; + align-items: center; - h3 { - font: $text-title-2xs; + @media screen and (min-width: $screen-md) { + padding: $spacing-md; + justify-content: center; + flex: 0.5 1 0%; + + @media screen and (min-width: $screen-xl) { + flex: 1 1 0%; + } } - .cta { - align-self: start; + .explainerContent { + display: flex; + gap: $spacing-md; + flex-direction: column; + + @media screen and (min-width: $screen-md) { + max-width: $content-sm; + } + + h3 { + font: $text-title-2xs; + } + + .cta { + align-self: start; + } } } - & > * { - flex: 1 1 0%; + .chart { + @media screen and (min-width: $screen-md) { + flex: 1 1 0%; + } } } diff --git a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/DashboardTopBanner.tsx b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/DashboardTopBanner.tsx index 3ca489c4aef..cc2dffecf67 100644 --- a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/DashboardTopBanner.tsx +++ b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/DashboardTopBanner.tsx @@ -3,10 +3,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import styles from "./DashboardTopBanner.module.scss"; -import { ReactElement } from "react"; import { Button } from "../../../../../components/server/Button"; import { useL10n } from "../../../../../hooks/l10n"; -import { useRouter } from "next/navigation"; +import { DoughnutChart as Chart } from "../../../../../components/client/Chart"; +import { DashboardSummary } from "../../../../../functions/server/dashboard"; export type DashboardTopBannerProps = { type: @@ -14,13 +14,19 @@ export type DashboardTopBannerProps = { | "DataBrokerScanUpsellContent" | "NoExposuresFoundContent" | "ResumeBreachResolutionContent" - | "YourDataIsProtected"; - chart: ReactElement; + | "YourDataIsProtectedContent"; + bannerData: DashboardSummary; }; export const DashboardTopBanner = (props: DashboardTopBannerProps) => { const l10n = useL10n(); - const router = useRouter(); + + const chartData: [string, number][] = props.bannerData.sanitizedExposures.map( + (obj) => { + const [key, value] = Object.entries(obj)[0]; + return [l10n.getString(key), value]; + } + ); const contentData = { LetsFixDataContent: { @@ -28,17 +34,15 @@ export const DashboardTopBanner = (props: DashboardTopBannerProps) => { description: l10n.getString( "dashboard-top-banner-protect-your-data-description", { - // TODO: Replace all mocked exposure data - data_breach_total_num: 95, - data_broker_total_num: 15, + data_breach_total_num: props.bannerData.dataBreachTotalNum, + data_broker_total_num: props.bannerData.dataBrokerTotalNum, } ), cta: { content: l10n.getString("dashboard-top-banner-protect-your-data-cta"), onClick: () => { - router.push( - `/redesign/user/dashboard/fix/data-broker-profiles/view-data-brokers` - ); + window.location.href = + "/redesign/user/dashboard/fix/data-broker-profiles/view-data-brokers"; }, }, }, @@ -83,6 +87,7 @@ export const DashboardTopBanner = (props: DashboardTopBannerProps) => { description: l10n.getString( "dashboard-top-banner-lets-keep-protecting-description", { + //TODO: Add remaining total exposures remaining_exposures_total_num: 40, } ), @@ -95,13 +100,14 @@ export const DashboardTopBanner = (props: DashboardTopBannerProps) => { }, }, }, - YourDataIsProtected: { + YourDataIsProtectedContent: { headline: l10n.getString( "dashboard-top-banner-your-data-is-protected-title" ), description: l10n.getString( "dashboard-top-banner-your-data-is-protected-description", { + //TODO: Add original count of exposures starting_exposure_total_num: 100, } ), @@ -120,9 +126,9 @@ export const DashboardTopBanner = (props: DashboardTopBannerProps) => { return (
-
+
{content && ( - <> +

{content.headline}

{content.description}

@@ -130,10 +136,12 @@ export const DashboardTopBanner = (props: DashboardTopBannerProps) => { {content.cta.content} - +
)}
-
Chart goes here
+
+ +
); }; diff --git a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/View.tsx b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/View.tsx index 6f43c48b405..d6c1e2b9651 100644 --- a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/View.tsx +++ b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/View.tsx @@ -22,18 +22,18 @@ import { useState } from "react"; import { ScanResult } from "../../../../../functions/server/onerep"; import { HibpLikeDbBreach } from "../../../../../../utils/hibp"; import { BundledVerifiedEmails } from "../../../../../../utils/breaches"; +import { DashboardSummary } from "../../../../../functions/server/dashboard"; export type Props = { user: Session["user"]; userBreaches: UserBreaches; - isUserScannedResults: boolean; userScannedResults: ScanResult[]; + bannerData: DashboardSummary; locale: string; }; export const View = (props: Props) => { const l10n = useL10n(); - const totalBreaches = props.userBreaches.breachesData.verifiedEmails.reduce( (count, emailData) => count + emailData.breaches.length, 0 @@ -208,6 +208,7 @@ export const View = (props: Props) => { ); } ); + const isScanResultItemsEmpty = props.userScannedResults.length === 0; return (
@@ -218,7 +219,14 @@ export const View = (props: Props) => {
- } /> +

{l10n.getString("dashboard-exposures-area-headline")} @@ -235,9 +243,7 @@ export const View = (props: Props) => {

    - {props.isUserScannedResults - ? exposureCardElems - : breachExposureCards} + {isScanResultItemsEmpty ? breachExposureCards : exposureCardElems}
diff --git a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/page.tsx b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/page.tsx index 5bcf377f4dd..d4dd5c8f000 100644 --- a/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/page.tsx +++ b/src/app/(proper_react)/redesign/(authenticated)/user/dashboard/page.tsx @@ -13,7 +13,7 @@ import { getLocale } from "../../../../../functions/server/l10n"; import { getOnerepProfileId } from "../../../../../../db/tables/subscribers"; import { authOptions } from "../../../../../api/utils/auth"; import { getLatestOnerepScan } from "../../../../../../db/tables/onerep_scans"; - +import { dashboardSummary } from "../../../../../functions/server/dashboard"; export default async function DashboardPage() { const session = await getServerSession(authOptions); if (!session?.user?.subscriber?.id) { @@ -35,6 +35,7 @@ export default async function DashboardPage() { const scanResult = await getLatestOnerepScan(profileId); const scanResultItems = scanResult?.onerep_scan_results?.data ?? []; const breaches = await getUserBreaches({ user: session.user }); + const summary = dashboardSummary(scanResultItems, breaches); const locale = getLocale(); return ( @@ -43,7 +44,7 @@ export default async function DashboardPage() { userScannedResults={scanResultItems} userBreaches={breaches} locale={locale} - isUserScannedResults={!!scanResultItems} + bannerData={summary} /> ); } diff --git a/src/app/components/client/Chart.module.scss b/src/app/components/client/Chart.module.scss index 936d9580ef1..f91196c53b0 100644 --- a/src/app/components/client/Chart.module.scss +++ b/src/app/components/client/Chart.module.scss @@ -1,15 +1,37 @@ @import "../../tokens"; .chartContainer { - width: $content-sm; text-align: center; display: flex; + gap: $spacing-lg; flex-direction: column; align-items: center; figcaption { + text-align: center; + display: flex; + align-items: center; + gap: $spacing-xs; font: $text-body-xs; font-style: italic; + + button { + @include question-mark-circle-btn; + } + } +} + +.modalBodyContent { + display: flex; + flex-direction: column; + gap: $spacing-sm; + + .confirmButtonWrapper { + align-self: center; + display: flex; + flex-direction: column; + min-width: $content-xs; + padding-block-start: $spacing-md; } } @@ -19,12 +41,20 @@ .chartAndLegendWrapper { display: flex; - gap: $spacing-2xl; - min-width: $content-xs; + flex-direction: column; + gap: $spacing-md; + + @media screen and (min-width: $screen-lg) { + flex-direction: row; + gap: $spacing-xl; + } .chart { - flex: 1 0 $content-sm; + flex: 1; + width: 100%; + max-width: 250px; + // Font size set in Chart.tsx .headingNr { font-family: var(--font-inter); font-weight: 600; @@ -43,10 +73,13 @@ } .legend { - flex: 0 0 $content-xs; - height: auto; - display: flex; - align-items: center; + align-self: center; + + @media screen and (min-width: $screen-lg) { + height: auto; + display: flex; + align-items: center; + } thead { // These styles are taken from @@ -69,6 +102,7 @@ font: $text-body-xs; td { + padding-block: $spacing-xs; padding-inline: $spacing-xs; } @@ -95,111 +129,111 @@ } } .slice:nth-child(3) { - stroke: $color-purple-80; + stroke: $color-purple-70; } .legend tbody tr:nth-child(2) { .chartAndLegendWrapper:has(.slice:nth-child(3):hover) & { - color: $color-purple-80; + color: $color-purple-70; } rect { - fill: $color-purple-80; + fill: $color-purple-70; } } .slice:nth-child(4) { - stroke: $color-purple-70; + stroke: $color-purple-50; } .legend tbody tr:nth-child(3) { .chartAndLegendWrapper:has(.slice:nth-child(4):hover) & { - color: $color-purple-70; + color: $color-purple-50; } rect { - fill: $color-purple-70; + fill: $color-purple-50; } } .slice:nth-child(5) { - stroke: $color-purple-60; + stroke: $color-purple-30; } .legend tbody tr:nth-child(4) { .chartAndLegendWrapper:has(.slice:nth-child(5):hover) & { - color: $color-purple-60; + color: $color-purple-30; } rect { - fill: $color-purple-60; + fill: $color-purple-30; } } .slice:nth-child(6) { - stroke: $color-purple-50; + stroke: $color-purple-10; } .legend tbody tr:nth-child(5) { .chartAndLegendWrapper:has(.slice:nth-child(6):hover) & { - color: $color-purple-50; + color: $color-purple-10; } rect { - fill: $color-purple-50; + fill: $color-purple-10; } } .slice:nth-child(7) { - stroke: $color-purple-40; + stroke: $color-purple-80; } .legend tbody tr:nth-child(6) { .chartAndLegendWrapper:has(.slice:nth-child(7):hover) & { - color: $color-purple-40; + color: $color-purple-80; } rect { - fill: $color-purple-40; + fill: $color-purple-80; } } .slice:nth-child(8) { - stroke: $color-purple-30; + stroke: $color-purple-60; } .legend tbody tr:nth-child(7) { .chartAndLegendWrapper:has(.slice:nth-child(8):hover) & { - color: $color-purple-30; + color: $color-purple-60; } rect { - fill: $color-purple-30; + fill: $color-purple-60; } } .slice:nth-child(9) { - stroke: $color-purple-20; + stroke: $color-purple-40; } .legend tbody tr:nth-child(8) { .chartAndLegendWrapper:has(.slice:nth-child(9):hover) & { - color: $color-purple-20; + color: $color-purple-40; } rect { - fill: $color-purple-20; + fill: $color-purple-40; } } .slice:nth-child(10) { - stroke: $color-purple-10; + stroke: $color-purple-20; } .legend tbody tr:nth-child(9) { .chartAndLegendWrapper:has(.slice:nth-child(10):hover) & { - color: $color-purple-10; + color: $color-purple-20; } rect { - fill: $color-purple-10; + fill: $color-purple-20; } } .slice:nth-child(11) { - stroke: $color-purple-05; + stroke: $color-violet-90; } .legend tbody tr:nth-child(10) { .chartAndLegendWrapper:has(.slice:nth-child(11):hover) & { - color: $color-purple-05; + color: $color-violet-90; } rect { - fill: $color-purple-05; + fill: $color-violet-90; } } /* stylelint-enable no-descending-specificity */ diff --git a/src/app/components/client/Chart.tsx b/src/app/components/client/Chart.tsx index f4ba068473a..73cd9c884d5 100644 --- a/src/app/components/client/Chart.tsx +++ b/src/app/components/client/Chart.tsx @@ -7,13 +7,28 @@ import { CSSProperties } from "react"; import { useL10n } from "../../hooks/l10n"; import styles from "./Chart.module.scss"; +import { QuestionMarkCircle } from "../server/Icons"; +import { useOverlayTrigger } from "react-aria"; +import { useOverlayTriggerState } from "react-stately"; +import { Button } from "../server/Button"; +import { ModalOverlay } from "./dialog/ModalOverlay"; +import { Dialog } from "./dialog/Dialog"; +import ModalImage from "../client/assets/modal-default-img.svg"; +import Image from "next/image"; export type Props = { data: Array<[string, number]>; - totalExposures: number; }; + export const DoughnutChart = (props: Props) => { const l10n = useL10n(); + + const explainerDialogState = useOverlayTriggerState({}); + const explainerDialogTrigger = useOverlayTrigger( + { type: "dialog" }, + explainerDialogState + ); + const sumOfFixedExposures = props.data.reduce( (total, [_label, num]) => total + num, 0 @@ -58,92 +73,141 @@ export const DoughnutChart = (props: Props) => { /> ); }); + const modalContent = ( +
+

+ {l10n.getString("modal-active-number-of-exposures-part-one", { + limit: 5, + })} +

+

{l10n.getString("modal-active-number-of-exposures-part-two")}

+

{l10n.getString("modal-active-number-of-exposures-part-three")}

+
+ +
+
+ ); return ( -
-
- ", "") - .replace("", "") - .replace("", "")} - viewBox={`0 0 ${diameter} ${diameter}`} - className={styles.chart} - > - - {slices} - {l10n.getFragment("exposure-chart-heading", { - elems: { - nr: ( - - ), - label: ( - - ), - }, - vars: { nr: sumOfFixedExposures }, - })} - -
- - - - {/* The first column contains the chart colour, - which is irrelevant to screen readers. */} - - - - - - {props.data.map(([label, num]) => ( - - - - + <> +
+
+ ", "") + .replace("", "") + .replace("", "")} + viewBox={`0 0 ${diameter} ${diameter}`} + className={styles.chart} + > + + {slices} + {l10n.getFragment("exposure-chart-heading", { + elems: { + nr: ( + + ), + label: ( + + ), + }, + vars: { nr: sumOfFixedExposures }, + })} + +
+
- {l10n.getString("exposure-chart-legend-heading-type")}{l10n.getString("exposure-chart-legend-heading-nr")}
- - - - {label} - {l10n.getString("exposure-chart-legend-value-nr", { - nr: num, - })} -
+ + + {/* The first column contains the chart colour, + which is irrelevant to screen readers. */} + + - ))} - -
+ + {l10n.getString("exposure-chart-legend-heading-type")} + {l10n.getString("exposure-chart-legend-heading-nr")}
+ + + {props.data.map(([label, num]) => ( + + + + + + + {label} + + {l10n.getString("exposure-chart-legend-value-nr", { + nr: num, + })} + + + ))} + + +
-
-
- This chart shows the total number of exposures that are fixed ( - {sumOfFixedExposures} out of {props.totalExposures}). -
- +
+ {l10n.getString("exposure-chart-caption")} + +
+ + {explainerDialogState.isOpen && ( + + } + onDismiss={() => explainerDialogState.close()} + > + {modalContent} + + + )} + ); }; diff --git a/src/app/components/client/ExposuresFilter.module.scss b/src/app/components/client/ExposuresFilter.module.scss index e314a4cd783..e2fbcaf5243 100644 --- a/src/app/components/client/ExposuresFilter.module.scss +++ b/src/app/components/client/ExposuresFilter.module.scss @@ -20,12 +20,7 @@ } button { - all: unset; - cursor: pointer; - - &:hover { - color: $color-grey-30; - } + @include question-mark-circle-btn; } ul.filterHeaderList { diff --git a/src/app/components/client/ProgressCard.module.scss b/src/app/components/client/ProgressCard.module.scss index 010fd94d757..e2574d606c0 100644 --- a/src/app/components/client/ProgressCard.module.scss +++ b/src/app/components/client/ProgressCard.module.scss @@ -18,14 +18,7 @@ font-weight: 600; button { - all: unset; - cursor: pointer; - height: 25px; // height of the button - color: $color-grey-40; - - &:hover { - color: $color-grey-30; - } + @include question-mark-circle-btn; } } diff --git a/src/app/components/client/stories/Chart.stories.ts b/src/app/components/client/stories/Chart.stories.ts index b7dd0662cba..eb921c71884 100644 --- a/src/app/components/client/stories/Chart.stories.ts +++ b/src/app/components/client/stories/Chart.stories.ts @@ -26,6 +26,5 @@ const data: Array<[string, number]> = [ export const FixedExposures: Story = { args: { data: data, - totalExposures: 309, }, }; diff --git a/src/app/functions/server/dashboard.ts b/src/app/functions/server/dashboard.ts new file mode 100644 index 00000000000..76455bb686a --- /dev/null +++ b/src/app/functions/server/dashboard.ts @@ -0,0 +1,177 @@ +/* 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 { BreachDataTypes } from "../../../utils/breachResolution"; +import type { UserBreaches } from "./getUserBreaches"; +import type { ScanResult } from "./onerep"; +export interface DashboardSummary { + dataBreachTotalNum: number; + dataBrokerTotalNum: number; + totalExposures: number; + allExposures: { + // shared + emailAddresses: number; + phoneNumbers: number; + + // data brokers + addresses: number; + familyMembers: number; + fullNames: number; + + // data breaches + socialSecurityNumbers: number; + ipAddresses: number; + passwords: number; + creditCardNumbers: number; + pinNumbers: number; + securityQuestions: number; + }; + sanitizedExposures: Array>; +} + +const exposureKeyMap: Record = { + emailAddresses: "email-addresses", + phoneNumbers: "phone-numbers", + + // data brokers + addresses: "physical-addresses", + familyMembers: "family-members-names", + fullNames: "full-names", + + // data breaches + socialSecurityNumbers: "social-security-numbers", + ipAddresses: "ip-addresses", + passwords: "passwords", + creditCardNumbers: "credit-cards", + pinNumbers: "pins", + securityQuestions: "security-questions-and-answers", +}; + +export function dashboardSummary( + scannedResults: ScanResult[], + { breachesData }: UserBreaches +): DashboardSummary { + const summary: DashboardSummary = { + dataBreachTotalNum: 0, + dataBrokerTotalNum: scannedResults.length, + totalExposures: 0, + allExposures: { + emailAddresses: 0, + phoneNumbers: 0, + addresses: 0, + familyMembers: 0, + fullNames: 0, + + // data breaches + socialSecurityNumbers: 0, + ipAddresses: 0, + passwords: 0, + creditCardNumbers: 0, + pinNumbers: 0, + securityQuestions: 0, + }, + sanitizedExposures: [], + }; + + // calculate broker summary from scanned results + if (scannedResults) { + scannedResults.forEach((r) => { + // count email + summary.totalExposures += r.emails.length; + summary.allExposures.emailAddresses += r.emails.length; + + // count phones + summary.totalExposures += r.phones.length; + summary.allExposures.phoneNumbers += r.phones.length; + + // count physical addresses + summary.totalExposures += r.addresses.length; + summary.allExposures.addresses += r.addresses.length; + + // count relatives + summary.totalExposures += r.relatives.length; + summary.allExposures.familyMembers += r.relatives.length; + + // count full name + summary.totalExposures++; + summary.allExposures.fullNames++; + }); + } + + const uniqueBreaches = new Set(); + + // calculate breaches summary from breaches data + // TODO: Modify after MNTOR-1947: Refactor user breaches object + if (breachesData.verifiedEmails) { + for (const emailBreaches of breachesData.verifiedEmails) { + const breaches = emailBreaches.breaches; + breaches.forEach((b) => { + uniqueBreaches.add(b.Name); + const dataClasses = b.DataClasses ?? []; + + // count password + if (dataClasses.includes(BreachDataTypes.Passwords)) { + summary.totalExposures++; + summary.allExposures.passwords++; + } + + // count ssn + if (dataClasses.includes(BreachDataTypes.SSN)) { + summary.totalExposures++; + summary.allExposures.socialSecurityNumbers++; + } + + // count IP + if (dataClasses.includes(BreachDataTypes.IP)) { + summary.totalExposures++; + summary.allExposures.ipAddresses++; + } + + // count credit card + if (dataClasses.includes(BreachDataTypes.CreditCard)) { + summary.totalExposures++; + summary.allExposures.creditCardNumbers++; + } + + // count pin numbers + if (dataClasses.includes(BreachDataTypes.PIN)) { + summary.totalExposures++; + summary.allExposures.pinNumbers++; + } + + // count security questions + if (dataClasses.includes(BreachDataTypes.SecurityQuestions)) { + summary.totalExposures++; + summary.allExposures.securityQuestions++; + } + }); + } + } + + // count unique breaches + summary.dataBreachTotalNum = uniqueBreaches.size; + + return sanitizeExposures(summary); +} + +function sanitizeExposures(summary: DashboardSummary): DashboardSummary { + const NUM_OF_TOP_EXPOSURES = 4; + const { allExposures } = summary; + const sanitizedExposures = Object.entries(allExposures) + .sort((a, b) => b[1] - a[1]) + .map((e) => { + const key = exposureKeyMap[e[0]]; + return { [key]: e[1] }; + }) + .splice(0, NUM_OF_TOP_EXPOSURES); + const other = sanitizedExposures.reduce( + (total, cur) => total - (Object.values(cur).pop() || 0), + summary.totalExposures + ); + sanitizedExposures.push({ ["other-data-class"]: other }); + + summary.sanitizedExposures = sanitizedExposures; + console.debug({ sanitizedExposures }); + return summary; +} diff --git a/src/app/functions/universal/user.ts b/src/app/functions/universal/user.ts index f72cc162b3d..7c605eb4f23 100644 --- a/src/app/functions/universal/user.ts +++ b/src/app/functions/universal/user.ts @@ -6,7 +6,7 @@ import { Session } from "next-auth"; import { ISO8601DateString } from "../../../utils/parse.js"; export function hasPremium(user?: Session["user"]): boolean { - return user?.fxa?.subscriptions.includes("monitor") ?? false; + return user?.fxa?.subscriptions?.includes("monitor") ?? false; } export function canSubscribeToPremium(params: { diff --git a/src/app/tokens.scss b/src/app/tokens.scss index 450c7193d4b..4afc9b459de 100644 --- a/src/app/tokens.scss +++ b/src/app/tokens.scss @@ -235,12 +235,23 @@ $text-body-lg: 400 clamp(16px, 1.5svw, 18px) / 1.5 var(--font-inter), sans-serif $text-body-md: 400 clamp(14px, 1.3svw, 16px) / 1.5 var(--font-inter), sans-serif; $text-body-sm: 400 clamp(12px, 1.14svw, 14px) / 1.5 var(--font-inter), sans-serif; -$text-body-xs: 400 clamp(10px, 0.975svw, 12px) / 1.5 var(--font-inter), - sans-serif; +$text-body-xs: 400 12px / 1.5 var(--font-inter), sans-serif; $tab-bar-height: 100px; $width-first-column-filter-bar: 50px; +// Question circle mark for explainer dialogs +@mixin question-mark-circle-btn { + all: unset; + cursor: pointer; + height: 25px; // height of the button + color: $color-grey-40; + + &:hover { + color: $color-grey-30; + } +} + @mixin visually-hidden { // These styles are taken from // https://react-spectrum.adobe.com/react-aria/VisuallyHidden.html